@@ -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<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) | |||
: [], | |||
})); | |||
} | |||
@@ -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; |
@@ -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} |
@@ -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)} | |||
/> | |||
); |
@@ -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[]; | |||
} |
@@ -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<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 }; | |||
} |
@@ -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<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 |
@@ -19,17 +19,15 @@ | |||
*/ | |||
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} | |||
/> | |||
); |
@@ -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; | |||
} |
@@ -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} | |||
/> | |||
); |
@@ -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} | |||
/> | |||
); |
@@ -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" |
@@ -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)} | |||
/> | |||
); |
@@ -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([]); |
@@ -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], | |||
}); | |||
} |
@@ -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; |
@@ -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<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, |
@@ -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<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, | |||
}; | |||
} |
@@ -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, | |||
}; | |||
} |
@@ -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? |