From 4cadd793241b54e5246f32e03fbdc7f838cd2d59 Mon Sep 17 00:00:00 2001 From: Shane Findley Date: Fri, 3 May 2024 08:14:51 +0200 Subject: [PATCH] SONAR 22131 Monorepo for bitbucket server (#11064) --- .../project/Azure/AzureProjectCreate.tsx | 211 ++++----- .../Azure/AzureProjectCreateRenderer.tsx | 2 +- .../project/Azure/AzureProjectsList.tsx | 4 +- .../BitbucketCloudProjectCreate.tsx | 27 +- .../BitbucketImportRepositoryForm.tsx | 9 +- .../BitbucketProjectCreate.tsx | 415 +++++++++--------- .../BitbucketProjectCreateRenderer.tsx | 67 ++- .../BitbucketServer/BitbucketRepositories.tsx | 14 +- ...BitbucketServerPersonalAccessTokenForm.tsx | 4 +- .../apps/create/project/CreateProjectPage.tsx | 15 +- .../project/Github/GitHubProjectCreate.tsx | 31 +- .../Github/GitHubProjectCreateRenderer.tsx | 4 +- .../project/Gitlab/GitlabProjectCreate.tsx | 25 +- .../create/project/__tests__/Azure-it.tsx | 18 +- .../create/project/__tests__/Bitbucket-it.tsx | 61 ++- .../project/__tests__/BitbucketCloud-it.tsx | 10 +- .../apps/create/project/useProjectCreate.tsx | 72 ++- .../project/useProjectRepositorySearch.tsx | 124 +++--- .../create/project/useRepositorySearch.tsx | 102 +++++ .../resources/org/sonar/l10n/core.properties | 1 + 20 files changed, 682 insertions(+), 534 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/create/project/useRepositorySearch.tsx 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 index 576e5bd092e..7ef68d6dea5 100644 --- 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 @@ -18,21 +18,23 @@ * 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) { - const { dopSettings, isLoadingBindings, onProjectSetupDone } = props; - const [isLoading, setIsLoading] = useState(false); +export default function AzureProjectCreate({ + dopSettings, + isLoadingBindings, + onProjectSetupDone, +}: Readonly) { + 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, AzureProject>( + AlmKeys.Azure, + dopSettings, + ({ name }) => name, + ); + const [loadingRepositories, setLoadingRepositories] = useState>({}); - const [isSearching, setIsSearching] = useState(false); - const [projects, setProjects] = useState(); - const [repositories, setRepositories] = useState>({}); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState(); - const [selectedDopSetting, setSelectedDopSetting] = useState(); - const [selectedRepository, setSelectedRepository] = useState(); - 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 => { if (selectedDopSetting === undefined) { @@ -119,13 +111,13 @@ export default function AzureProjectCreate(props: Readonly) { 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) { } 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) { [onProjectSetupDone, selectedRepository?.projectName], ); + const { isSearching, onSearch, onSelectRepository, searchResults } = + useProjectRepositorySearch({ + 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) { })); 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, { 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 ? ( ) { 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 && ( ) @@ -324,12 +282,12 @@ export default function AzureProjectCreate(props: Readonly) { ) : ( ) { function transformToOptions( projects: AzureProject[], - repositories: Dict, + repositories?: Dict, ): Array>> { return projects.map(({ name: projectName }) => ({ label: projectName, - options: repositories[projectName]?.map(transformToOption) ?? [], + options: + repositories?.[projectName] !== undefined + ? repositories[projectName].map(transformToOption) + : [], })); } 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 index 42a335f7616..7db5f88a75a 100644 --- 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 @@ -51,7 +51,7 @@ export interface AzureProjectCreateRendererProps { onPersonalAccessTokenCreate: () => void; onSearch: (query: string) => void; projects?: AzureProject[]; - repositories: Dict; + repositories?: Dict; searching?: boolean; searchResults?: AzureRepository[]; searchQuery?: string; 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 index 1bdb1c44091..5cd8189660c 100644 --- 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 @@ -34,7 +34,7 @@ export interface AzureProjectsListProps { onOpenProject: (key: string) => void; onImportRepository: (repository: AzureRepository) => void; projects?: AzureProject[]; - repositories: Dict; + repositories?: Dict; 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} 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 index 56c40b24f6b..1c206512972 100644 --- 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 @@ -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) { }); const { + almInstances, handlePersonalAccessTokenCreated, handleSelectRepository, isInitialized, @@ -61,6 +62,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly) { resetLoading, resetPersonalAccessToken, searchQuery, + selectedAlmInstance, selectedDopSetting, selectedRepository, setIsInitialized, @@ -69,11 +71,10 @@ export default function BitbucketCloudProjectCreate(props: Readonly) { setSearchQuery, setShowPersonalAccessTokenForm, showPersonalAccessTokenForm, - } = useProjectCreate( + } = useProjectCreate( AlmKeys.BitbucketCloud, dopSettings, ({ slug }) => slug, - REPOSITORY_PAGE_SIZE, ); const location = useLocation(); @@ -153,7 +154,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly) { [onProjectSetupDone, selectedDopSetting], ); - const { isSearching, onSearch } = useProjectRepositorySearch( + const { isSearching, onSearch } = useRepositorySearch( AlmKeys.BitbucketCloud, fetchRepositories, isInitialized, @@ -192,21 +193,8 @@ export default function BitbucketCloudProjectCreate(props: Readonly) { /> ) : ( ({ - 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) { 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)} /> ); 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 index 457c50b8724..dbc28d6e7ee 100644 --- 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 @@ -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; searching: boolean; searchResults?: BitbucketRepository[]; } 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 index 21067c5e982..a0435481bc5 100644 --- 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 @@ -17,253 +17,244 @@ * 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 { - mounted = false; +export default function BitbucketProjectCreate({ + dopSettings, + isLoadingBindings, + onProjectSetupDone, +}: Readonly) { + 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, 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 => { + 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 | 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, { 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 => { - 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 => { - 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({ + 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, { 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 ? ( + + ) + } + repositoryOptions={repositoryOptions} + repositorySearchQuery={searchQuery} + selectedDopSetting={selectedDopSetting} + selectedRepository={selectedRepository ? transformToOption(selectedRepository) : undefined} + showPersonalAccessToken={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, +): Array>> { + return projects.map(({ name, key }) => ({ + label: name, + options: repositories?.[key] !== undefined ? repositories[key].map(transformToOption) : [], + })); +} - return ( - - ); - } +function transformToOption({ name, slug }: BitbucketRepository): LabelValueSelectOption { + return { value: slug, label: name }; } 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 index 961f319f2f4..d44187b2e24 100644 --- 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 @@ -17,56 +17,85 @@ * 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; resetPat: boolean; searching: boolean; searchResults?: BitbucketRepository[]; + selectedAlmInstance?: AlmSettingsInstance; showPersonalAccessTokenForm?: boolean; } -export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) { +export default function BitbucketProjectCreateRenderer( + props: Readonly, +) { const { almInstances, - selectedAlmInstance, - loading, + isLoading, projects, projectRepositories, - + resetPat, searching, searchResults, + selectedAlmInstance, showPersonalAccessTokenForm, - resetPat, } = props; + const isMonorepoSupported = React.useContext(AvailableFeaturesContext).includes( + Feature.MonoRepositoryPullRequestDecoration, + ); + return (
{translate('onboarding.create_project.bitbucket.title')} - {translate('onboarding.create_project.bitbucket.subtitle')} + {isMonorepoSupported ? ( + + + + ), + }} + /> + ) : ( + + )}
@@ -77,12 +106,12 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr onChangeConfig={props.onSelectedAlmInstanceChange} /> - - {!loading && !selectedAlmInstance && ( + + {!isLoading && almInstances && almInstances.length === 0 && !selectedAlmInstance && ( )} - {!loading && + {!isLoading && selectedAlmInstance && (showPersonalAccessTokenForm ? ( void; projects: BitbucketProject[]; - projectRepositories: BitbucketProjectRepositories; + projectRepositories: Dict; } 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 ( ); diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketServerPersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketServerPersonalAccessTokenForm.tsx index 8d9b8f3b4a4..52bc7809881 100644 --- a/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketServerPersonalAccessTokenForm.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketServerPersonalAccessTokenForm.tsx @@ -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; } diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx index 56ac38b6b26..1422d315c3f 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -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 { 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 ); 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 index 1c9d5f744ca..9eab22d95f5 100644 --- 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 @@ -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) { const { dopSettings, isLoadingBindings, onProjectSetupDone } = props; const { + almInstances, handleSelectRepository, isInitialized, isLoadingOrganizations, @@ -54,6 +55,7 @@ export default function GitHubProjectCreate(props: Readonly) { organizations, repositories, searchQuery, + selectedAlmInstance, selectedDopSetting, selectedRepository, setIsInitialized, @@ -65,11 +67,10 @@ export default function GitHubProjectCreate(props: Readonly) { setSelectedOrganization, selectedOrganization, setIsLoadingOrganizations, - } = useProjectCreate( + } = useProjectCreate( AlmKeys.GitHub, dopSettings, ({ key }) => key, - REPOSITORY_PAGE_SIZE, ); const [isInError, setIsInError] = useState(false); @@ -79,10 +80,10 @@ export default function GitHubProjectCreate(props: Readonly) { 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) { .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) { 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) { // 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) { /> ) : ( ({ - alm: type, - key, - url, - }))} + almInstances={almInstances} error={isInError} loadingBindings={isLoadingBindings} loadingOrganizations={isLoadingOrganizations} @@ -250,13 +247,7 @@ export default function GitHubProjectCreate(props: Readonly) { repositories={repositories} repositoryPaging={projectsPaging} searchQuery={searchQuery} - selectedAlmInstance={ - selectedDopSetting && { - alm: selectedDopSetting.type, - key: selectedDopSetting.key, - url: selectedDopSetting.url, - } - } + selectedAlmInstance={selectedAlmInstance} selectedOrganization={selectedOrganization} /> ); 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 index dc39a57ad88..c5834b26766 100644 --- 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 @@ -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( {translate('onboarding.create_project.github.choose_organization')} - {organizations.length > 0 ? ( + {organizations && organizations.length > 0 ? ( ) { const { dopSettings, isLoadingBindings, onProjectSetupDone } = props; const { + almInstances, handlePersonalAccessTokenCreated, handleSelectRepository, isInitialized, @@ -54,6 +55,7 @@ export default function GitlabProjectCreate(props: Readonly) { repositories, resetPersonalAccessToken, searchQuery, + selectedAlmInstance, selectedDopSetting, selectedRepository, setIsInitialized, @@ -64,17 +66,16 @@ export default function GitlabProjectCreate(props: Readonly) { setSearchQuery, setShowPersonalAccessTokenForm, showPersonalAccessTokenForm, - } = useProjectCreate( + } = useProjectCreate( 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) { 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) { /> ) : ( ({ - 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) { 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)} /> ); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx index 16832af1aaf..c235dce4330 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx @@ -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([]); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx index 11f52a278aa..8c372759e8d 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx @@ -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', , { - 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', , { + navigateTo: `projects/create?${queryString}`, + featureList: [Feature.MonoRepositoryPullRequestDecoration], }); } diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx index e3ecfa2ce84..d9a2b7e8a40 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx index 1c6c34e3c3e..13486d0d261 100644 --- a/server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx @@ -20,31 +20,46 @@ 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( - 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[]; + +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(); const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true); - const [organizations, setOrganizations] = useState([]); + const [organizations, setOrganizations] = useState(); const [selectedOrganization, setSelectedOrganization] = useState(); const [isLoadingRepositories, setIsLoadingRepositories] = useState(false); const [isLoadingMoreRepositories, setIsLoadingMoreRepositories] = useState(false); - const [repositories, setRepositories] = useState([]); + const [repositories, setRepositories] = useState(); const [selectedRepository, setSelectedRepository] = useState(); const [showPersonalAccessTokenForm, setShowPersonalAccessTokenForm] = useState(true); const [resetPersonalAccessToken, setResetPersonalAccessToken] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [projectsPaging, setProjectsPaging] = useState({ pageIndex: 1, - pageSize, + pageSize: REPOSITORY_PAGE_SIZE, total: 0, }); @@ -54,6 +69,26 @@ export function useProjectCreate( 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( setSelectedDopSetting(setting); setShowPersonalAccessTokenForm(true); setOrganizations([]); - setRepositories([]); + setRepositories(undefined); setSearchQuery(''); }, []); @@ -93,7 +128,16 @@ export function useProjectCreate( 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; + const selected = Object.values(repos) + .flat() + .find((repo) => getKey(repo) === repositoryKey); + setSelectedRepository(selected); + } }, [getKey, repositories, setSelectedRepository], ); @@ -124,6 +168,7 @@ export function useProjectCreate( }, [almKey, dopSettings, hasDopSettings, location, selectedDopSetting, setSelectedDopSetting]); return { + almInstances, handlePersonalAccessTokenCreated, handleSelectRepository, hasDopSettings, @@ -148,6 +193,7 @@ export function useProjectCreate( setIsLoadingOrganizations, setProjectsPaging, setOrganizations, + selectedAlmInstance, selectedOrganization, setRepositories, setResetPersonalAccessToken, diff --git a/server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx b/server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx index c4b0a5ec7c1..69adbfc788c 100644 --- a/server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx @@ -17,86 +17,94 @@ * 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, - isInitialized: boolean, - selectedDopSetting: DopSetting | undefined, - selectedOrganizationKey: string | undefined, - setSearchQuery: (query: string) => void, - showPersonalAccessTokenForm = false, -) { - const repositorySearchDebounceId = useRef(); - const [isSearching, setIsSearching] = useState(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({ + 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(); + const [isSearching, setIsSearching] = useState(false); + const [searchResults, setSearchResults] = useState(); 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 index 00000000000..d8a8fe536a1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/useRepositorySearch.tsx @@ -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, + isInitialized: boolean, + selectedDopSetting: DopSetting | undefined, + selectedOrganizationKey: string | undefined, + setSearchQuery: (query: string) => void, + showPersonalAccessTokenForm = false, +) { + const repositorySearchDebounceId = useRef(); + const [isSearching, setIsSearching] = useState(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, + }; +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index bb0980fecf1..e3f02e76d09 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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? -- 2.39.5