@@ -146,8 +146,9 @@ export default class AlmIntegrationsServiceMock { | |||
]; | |||
defaultAzureRepositories: AzureRepository[] = [ | |||
mockAzureRepository({ sqProjectKey: 'random' }), | |||
mockAzureRepository({ name: 'Azure repo 2' }), | |||
mockAzureRepository({ sqProjectKey: 'random', projectName: 'Azure project' }), | |||
mockAzureRepository({ name: 'Azure repo 2', projectName: 'Azure project' }), | |||
mockAzureRepository({ name: 'Azure repo 3', projectName: 'Azure project 2' }), | |||
]; | |||
defaultGithubRepositories: GithubRepository[] = [ | |||
@@ -232,15 +233,15 @@ export default class AlmIntegrationsServiceMock { | |||
return Promise.resolve({ projects: this.azureProjects }); | |||
}; | |||
getAzureRepositories = () => { | |||
getAzureRepositories: typeof getAzureRepositories = (_, projectName) => { | |||
return Promise.resolve({ | |||
repositories: this.azureRepositories, | |||
repositories: this.azureRepositories.filter((repo) => repo.projectName === projectName), | |||
}); | |||
}; | |||
searchAzureRepositories = () => { | |||
searchAzureRepositories: typeof searchAzureRepositories = (_, searchQuery) => { | |||
return Promise.resolve({ | |||
repositories: this.azureRepositories, | |||
repositories: this.azureRepositories.filter((repo) => repo.name.includes(searchQuery)), | |||
}); | |||
}; | |||
@@ -17,273 +17,345 @@ | |||
* 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 { LabelValueSelectOption } from 'design-system/lib'; | |||
import React, { useCallback, useEffect, useMemo, useState } from 'react'; | |||
import { GroupBase } from 'react-select'; | |||
import { | |||
getAzureProjects, | |||
getAzureRepositories, | |||
searchAzureRepositories, | |||
} from '../../../../api/alm-integrations'; | |||
import { Location, Router } from '../../../../components/hoc/withRouter'; | |||
import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; | |||
import { AzureProject, AzureRepository } from '../../../../types/alm-integration'; | |||
import { AlmSettingsInstance } 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 AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm'; | |||
import AzureCreateProjectRenderer from './AzureProjectCreateRenderer'; | |||
interface Props { | |||
canAdmin: boolean; | |||
loadingBindings: boolean; | |||
almInstances: AlmSettingsInstance[]; | |||
location: Location; | |||
router: Router; | |||
dopSettings: DopSetting[]; | |||
isLoadingBindings: boolean; | |||
onProjectSetupDone: (importProjects: ImportProjectParam) => void; | |||
} | |||
interface State { | |||
loading: boolean; | |||
loadingRepositories: Dict<boolean>; | |||
projects?: AzureProject[]; | |||
repositories: Dict<AzureRepository[]>; | |||
searching?: boolean; | |||
searchResults?: AzureRepository[]; | |||
searchQuery?: string; | |||
selectedAlmInstance?: AlmSettingsInstance; | |||
showPersonalAccessTokenForm: boolean; | |||
} | |||
export default function AzureProjectCreate(props: Readonly<Props>) { | |||
const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const [isLoading, setIsLoading] = useState(false); | |||
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]); | |||
export default class AzureProjectCreate extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { | |||
selectedAlmInstance: props.almInstances[0], | |||
loading: false, | |||
showPersonalAccessTokenForm: true, | |||
loadingRepositories: {}, | |||
repositories: {}, | |||
}; | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchData(); | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) { | |||
this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => { | |||
this.fetchData().catch(() => { | |||
/* noop */ | |||
}); | |||
}); | |||
const fetchAzureProjects = useCallback(async (): Promise<AzureProject[] | undefined> => { | |||
if (selectedDopSetting === undefined) { | |||
return undefined; | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
const azureProjects = await getAzureProjects(selectedDopSetting.key); | |||
fetchData = async () => { | |||
const { showPersonalAccessTokenForm } = this.state; | |||
return azureProjects.projects; | |||
}, [selectedDopSetting]); | |||
if (!showPersonalAccessTokenForm) { | |||
this.setState({ loading: true }); | |||
let projects: AzureProject[] | undefined; | |||
try { | |||
projects = await this.fetchAzureProjects(); | |||
} catch (_) { | |||
if (this.mounted) { | |||
this.setState({ showPersonalAccessTokenForm: true, loading: false }); | |||
} | |||
const fetchAzureRepositories = useCallback( | |||
async (projectName: string): Promise<AzureRepository[]> => { | |||
if (!selectedDopSetting) { | |||
return []; | |||
} | |||
const { repositories } = this.state; | |||
let firstProjectName: string; | |||
if (projects && projects.length > 0) { | |||
firstProjectName = projects[0].name; | |||
this.setState(({ loadingRepositories }) => ({ | |||
loadingRepositories: { ...loadingRepositories, [firstProjectName]: true }, | |||
})); | |||
const repos = await this.fetchAzureRepositories(firstProjectName); | |||
repositories[firstProjectName] = repos; | |||
try { | |||
const azureRepositories = await getAzureRepositories(selectedDopSetting.key, projectName); | |||
return azureRepositories.repositories; | |||
} catch { | |||
return []; | |||
} | |||
}, | |||
[selectedDopSetting], | |||
); | |||
if (this.mounted) { | |||
this.setState(({ loadingRepositories }) => { | |||
if (firstProjectName !== '') { | |||
loadingRepositories[firstProjectName] = false; | |||
} | |||
return { | |||
loading: false, | |||
loadingRepositories: { ...loadingRepositories }, | |||
projects, | |||
repositories, | |||
}; | |||
}); | |||
} | |||
const fetchData = useCallback(async () => { | |||
if (showPersonalAccessTokenForm) { | |||
return; | |||
} | |||
}; | |||
fetchAzureProjects = (): Promise<AzureProject[] | undefined> => { | |||
const { selectedAlmInstance } = this.state; | |||
if (!selectedAlmInstance) { | |||
return Promise.resolve(undefined); | |||
setIsLoading(true); | |||
let projects: AzureProject[] | undefined; | |||
try { | |||
projects = await fetchAzureProjects(); | |||
} catch (_) { | |||
setShowPersonalAccessTokenForm(true); | |||
setIsLoading(false); | |||
return; | |||
} | |||
return getAzureProjects(selectedAlmInstance.key).then(({ projects }) => projects); | |||
}; | |||
fetchAzureRepositories = (projectName: string): Promise<AzureRepository[]> => { | |||
const { selectedAlmInstance } = this.state; | |||
if (projects && projects.length > 0) { | |||
if (isMonorepoSetup) { | |||
// Load every projects repos if we're in monorepo setup | |||
projects.forEach(async (project) => { | |||
setLoadingRepositories((loadingRepositories) => ({ | |||
...loadingRepositories, | |||
[project.name]: true, | |||
})); | |||
try { | |||
const repos = await fetchAzureRepositories(project.name); | |||
setRepositories((repositories) => ({ | |||
...repositories, | |||
[project.name]: repos, | |||
})); | |||
} finally { | |||
setLoadingRepositories((loadingRepositories) => { | |||
loadingRepositories[project.name] = false; | |||
return { ...loadingRepositories }; | |||
}); | |||
} | |||
}); | |||
} else { | |||
const firstProjectName = projects[0].name; | |||
if (!selectedAlmInstance) { | |||
return Promise.resolve([]); | |||
} | |||
setLoadingRepositories((loadingRepositories) => ({ | |||
...loadingRepositories, | |||
[firstProjectName]: true, | |||
})); | |||
return getAzureRepositories(selectedAlmInstance.key, projectName) | |||
.then(({ repositories }) => repositories) | |||
.catch(() => []); | |||
}; | |||
const repos = await fetchAzureRepositories(firstProjectName); | |||
cleanUrl = () => { | |||
const { location, router } = this.props; | |||
delete location.query.resetPat; | |||
router.replace(location); | |||
}; | |||
setLoadingRepositories((loadingRepositories) => { | |||
loadingRepositories[firstProjectName] = false; | |||
handleOpenProject = async (projectName: string) => { | |||
if (this.state.searchResults) { | |||
return; | |||
return { ...loadingRepositories }; | |||
}); | |||
setRepositories((repositories) => ({ ...repositories, [firstProjectName]: repos })); | |||
} | |||
} | |||
this.setState(({ loadingRepositories }) => ({ | |||
loadingRepositories: { ...loadingRepositories, [projectName]: true }, | |||
})); | |||
const projectRepos = await this.fetchAzureRepositories(projectName); | |||
setProjects(projects); | |||
setIsLoading(false); | |||
}, [fetchAzureProjects, fetchAzureRepositories, isMonorepoSetup, showPersonalAccessTokenForm]); | |||
const handleImportRepository = useCallback( | |||
(selectedRepository: AzureRepository) => { | |||
if (selectedDopSetting !== undefined && selectedRepository !== undefined) { | |||
onProjectSetupDone({ | |||
creationMode: CreateProjectModes.AzureDevOps, | |||
almSetting: selectedDopSetting.key, | |||
monorepo: false, | |||
projects: [ | |||
{ | |||
projectName: selectedRepository.projectName, | |||
repositoryName: selectedRepository.name, | |||
}, | |||
], | |||
}); | |||
} | |||
}, | |||
[onProjectSetupDone, selectedDopSetting], | |||
); | |||
const handleMonorepoSetupDone = useCallback( | |||
(monorepoSetup: ImportProjectParam) => { | |||
const azureMonorepoSetup = { | |||
...monorepoSetup, | |||
projectIdentifier: selectedRepository?.projectName, | |||
}; | |||
onProjectSetupDone(azureMonorepoSetup); | |||
}, | |||
[onProjectSetupDone, selectedRepository?.projectName], | |||
); | |||
const handleOpenProject = useCallback( | |||
async (projectName: string) => { | |||
if (searchResults !== undefined) { | |||
return; | |||
} | |||
this.setState(({ loadingRepositories, repositories }) => ({ | |||
loadingRepositories: { ...loadingRepositories, [projectName]: false }, | |||
repositories: { ...repositories, [projectName]: projectRepos }, | |||
})); | |||
}; | |||
setLoadingRepositories((loadingRepositories) => ({ | |||
...loadingRepositories, | |||
[projectName]: true, | |||
})); | |||
const projectRepos = await fetchAzureRepositories(projectName); | |||
setLoadingRepositories((loadingRepositories) => ({ | |||
...loadingRepositories, | |||
[projectName]: false, | |||
})); | |||
setRepositories((repositories) => ({ ...repositories, [projectName]: projectRepos })); | |||
}, | |||
[fetchAzureRepositories, searchResults], | |||
); | |||
const handlePersonalAccessTokenCreate = useCallback(() => { | |||
cleanUrl(); | |||
setShowPersonalAccessTokenForm(false); | |||
}, [cleanUrl]); | |||
const handleSearchRepositories = useCallback( | |||
async (searchQuery: string) => { | |||
if (!selectedDopSetting) { | |||
return; | |||
} | |||
handleSearchRepositories = async (searchQuery: string) => { | |||
const { selectedAlmInstance } = this.state; | |||
if (searchQuery.length === 0) { | |||
setSearchResults(undefined); | |||
setSearchQuery(''); | |||
return; | |||
} | |||
if (!selectedAlmInstance) { | |||
return; | |||
} | |||
setIsSearching(true); | |||
if (searchQuery.length === 0) { | |||
this.setState({ searchResults: undefined, searchQuery: undefined }); | |||
return; | |||
} | |||
this.setState({ searching: 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]); | |||
useEffect(() => { | |||
fetchData(); | |||
}, [fetchData]); | |||
return isMonorepoSetup ? ( | |||
<MonorepoProjectCreate | |||
canAdmin={canAdmin} | |||
dopSettings={dopSettings} | |||
error={false} | |||
loadingBindings={isLoadingBindings} | |||
loadingOrganizations={false} | |||
loadingRepositories={isLoading} | |||
onProjectSetupDone={handleMonorepoSetupDone} | |||
onSearchRepositories={setSearchQuery} | |||
onSelectDopSetting={setSelectedDopSetting} | |||
onSelectRepository={handleSelectRepository} | |||
personalAccessTokenComponent={ | |||
!isLoading && | |||
selectedAlmInstance && ( | |||
<AzurePersonalAccessTokenForm | |||
almSetting={selectedAlmInstance} | |||
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate} | |||
resetPat={Boolean(location.query.resetPat)} | |||
/> | |||
) | |||
} | |||
repositoryOptions={repositoryOptions} | |||
repositorySearchQuery={searchQuery} | |||
selectedDopSetting={selectedDopSetting} | |||
selectedRepository={selectedRepository ? transformToOption(selectedRepository) : undefined} | |||
showPersonalAccessToken={showPersonalAccessTokenForm || Boolean(location.query.resetPat)} | |||
/> | |||
) : ( | |||
<AzureCreateProjectRenderer | |||
almInstances={almInstances} | |||
canAdmin={canAdmin} | |||
loading={isLoading || isLoadingBindings} | |||
loadingRepositories={loadingRepositories} | |||
onImportRepository={handleImportRepository} | |||
onOpenProject={handleOpenProject} | |||
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate} | |||
onSearch={handleSearchRepositories} | |||
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange} | |||
projects={projects} | |||
repositories={repositories} | |||
resetPat={Boolean(location.query.resetPat)} | |||
searching={isSearching} | |||
searchResults={searchResults} | |||
searchQuery={searchQuery} | |||
selectedAlmInstance={selectedAlmInstance} | |||
showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)} | |||
/> | |||
); | |||
} | |||
const searchResults: AzureRepository[] = await searchAzureRepositories( | |||
selectedAlmInstance.key, | |||
searchQuery, | |||
) | |||
.then(({ repositories }) => repositories) | |||
.catch(() => []); | |||
function transformToOptions( | |||
projects: AzureProject[], | |||
repositories: Dict<AzureRepository[]>, | |||
): Array<GroupBase<LabelValueSelectOption<string>>> { | |||
return projects.map(({ name: projectName }) => ({ | |||
label: projectName, | |||
options: repositories[projectName]?.map(transformToOption) ?? [], | |||
})); | |||
} | |||
if (this.mounted) { | |||
this.setState({ | |||
searching: false, | |||
searchResults, | |||
searchQuery, | |||
}); | |||
} | |||
}; | |||
handleImportRepository = (selectedRepository: AzureRepository) => { | |||
const { selectedAlmInstance } = this.state; | |||
if (selectedAlmInstance && selectedRepository) { | |||
this.props.onProjectSetupDone({ | |||
creationMode: CreateProjectModes.AzureDevOps, | |||
almSetting: selectedAlmInstance.key, | |||
monorepo: false, | |||
projects: [ | |||
{ | |||
projectName: selectedRepository.projectName, | |||
repositoryName: selectedRepository.name, | |||
}, | |||
], | |||
}); | |||
} | |||
}; | |||
handlePersonalAccessTokenCreate = () => { | |||
this.cleanUrl(); | |||
this.setState({ showPersonalAccessTokenForm: false }, () => { | |||
this.fetchData(); | |||
}); | |||
}; | |||
onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => { | |||
this.setState( | |||
{ | |||
selectedAlmInstance: instance, | |||
searchResults: undefined, | |||
searchQuery: '', | |||
showPersonalAccessTokenForm: true, | |||
}, | |||
() => { | |||
this.fetchData().catch(() => { | |||
/* noop */ | |||
}); | |||
}, | |||
); | |||
}; | |||
render() { | |||
const { canAdmin, loadingBindings, location, almInstances } = this.props; | |||
const { | |||
loading, | |||
loadingRepositories, | |||
showPersonalAccessTokenForm, | |||
projects, | |||
repositories, | |||
searching, | |||
searchResults, | |||
searchQuery, | |||
selectedAlmInstance, | |||
} = this.state; | |||
return ( | |||
<AzureCreateProjectRenderer | |||
canAdmin={canAdmin} | |||
loading={loading || loadingBindings} | |||
loadingRepositories={loadingRepositories} | |||
onImportRepository={this.handleImportRepository} | |||
onOpenProject={this.handleOpenProject} | |||
onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate} | |||
onSearch={this.handleSearchRepositories} | |||
projects={projects} | |||
repositories={repositories} | |||
searching={searching} | |||
searchResults={searchResults} | |||
searchQuery={searchQuery} | |||
almInstances={almInstances} | |||
selectedAlmInstance={selectedAlmInstance} | |||
resetPat={Boolean(location.query.resetPat)} | |||
showPersonalAccessTokenForm={ | |||
showPersonalAccessTokenForm || Boolean(location.query.resetPat) | |||
} | |||
onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange} | |||
/> | |||
); | |||
} | |||
function transformToOption({ name }: AzureRepository): LabelValueSelectOption<string> { | |||
return { value: name, label: name }; | |||
} |
@@ -17,25 +17,27 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { | |||
FlagMessage, | |||
InputSearch, | |||
LightPrimary, | |||
Link, | |||
PageContentFontWrapper, | |||
Spinner, | |||
Title, | |||
} from 'design-system'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { getGlobalSettingsUrl } from '../../../../helpers/urls'; | |||
import { getGlobalSettingsUrl, queryToSearch } from '../../../../helpers/urls'; | |||
import { AzureProject, AzureRepository } from '../../../../types/alm-integration'; | |||
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; | |||
import { Feature } from '../../../../types/features'; | |||
import { Dict } from '../../../../types/types'; | |||
import { ALM_INTEGRATION_CATEGORY } from '../../../settings/constants'; | |||
import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown'; | |||
import WrongBindingCountAlert from '../components/WrongBindingCountAlert'; | |||
import { CreateProjectModes } from '../types'; | |||
import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm'; | |||
import AzureProjectsList from './AzureProjectsList'; | |||
@@ -59,7 +61,9 @@ export interface AzureProjectCreateRendererProps { | |||
onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void; | |||
} | |||
export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) { | |||
export default function AzureProjectCreateRenderer( | |||
props: Readonly<AzureProjectCreateRendererProps>, | |||
) { | |||
const { | |||
canAdmin, | |||
loading, | |||
@@ -75,15 +79,41 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend | |||
selectedAlmInstance, | |||
} = props; | |||
const showCountError = !loading && (!almInstances || almInstances?.length === 0); | |||
const showUrlError = !loading && selectedAlmInstance && !selectedAlmInstance.url; | |||
const isMonorepoSupported = React.useContext(AvailableFeaturesContext).includes( | |||
Feature.MonoRepositoryPullRequestDecoration, | |||
); | |||
const showCountError = !loading && (!almInstances || almInstances.length === 0); | |||
const showUrlError = | |||
!loading && selectedAlmInstance !== undefined && selectedAlmInstance.url === undefined; | |||
return ( | |||
<PageContentFontWrapper> | |||
<header className="sw-mb-10"> | |||
<Title className="sw-mb-4">{translate('onboarding.create_project.azure.title')}</Title> | |||
<LightPrimary className="sw-body-sm"> | |||
{translate('onboarding.create_project.azure.subtitle')} | |||
{isMonorepoSupported ? ( | |||
<FormattedMessage | |||
id="onboarding.create_project.azure.subtitle.with_monorepo" | |||
values={{ | |||
monorepoSetupLink: ( | |||
<Link | |||
to={{ | |||
pathname: '/projects/create', | |||
search: queryToSearch({ | |||
mode: CreateProjectModes.AzureDevOps, | |||
mono: true, | |||
}), | |||
}} | |||
> | |||
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" /> | |||
</Link> | |||
), | |||
}} | |||
/> | |||
) : ( | |||
<FormattedMessage id="onboarding.create_project.azure.subtitle" /> | |||
)} | |||
</LightPrimary> | |||
</header> | |||
@@ -94,7 +124,7 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend | |||
onChangeConfig={props.onSelectedAlmInstanceChange} | |||
/> | |||
<Spinner loading={loading} /> | |||
<Spinner isLoading={loading} /> | |||
{showUrlError && ( | |||
<FlagMessage variant="error" className="sw-mb-2"> | |||
@@ -122,8 +152,7 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend | |||
{showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />} | |||
{!loading && | |||
selectedAlmInstance && | |||
selectedAlmInstance.url && | |||
selectedAlmInstance?.url && | |||
(showPersonalAccessTokenForm ? ( | |||
<div> | |||
<AzurePersonalAccessTokenForm | |||
@@ -141,7 +170,7 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend | |||
size="full" | |||
/> | |||
</div> | |||
<Spinner loading={Boolean(searching)}> | |||
<Spinner isLoading={Boolean(searching)}> | |||
<AzureProjectsList | |||
loadingRepositories={loadingRepositories} | |||
onOpenProject={props.onOpenProject} |
@@ -51,7 +51,7 @@ export interface CreateProjectPageProps extends WithAvailableFeaturesProps { | |||
} | |||
interface State { | |||
azureSettings: AlmSettingsInstance[]; | |||
azureSettings: DopSetting[]; | |||
bitbucketSettings: AlmSettingsInstance[]; | |||
bitbucketCloudSettings: AlmSettingsInstance[]; | |||
githubSettings: DopSetting[]; | |||
@@ -130,6 +130,7 @@ export type ImportProjectParam = | |||
projectKey: string; | |||
projectName: string; | |||
}[]; | |||
projectIdentifier?: string; | |||
repositoryIdentifier: string; | |||
}; | |||
@@ -192,9 +193,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
return getDopSettings() | |||
.then(({ dopSettings }) => { | |||
this.setState({ | |||
azureSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.Azure) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
azureSettings: dopSettings.filter(({ type }) => type === AlmKeys.Azure), | |||
bitbucketSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.BitbucketServer) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
@@ -276,10 +275,8 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
return ( | |||
<AzureProjectCreate | |||
canAdmin={!!canAdmin} | |||
loadingBindings={loading} | |||
location={location} | |||
router={router} | |||
almInstances={azureSettings} | |||
dopSettings={azureSettings} | |||
isLoadingBindings={loading} | |||
onProjectSetupDone={this.handleProjectSetupDone} | |||
/> | |||
); |
@@ -232,7 +232,7 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe | |||
}), | |||
}} | |||
> | |||
<FormattedMessage id="onboarding.create_project.github.subtitle.link" /> | |||
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" /> | |||
</Link> | |||
), | |||
}} |
@@ -93,7 +93,7 @@ export default function GitlabProjectCreateRenderer( | |||
}), | |||
}} | |||
> | |||
<FormattedMessage id="onboarding.create_project.gitlab.subtitle.link" /> | |||
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" /> | |||
</Link> | |||
), | |||
}} |
@@ -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 } from '@testing-library/react'; | |||
import { screen, waitFor } 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'); | |||
@@ -39,10 +41,25 @@ let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const ui = { | |||
azureCreateProjectButton: byText('onboarding.create_project.select_method.azure'), | |||
cancelButton: byRole('button', { name: 'cancel' }), | |||
azureOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.azure.title' }), | |||
monorepoDopSettingDropdown: byRole('combobox', { | |||
name: 'onboarding.create_project.monorepo.choose_dop_settingalm.azure', | |||
}), | |||
instanceSelector: byLabelText(/alm.configuration.selector.label/), | |||
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.azure' }), | |||
monorepoSetupLink: byRole('link', { | |||
name: 'onboarding.create_project.subtitle_monorepo_setup_link', | |||
}), | |||
personalAccessTokenInput: byRole('textbox', { | |||
name: /onboarding.create_project.enter_pat/, | |||
}), | |||
instanceSelector: byLabelText(/alm.configuration.selector.label/), | |||
repositorySelector: byRole('combobox', { | |||
name: `onboarding.create_project.monorepo.choose_repository`, | |||
}), | |||
searchbox: byRole('searchbox', { | |||
name: 'onboarding.create_project.search_projects_repositories', | |||
}), | |||
}; | |||
const original = window.location; | |||
@@ -110,6 +127,47 @@ it('should show import project feature when PAT is already set', async () => { | |||
}), | |||
).toBeInTheDocument(); | |||
await user.click(screen.getByText('Azure project 2')); | |||
expect( | |||
screen.getByRole('listitem', { | |||
name: 'Azure repo 3', | |||
}), | |||
).toBeInTheDocument(); | |||
await user.type(ui.searchbox.get(), 'repo 2'); | |||
expect( | |||
screen.queryByRole('listitem', { | |||
name: 'Azure repo 1', | |||
}), | |||
).not.toBeInTheDocument(); | |||
expect( | |||
screen.queryByRole('listitem', { | |||
name: 'Azure repo 3', | |||
}), | |||
).not.toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('listitem', { | |||
name: 'Azure repo 2', | |||
}), | |||
).toBeInTheDocument(); | |||
await user.clear(ui.searchbox.get()); | |||
expect( | |||
screen.queryByRole('listitem', { | |||
name: 'Azure repo 3', | |||
}), | |||
).not.toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('listitem', { | |||
name: 'Azure repo 1', | |||
}), | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('listitem', { | |||
name: 'Azure repo 2', | |||
}), | |||
).toBeInTheDocument(); | |||
const importButton = screen.getByText('onboarding.create_project.import'); | |||
await user.click(importButton); | |||
@@ -149,8 +207,63 @@ it('should show search filter when PAT is already set', async () => { | |||
expect(screen.getByText('onboarding.create_project.azure.no_results')).toBeInTheDocument(); | |||
}); | |||
function renderCreateProject() { | |||
renderApp('project/create', <CreateProjectPage />, { | |||
navigateTo: 'project/create?mode=azure', | |||
describe('Azure monorepo setup navigation', () => { | |||
it('should not display monorepo setup link if feature is disabled', async () => { | |||
renderCreateProject({ isMonorepoFeatureEnabled: false }); | |||
await waitFor(() => { | |||
// This test raises an Act warning if the following expect is not wrapped inside a `waitFor` | |||
// Feel free to investigate and fix it if you have time | |||
expect(ui.monorepoSetupLink.query()).not.toBeInTheDocument(); | |||
}); | |||
}); | |||
it('should be able to access monorepo setup page from Azure 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 Azure onboarding page from monorepo setup page', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject({ isMonorepo: true }); | |||
await user.click(await ui.cancelButton.find()); | |||
expect(ui.azureOnboardingTitle.get()).toBeInTheDocument(); | |||
}); | |||
it('should load every repositories from every projects in monorepo setup mode', async () => { | |||
renderCreateProject({ isMonorepo: true }); | |||
await selectEvent.select(await ui.monorepoDopSettingDropdown.find(), [/conf-azure-2/]); | |||
selectEvent.openMenu(await ui.repositorySelector.find()); | |||
expect(screen.getByText('Azure repo 1')).toBeInTheDocument(); | |||
expect(screen.getByText('Azure repo 2')).toBeInTheDocument(); | |||
expect(screen.getByText('Azure repo 3')).toBeInTheDocument(); | |||
}); | |||
}); | |||
function renderCreateProject({ | |||
isMonorepo = false, | |||
isMonorepoFeatureEnabled = true, | |||
}: { | |||
isMonorepo?: boolean; | |||
isMonorepoFeatureEnabled?: boolean; | |||
} = {}) { | |||
let queryString = `mode=${CreateProjectModes.AzureDevOps}`; | |||
if (isMonorepo) { | |||
queryString += '&mono=true'; | |||
} | |||
renderApp('projects/create', <CreateProjectPage />, { | |||
navigateTo: `projects/create?${queryString}`, | |||
featureList: isMonorepoFeatureEnabled | |||
? [Feature.MonoRepositoryPullRequestDecoration] | |||
: undefined, | |||
}); | |||
} |
@@ -43,7 +43,9 @@ const ui = { | |||
gitlabCreateProjectButton: byText('onboarding.create_project.select_method.gitlab'), | |||
gitLabOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.gitlab.title' }), | |||
instanceSelector: byLabelText(/alm.configuration.selector.label/), | |||
monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.gitlab.subtitle.link' }), | |||
monorepoSetupLink: byRole('link', { | |||
name: 'onboarding.create_project.subtitle_monorepo_setup_link', | |||
}), | |||
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.gitlab' }), | |||
personalAccessTokenInput: byRole('textbox', { |
@@ -57,7 +57,9 @@ const ui = { | |||
monorepoProjectTitle: byRole('heading', { | |||
name: 'onboarding.create_project.monorepo.project_title', | |||
}), | |||
monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.github.subtitle.link' }), | |||
monorepoSetupLink: byRole('link', { | |||
name: 'onboarding.create_project.subtitle_monorepo_setup_link', | |||
}), | |||
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.github' }), | |||
organizationSelector: byRole('combobox', { | |||
name: `onboarding.create_project.monorepo.choose_organization`, |
@@ -20,6 +20,7 @@ | |||
import { Title } from 'design-system/lib'; | |||
import React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { GroupBase } from 'react-select'; | |||
import { LabelValueSelectOption } from '../../../../helpers/search'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
@@ -48,7 +49,7 @@ interface Props { | |||
onSelectRepository: (repositoryKey: string) => void; | |||
organizationOptions?: LabelValueSelectOption[]; | |||
personalAccessTokenComponent?: React.ReactNode; | |||
repositoryOptions?: LabelValueSelectOption[]; | |||
repositoryOptions?: LabelValueSelectOption[] | GroupBase<LabelValueSelectOption>[]; | |||
repositorySearchQuery: string; | |||
selectedDopSetting?: DopSetting; | |||
selectedOrganization?: LabelValueSelectOption; |
@@ -21,6 +21,7 @@ import { Spinner } from '@sonarsource/echoes-react'; | |||
import { BlueGreySeparator, ButtonPrimary, ButtonSecondary } from 'design-system'; | |||
import React, { useEffect, useRef } from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { GroupBase } from 'react-select'; | |||
import { getComponents } from '../../../../api/project-management'; | |||
import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; | |||
import { throwGlobalError } from '../../../../helpers/error'; | |||
@@ -50,7 +51,7 @@ interface MonorepoProjectCreateProps { | |||
onSelectRepository: (repositoryKey: string) => void; | |||
organizationOptions?: LabelValueSelectOption[]; | |||
personalAccessTokenComponent?: React.ReactNode; | |||
repositoryOptions?: LabelValueSelectOption[]; | |||
repositoryOptions?: LabelValueSelectOption[] | GroupBase<LabelValueSelectOption>[]; | |||
repositorySearchQuery: string; | |||
selectedDopSetting?: DopSetting; | |||
selectedOrganization?: LabelValueSelectOption; |
@@ -21,6 +21,7 @@ import { LinkHighlight, LinkStandalone, Spinner } from '@sonarsource/echoes-reac | |||
import { DarkLabel, FlagMessage, InputSelect } from 'design-system'; | |||
import React from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { GroupBase } from 'react-select'; | |||
import { LabelValueSelectOption } from '../../../../helpers/search'; | |||
import { getProjectUrl } from '../../../../helpers/urls'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
@@ -38,7 +39,7 @@ interface Props { | |||
onSearchRepositories: (query: string) => void; | |||
onSelectRepository: (repositoryKey: string) => void; | |||
repositorySearchQuery: string; | |||
repositoryOptions?: LabelValueSelectOption[]; | |||
repositoryOptions?: LabelValueSelectOption[] | GroupBase<LabelValueSelectOption>[]; | |||
selectedOrganization?: LabelValueSelectOption; | |||
selectedRepository?: LabelValueSelectOption; | |||
showOrganizations?: boolean; |
@@ -63,7 +63,7 @@ export const usePersonalAccessToken = ( | |||
setCheckingPat(true); | |||
const { patIsValid, error } = await checkPersonalAccessTokenIsValid(key) | |||
.then(({ status, error }) => ({ patIsValid: status, error })) | |||
.catch(() => ({ patIsValid: status, error: translate('default_error_message') })); | |||
.catch(() => ({ patIsValid: false, error: translate('default_error_message') })); | |||
if (patIsValid) { | |||
onPersonalAccessTokenCreated(); | |||
return; |
@@ -31,6 +31,7 @@ export interface BoundProject { | |||
monorepo: boolean; | |||
newCodeDefinitionType?: string; | |||
newCodeDefinitionValue?: string; | |||
projectIdentifier?: string; | |||
projectKey: string; | |||
projectName: string; | |||
repositoryIdentifier: string; |
@@ -4396,8 +4396,10 @@ onboarding.create_project.see_on_github=See on GitHub | |||
onboarding.create_project.search_prompt=Search for projects | |||
onboarding.create_project.set_up=Set up | |||
onboarding.create_project.subtitle_monorepo_setup_link=set up a monorepo | |||
onboarding.create_project.azure.title=Azure project onboarding | |||
onboarding.create_project.azure.subtitle=Import projects from one of your Azure projects | |||
onboarding.create_project.azure.subtitle.with_monorepo=Import projects from one of your Azure projects or {monorepoSetupLink}. | |||
onboarding.create_project.azure.no_projects=No projects could be fetched from Azure DevOps. Contact your system administrator, or {link}. | |||
onboarding.create_project.azure.search_results_for_project_X=Search results for "{0}" | |||
onboarding.create_project.azure.no_repositories=Could not fetch repositories for this project. Contact your system administrator, or {link}. | |||
@@ -4409,7 +4411,6 @@ onboarding.create_project.bitbucketcloud.link=See on Bitbucket | |||
onboarding.create_project.github.title=GitHub project onboarding | |||
onboarding.create_project.github.subtitle=Import repositories from one of your GitHub organizations. | |||
onboarding.create_project.github.subtitle.with_monorepo=Import repositories from one of your GitHub organizations or {monorepoSetupLink}. | |||
onboarding.create_project.github.subtitle.link=set up a monorepo | |||
onboarding.create_project.github.choose_organization=Choose an organization | |||
onboarding.create_project.github.choose_repository=Choose the repository | |||
onboarding.create_project.github.warning.message=Could not connect to GitHub. Please contact an administrator to configure GitHub integration. | |||
@@ -4421,7 +4422,6 @@ onboarding.create_project.github.no_projects=No projects could be fetched from G | |||
onboarding.create_project.gitlab.title=Gitlab project onboarding | |||
onboarding.create_project.gitlab.subtitle=Import projects from one of your GitLab groups | |||
onboarding.create_project.gitlab.subtitle.with_monorepo=Import projects from one of your GitLab groups or {monorepoSetupLink}. | |||
onboarding.create_project.gitlab.subtitle.link=set up a monorepo | |||
onboarding.create_project.gitlab.no_projects=No projects could be fetched from Gitlab. Contact your system administrator, or {link}. | |||
onboarding.create_project.gitlab.link=See on GitLab | |||
onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator. |