@@ -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 { LabelValueSelectOption } from 'design-system/lib'; | |||
import { LabelValueSelectOption } from 'design-system'; | |||
import React, { useCallback, useEffect, useMemo, useState } from 'react'; | |||
import { GroupBase } from 'react-select'; | |||
import { | |||
@@ -37,14 +37,13 @@ import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm'; | |||
import AzureCreateProjectRenderer from './AzureProjectCreateRenderer'; | |||
interface Props { | |||
canAdmin: boolean; | |||
dopSettings: DopSetting[]; | |||
isLoadingBindings: boolean; | |||
onProjectSetupDone: (importProjects: ImportProjectParam) => void; | |||
} | |||
export default function AzureProjectCreate(props: Readonly<Props>) { | |||
const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const { dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const [isLoading, setIsLoading] = useState(false); | |||
const [loadingRepositories, setLoadingRepositories] = useState<Dict<boolean>>({}); | |||
const [isSearching, setIsSearching] = useState(false); | |||
@@ -297,7 +296,6 @@ export default function AzureProjectCreate(props: Readonly<Props>) { | |||
return isMonorepoSetup ? ( | |||
<MonorepoProjectCreate | |||
canAdmin={canAdmin} | |||
dopSettings={dopSettings} | |||
error={false} | |||
loadingBindings={isLoadingBindings} | |||
@@ -326,7 +324,6 @@ export default function AzureProjectCreate(props: Readonly<Props>) { | |||
) : ( | |||
<AzureCreateProjectRenderer | |||
almInstances={almInstances} | |||
canAdmin={canAdmin} | |||
loading={isLoading || isLoadingBindings} | |||
loadingRepositories={loadingRepositories} | |||
onImportRepository={handleImportRepository} |
@@ -27,6 +27,7 @@ import { | |||
} from 'design-system'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { useAppState } from '../../../../app/components/app-state/withAppStateContext'; | |||
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { getGlobalSettingsUrl, queryToSearch } from '../../../../helpers/urls'; | |||
@@ -42,7 +43,6 @@ import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm'; | |||
import AzureProjectsList from './AzureProjectsList'; | |||
export interface AzureProjectCreateRendererProps { | |||
canAdmin?: boolean; | |||
loading: boolean; | |||
loadingRepositories: Dict<boolean>; | |||
onImportRepository: (resository: AzureRepository) => void; | |||
@@ -65,7 +65,6 @@ export default function AzureProjectCreateRenderer( | |||
props: Readonly<AzureProjectCreateRendererProps>, | |||
) { | |||
const { | |||
canAdmin, | |||
loading, | |||
loadingRepositories, | |||
projects, | |||
@@ -83,6 +82,8 @@ export default function AzureProjectCreateRenderer( | |||
Feature.MonoRepositoryPullRequestDecoration, | |||
); | |||
const { canAdmin } = useAppState(); | |||
const showCountError = !loading && (!almInstances || almInstances.length === 0); | |||
const showUrlError = | |||
!loading && selectedAlmInstance !== undefined && selectedAlmInstance.url === undefined; | |||
@@ -149,7 +150,7 @@ export default function AzureProjectCreateRenderer( | |||
</FlagMessage> | |||
)} | |||
{showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />} | |||
{showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} />} | |||
{!loading && | |||
selectedAlmInstance?.url && |
@@ -17,6 +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 { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { | |||
ButtonPrimary, | |||
FlagErrorIcon, | |||
@@ -24,17 +25,15 @@ import { | |||
FormField, | |||
InputField, | |||
LightPrimary, | |||
Link, | |||
Spinner, | |||
} from 'design-system'; | |||
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; | |||
} | |||
@@ -43,7 +42,7 @@ export default function BitbucketCloudPersonalAccessTokenForm({ | |||
almSetting, | |||
resetPat, | |||
onPersonalAccessTokenCreated, | |||
}: Props) { | |||
}: Readonly<Props>) { | |||
const { | |||
username, | |||
password, | |||
@@ -59,12 +58,12 @@ export default function BitbucketCloudPersonalAccessTokenForm({ | |||
} = usePersonalAccessToken(almSetting, resetPat, onPersonalAccessTokenCreated); | |||
if (checkingPat) { | |||
return <Spinner className="sw-ml-2" loading />; | |||
return <Spinner className="sw-ml-2" isLoading />; | |||
} | |||
const isInvalid = validationFailed && !touched; | |||
const canSubmit = Boolean(password) && Boolean(username); | |||
const submitButtonDiabled = isInvalid || submitting || !canSubmit; | |||
const submitButtonDisabled = isInvalid || submitting || !canSubmit; | |||
const errorMessage = | |||
validationErrorMessage ?? translate('onboarding.create_project.pat_incorrect.bitbucket_cloud'); | |||
@@ -175,10 +174,10 @@ export default function BitbucketCloudPersonalAccessTokenForm({ | |||
</FlagMessage> | |||
</div> | |||
<ButtonPrimary type="submit" disabled={submitButtonDiabled} className="sw-mb-6"> | |||
<ButtonPrimary type="submit" disabled={submitButtonDisabled} className="sw-mb-6"> | |||
{translate('save')} | |||
</ButtonPrimary> | |||
<Spinner className="sw-ml-2" loading={submitting} /> | |||
<Spinner className="sw-ml-2" isLoading={submitting} /> | |||
</form> | |||
); | |||
} |
@@ -17,242 +17,215 @@ | |||
* 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'; | |||
import React, { useCallback, useMemo, useState } from 'react'; | |||
import { searchForBitbucketCloudRepositories } from '../../../../api/alm-integrations'; | |||
import { Location, Router } from '../../../../components/hoc/withRouter'; | |||
import { useLocation } from '../../../../components/hoc/withRouter'; | |||
import { BitbucketCloudRepository } from '../../../../types/alm-integration'; | |||
import { AlmSettingsInstance } from '../../../../types/alm-settings'; | |||
import { Paging } from '../../../../types/types'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
import { ImportProjectParam } from '../CreateProjectPage'; | |||
import { BITBUCKET_CLOUD_PROJECTS_PAGESIZE } from '../constants'; | |||
import { REPOSITORY_PAGE_SIZE } from '../constants'; | |||
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate'; | |||
import { CreateProjectModes } from '../types'; | |||
import { useProjectCreate } from '../useProjectCreate'; | |||
import { useProjectRepositorySearch } from '../useProjectRepositorySearch'; | |||
import BitbucketCloudPersonalAccessTokenForm from './BitbucketCloudPersonalAccessTokenForm'; | |||
import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender'; | |||
interface Props { | |||
canAdmin: boolean; | |||
almInstances: AlmSettingsInstance[]; | |||
loadingBindings: boolean; | |||
location: Location; | |||
router: Router; | |||
dopSettings: DopSetting[]; | |||
isLoadingBindings: boolean; | |||
onProjectSetupDone: (importProjects: ImportProjectParam) => void; | |||
} | |||
interface State { | |||
isLastPage?: boolean; | |||
loading: boolean; | |||
loadingMore: boolean; | |||
projectsPaging: Omit<Paging, 'total'>; | |||
resetPat: boolean; | |||
repositories: BitbucketCloudRepository[]; | |||
searching: boolean; | |||
searchQuery: string; | |||
selectedAlmInstance: AlmSettingsInstance; | |||
showPersonalAccessTokenForm: boolean; | |||
} | |||
export default class BitbucketCloudProjectCreate extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { | |||
// For now, we only handle a single instance. So we always use the first | |||
// one from the list. | |||
loading: false, | |||
loadingMore: false, | |||
resetPat: false, | |||
projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE }, | |||
repositories: [], | |||
searching: false, | |||
searchQuery: '', | |||
selectedAlmInstance: props.almInstances[0], | |||
showPersonalAccessTokenForm: true, | |||
}; | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) { | |||
this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => { | |||
this.fetchData().catch(() => { | |||
/* noop */ | |||
}); | |||
}); | |||
} | |||
} | |||
export default function BitbucketCloudProjectCreate(props: Readonly<Props>) { | |||
const { dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const [isLastPage, setIsLastPage] = useState<boolean>(true); | |||
const [projectsPaging, setProjectsPaging] = useState<{ pageIndex: number; pageSize: number }>({ | |||
pageIndex: 1, | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
}); | |||
const { | |||
handlePersonalAccessTokenCreated, | |||
handleSelectRepository, | |||
isInitialized, | |||
isLoadingRepositories, | |||
isLoadingMoreRepositories, | |||
isMonorepoSetup, | |||
onSelectedAlmInstanceChange, | |||
onSelectDopSetting, | |||
repositories, | |||
resetLoading, | |||
resetPersonalAccessToken, | |||
searchQuery, | |||
selectedDopSetting, | |||
selectedRepository, | |||
setIsInitialized, | |||
setRepositories, | |||
setResetPersonalAccessToken, | |||
setSearchQuery, | |||
setShowPersonalAccessTokenForm, | |||
showPersonalAccessTokenForm, | |||
} = useProjectCreate<BitbucketCloudRepository, undefined>( | |||
AlmKeys.BitbucketCloud, | |||
dopSettings, | |||
({ slug }) => slug, | |||
REPOSITORY_PAGE_SIZE, | |||
); | |||
const location = useLocation(); | |||
const repositoryOptions = useMemo(() => repositories?.map(transformToOption), [repositories]); | |||
const fetchRepositories = useCallback( | |||
(_orgKey?: string, query = '', pageIndex = 1, more = false) => { | |||
if (!selectedDopSetting || showPersonalAccessTokenForm) { | |||
return Promise.resolve(); | |||
} | |||
handlePersonalAccessTokenCreated = () => { | |||
this.cleanUrl(); | |||
resetLoading(true, more); | |||
this.setState({ loading: true, showPersonalAccessTokenForm: false }, () => { | |||
this.fetchData() | |||
.then(() => this.setState({ loading: false })) | |||
// eslint-disable-next-line local-rules/no-api-imports | |||
return searchForBitbucketCloudRepositories( | |||
selectedDopSetting.key, | |||
query, | |||
REPOSITORY_PAGE_SIZE, | |||
pageIndex, | |||
) | |||
.then((result) => { | |||
resetLoading(false, more); | |||
if (result) { | |||
setIsLastPage(result.isLastPage); | |||
setIsInitialized(true); | |||
} | |||
if (result?.repositories) { | |||
setRepositories( | |||
more && repositories && repositories.length > 0 | |||
? [...repositories, ...result.repositories] | |||
: result.repositories, | |||
); | |||
} | |||
}) | |||
.catch(() => { | |||
/* noop */ | |||
resetLoading(false, more); | |||
setResetPersonalAccessToken(true); | |||
setShowPersonalAccessTokenForm(true); | |||
}); | |||
}); | |||
}; | |||
cleanUrl = () => { | |||
const { location, router } = this.props; | |||
delete location.query.resetPat; | |||
router.replace(location); | |||
}; | |||
async fetchData(more = false) { | |||
const { | |||
selectedAlmInstance, | |||
searchQuery, | |||
projectsPaging: { pageIndex, pageSize }, | |||
}, | |||
[ | |||
repositories, | |||
resetLoading, | |||
selectedDopSetting, | |||
showPersonalAccessTokenForm, | |||
} = this.state; | |||
if (selectedAlmInstance && !showPersonalAccessTokenForm) { | |||
const { isLastPage, repositories } = await searchForBitbucketCloudRepositories( | |||
selectedAlmInstance.key, | |||
searchQuery, | |||
pageSize, | |||
pageIndex, | |||
).catch(() => { | |||
this.handleError(); | |||
return { isLastPage: undefined, repositories: undefined }; | |||
}); | |||
if (this.mounted && isLastPage !== undefined && repositories !== undefined) { | |||
if (more) { | |||
this.setState((state) => ({ | |||
isLastPage, | |||
repositories: [...state.repositories, ...repositories], | |||
})); | |||
} else { | |||
this.setState({ isLastPage, repositories }); | |||
} | |||
setIsInitialized, | |||
setIsLastPage, | |||
setRepositories, | |||
setResetPersonalAccessToken, | |||
setShowPersonalAccessTokenForm, | |||
], | |||
); | |||
const handleLoadMore = useCallback(() => { | |||
const page = projectsPaging.pageIndex + 1; | |||
setProjectsPaging((paging) => ({ | |||
pageIndex: page, | |||
pageSize: paging.pageSize, | |||
})); | |||
fetchRepositories(undefined, searchQuery, page, true); | |||
}, [fetchRepositories, projectsPaging, searchQuery, setProjectsPaging]); | |||
const handleImportRepository = useCallback( | |||
(repositorySlug: string) => { | |||
if (selectedDopSetting) { | |||
onProjectSetupDone({ | |||
creationMode: CreateProjectModes.BitbucketCloud, | |||
almSetting: selectedDopSetting.key, | |||
monorepo: false, | |||
projects: [{ repositorySlug }], | |||
}); | |||
} | |||
} | |||
} | |||
handleError = () => { | |||
if (this.mounted) { | |||
this.setState({ | |||
projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE }, | |||
repositories: [], | |||
resetPat: true, | |||
showPersonalAccessTokenForm: true, | |||
}); | |||
} | |||
return undefined; | |||
}; | |||
handleSearch = (searchQuery: string) => { | |||
this.setState( | |||
{ | |||
searching: true, | |||
projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE }, | |||
searchQuery, | |||
}, | |||
() => { | |||
this.fetchData().then( | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ searching: false }); | |||
} | |||
}, | |||
() => { | |||
/* noop */ | |||
}, | |||
); | |||
}, | |||
); | |||
}; | |||
handleLoadMore = () => { | |||
this.setState( | |||
(state) => ({ | |||
loadingMore: true, | |||
projectsPaging: { | |||
pageIndex: state.projectsPaging.pageIndex + 1, | |||
pageSize: state.projectsPaging.pageSize, | |||
}, | |||
}), | |||
() => { | |||
this.fetchData(true).then( | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ loadingMore: false }); | |||
}, | |||
[onProjectSetupDone, selectedDopSetting], | |||
); | |||
const { isSearching, onSearch } = useProjectRepositorySearch( | |||
AlmKeys.BitbucketCloud, | |||
fetchRepositories, | |||
isInitialized, | |||
selectedDopSetting, | |||
undefined, | |||
setSearchQuery, | |||
showPersonalAccessTokenForm, | |||
); | |||
return isMonorepoSetup ? ( | |||
<MonorepoProjectCreate | |||
dopSettings={dopSettings} | |||
error={false} | |||
loadingBindings={isLoadingBindings} | |||
loadingOrganizations={false} | |||
loadingRepositories={isLoadingRepositories} | |||
onProjectSetupDone={onProjectSetupDone} | |||
onSearchRepositories={onSearch} | |||
onSelectDopSetting={onSelectDopSetting} | |||
onSelectRepository={handleSelectRepository} | |||
personalAccessTokenComponent={ | |||
!isLoadingRepositories && | |||
selectedDopSetting && ( | |||
<BitbucketCloudPersonalAccessTokenForm | |||
almSetting={selectedDopSetting} | |||
resetPat={resetPersonalAccessToken} | |||
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated} | |||
/> | |||
) | |||
} | |||
repositoryOptions={repositoryOptions} | |||
repositorySearchQuery={searchQuery} | |||
selectedDopSetting={selectedDopSetting} | |||
selectedRepository={selectedRepository ? transformToOption(selectedRepository) : undefined} | |||
showPersonalAccessToken={showPersonalAccessTokenForm || Boolean(location.query.resetPat)} | |||
/> | |||
) : ( | |||
<BitbucketCloudProjectCreateRenderer | |||
isLastPage={isLastPage} | |||
selectedAlmInstance={ | |||
selectedDopSetting | |||
? { | |||
alm: selectedDopSetting.type, | |||
key: selectedDopSetting.key, | |||
url: selectedDopSetting.url, | |||
} | |||
}, | |||
() => { | |||
/* noop */ | |||
}, | |||
); | |||
}, | |||
); | |||
}; | |||
handleImport = (repositorySlug: string) => { | |||
const { selectedAlmInstance } = this.state; | |||
if (selectedAlmInstance) { | |||
this.props.onProjectSetupDone({ | |||
creationMode: CreateProjectModes.BitbucketCloud, | |||
almSetting: selectedAlmInstance.key, | |||
monorepo: false, | |||
projects: [ | |||
{ | |||
repositorySlug, | |||
}, | |||
], | |||
}); | |||
} | |||
}; | |||
onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => { | |||
this.setState({ | |||
selectedAlmInstance: instance, | |||
showPersonalAccessTokenForm: true, | |||
resetPat: false, | |||
searching: false, | |||
searchQuery: '', | |||
projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE }, | |||
}); | |||
}; | |||
: undefined | |||
} | |||
almInstances={dopSettings?.map((instance) => ({ | |||
alm: instance.type, | |||
key: instance.key, | |||
url: instance.url, | |||
}))} | |||
loadingMore={isLoadingMoreRepositories} | |||
loading={isLoadingRepositories || isLoadingBindings} | |||
onImport={handleImportRepository} | |||
onLoadMore={handleLoadMore} | |||
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated} | |||
onSearch={onSearch} | |||
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange} | |||
repositories={repositories} | |||
searching={isSearching} | |||
searchQuery={searchQuery} | |||
resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)} | |||
showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)} | |||
/> | |||
); | |||
} | |||
render() { | |||
const { canAdmin, loadingBindings, location, almInstances } = this.props; | |||
const { | |||
isLastPage = true, | |||
selectedAlmInstance, | |||
loading, | |||
loadingMore, | |||
repositories, | |||
showPersonalAccessTokenForm, | |||
resetPat, | |||
searching, | |||
searchQuery, | |||
} = this.state; | |||
return ( | |||
<BitbucketCloudProjectCreateRenderer | |||
isLastPage={isLastPage} | |||
selectedAlmInstance={selectedAlmInstance} | |||
almInstances={almInstances} | |||
canAdmin={canAdmin} | |||
loadingMore={loadingMore} | |||
loading={loading || loadingBindings} | |||
onImport={this.handleImport} | |||
onLoadMore={this.handleLoadMore} | |||
onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated} | |||
onSearch={this.handleSearch} | |||
onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange} | |||
repositories={repositories} | |||
searching={searching} | |||
searchQuery={searchQuery} | |||
resetPat={resetPat || Boolean(location.query.resetPat)} | |||
showPersonalAccessTokenForm={ | |||
showPersonalAccessTokenForm || Boolean(location.query.resetPat) | |||
} | |||
/> | |||
); | |||
} | |||
function transformToOption({ | |||
name, | |||
slug, | |||
}: BitbucketCloudRepository): LabelValueSelectOption<string> { | |||
return { value: slug, label: name }; | |||
} |
@@ -17,19 +17,25 @@ | |||
* 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, Spinner, Title } from 'design-system'; | |||
import * as React from 'react'; | |||
import { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { LightPrimary, Title } from 'design-system'; | |||
import React, { useContext } from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { queryToSearch } from '../../../../helpers/urls'; | |||
import { BitbucketCloudRepository } from '../../../../types/alm-integration'; | |||
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; | |||
import { Feature } from '../../../../types/features'; | |||
import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown'; | |||
import WrongBindingCountAlert from '../components/WrongBindingCountAlert'; | |||
import { CreateProjectModes } from '../types'; | |||
import BitbucketCloudPersonalAccessTokenForm from './BitbucketCloudPersonalAccessTokenForm'; | |||
import BitbucketCloudSearchForm from './BitbucketCloudSearchForm'; | |||
export interface BitbucketCloudProjectCreateRendererProps { | |||
almInstances: AlmSettingsInstance[]; | |||
isLastPage: boolean; | |||
canAdmin?: boolean; | |||
loading: boolean; | |||
loadingMore: boolean; | |||
onImport: (repositorySlug: string) => void; | |||
@@ -41,19 +47,21 @@ export interface BitbucketCloudProjectCreateRendererProps { | |||
resetPat: boolean; | |||
searching: boolean; | |||
searchQuery: string; | |||
showPersonalAccessTokenForm: boolean; | |||
almInstances: AlmSettingsInstance[]; | |||
selectedAlmInstance?: AlmSettingsInstance; | |||
showPersonalAccessTokenForm: boolean; | |||
} | |||
export default function BitbucketCloudProjectCreateRenderer( | |||
props: Readonly<BitbucketCloudProjectCreateRendererProps>, | |||
) { | |||
const isMonorepoSupported = useContext(AvailableFeaturesContext).includes( | |||
Feature.MonoRepositoryPullRequestDecoration, | |||
); | |||
const { | |||
almInstances, | |||
isLastPage, | |||
selectedAlmInstance, | |||
canAdmin, | |||
loading, | |||
loadingMore, | |||
repositories, | |||
@@ -70,7 +78,28 @@ export default function BitbucketCloudProjectCreateRenderer( | |||
{translate('onboarding.create_project.bitbucketcloud.title')} | |||
</Title> | |||
<LightPrimary className="sw-body-sm"> | |||
{translate('onboarding.create_project.bitbucketcloud.subtitle')} | |||
{isMonorepoSupported ? ( | |||
<FormattedMessage | |||
id="onboarding.create_project.bitbucketcloud.subtitle.with_monorepo" | |||
values={{ | |||
monorepoSetupLink: ( | |||
<Link | |||
to={{ | |||
pathname: '/projects/create', | |||
search: queryToSearch({ | |||
mode: CreateProjectModes.BitbucketCloud, | |||
mono: true, | |||
}), | |||
}} | |||
> | |||
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" /> | |||
</Link> | |||
), | |||
}} | |||
/> | |||
) : ( | |||
<FormattedMessage id="onboarding.create_project.bitbucketcloud.subtitle" /> | |||
)} | |||
</LightPrimary> | |||
</header> | |||
@@ -81,10 +110,10 @@ export default function BitbucketCloudProjectCreateRenderer( | |||
onChangeConfig={props.onSelectedAlmInstanceChange} | |||
/> | |||
<Spinner loading={loading} /> | |||
<Spinner isLoading={loading} /> | |||
{!loading && !selectedAlmInstance && ( | |||
<WrongBindingCountAlert alm={AlmKeys.BitbucketCloud} canAdmin={!!canAdmin} /> | |||
{!loading && almInstances && almInstances.length === 0 && !selectedAlmInstance && ( | |||
<WrongBindingCountAlert alm={AlmKeys.BitbucketCloud} /> | |||
)} | |||
{!loading && |
@@ -26,7 +26,7 @@ import { getBaseUrl } from '../../../../helpers/system'; | |||
import { queryToSearch } from '../../../../helpers/urls'; | |||
import { BitbucketCloudRepository } from '../../../../types/alm-integration'; | |||
import AlmRepoItem from '../components/AlmRepoItem'; | |||
import { BITBUCKET_CLOUD_PROJECTS_PAGESIZE } from '../constants'; | |||
import { REPOSITORY_PAGE_SIZE } from '../constants'; | |||
import { CreateProjectModes } from '../types'; | |||
export interface BitbucketCloudSearchFormProps { | |||
@@ -112,7 +112,7 @@ export default function BitbucketCloudSearchForm(props: BitbucketCloudSearchForm | |||
count={repositories.length} | |||
// we don't know the total, so only provide when we've reached the last page | |||
total={isLastPage ? repositories.length : undefined} | |||
pageSize={BITBUCKET_CLOUD_PROJECTS_PAGESIZE} | |||
pageSize={REPOSITORY_PAGE_SIZE} | |||
loadMore={props.onLoadMore} | |||
loading={loadingMore} | |||
/> |
@@ -36,7 +36,6 @@ import { CreateProjectModes } from '../types'; | |||
import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer'; | |||
interface Props { | |||
canAdmin: boolean; | |||
almInstances: AlmSettingsInstance[]; | |||
loadingBindings: boolean; | |||
location: Location; | |||
@@ -236,7 +235,7 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S | |||
}; | |||
render() { | |||
const { canAdmin, loadingBindings, location, almInstances } = this.props; | |||
const { loadingBindings, location, almInstances } = this.props; | |||
const { | |||
selectedAlmInstance, | |||
loading, | |||
@@ -251,7 +250,6 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S | |||
<BitbucketCreateProjectRenderer | |||
selectedAlmInstance={selectedAlmInstance} | |||
almInstances={almInstances} | |||
canAdmin={canAdmin} | |||
loading={loading || loadingBindings} | |||
onImportRepository={this.handleImportRepository} | |||
onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated} |
@@ -34,7 +34,6 @@ import BitbucketServerPersonalAccessTokenForm from './BitbucketServerPersonalAcc | |||
export interface BitbucketProjectCreateRendererProps { | |||
selectedAlmInstance?: AlmSettingsInstance; | |||
almInstances: AlmSettingsInstance[]; | |||
canAdmin?: boolean; | |||
loading: boolean; | |||
onImportRepository: (repository: BitbucketRepository) => void; | |||
onSearch: (query: string) => void; | |||
@@ -52,7 +51,6 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr | |||
const { | |||
almInstances, | |||
selectedAlmInstance, | |||
canAdmin, | |||
loading, | |||
projects, | |||
projectRepositories, | |||
@@ -81,7 +79,7 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr | |||
<Spinner loading={loading}> | |||
{!loading && !selectedAlmInstance && ( | |||
<WrongBindingCountAlert alm={AlmKeys.BitbucketServer} canAdmin={!!canAdmin} /> | |||
<WrongBindingCountAlert alm={AlmKeys.BitbucketServer} /> | |||
)} | |||
{!loading && |
@@ -22,7 +22,6 @@ import { LargeCenteredLayout } from 'design-system'; | |||
import * as React from 'react'; | |||
import { Helmet } from 'react-helmet-async'; | |||
import { getDopSettings } from '../../../api/dop-translation'; | |||
import withAppStateContext from '../../../app/components/app-state/withAppStateContext'; | |||
import withAvailableFeatures, { | |||
WithAvailableFeaturesProps, | |||
} from '../../../app/components/available-features/withAvailableFeatures'; | |||
@@ -30,7 +29,6 @@ import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; | |||
import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; | |||
import { AppState } from '../../../types/appstate'; | |||
import { DopSetting } from '../../../types/dop-translation'; | |||
import { Feature } from '../../../types/features'; | |||
import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm'; | |||
@@ -45,7 +43,6 @@ import ManualProjectCreate from './manual/ManualProjectCreate'; | |||
import { CreateProjectModes } from './types'; | |||
export interface CreateProjectPageProps extends WithAvailableFeaturesProps { | |||
appState: AppState; | |||
location: Location; | |||
router: Router; | |||
} | |||
@@ -53,7 +50,7 @@ export interface CreateProjectPageProps extends WithAvailableFeaturesProps { | |||
interface State { | |||
azureSettings: DopSetting[]; | |||
bitbucketSettings: AlmSettingsInstance[]; | |||
bitbucketCloudSettings: AlmSettingsInstance[]; | |||
bitbucketCloudSettings: DopSetting[]; | |||
githubSettings: DopSetting[]; | |||
gitlabSettings: DopSetting[]; | |||
loading: boolean; | |||
@@ -197,9 +194,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
bitbucketSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.BitbucketServer) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
bitbucketCloudSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.BitbucketCloud) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
bitbucketCloudSettings: dopSettings.filter(({ type }) => type === AlmKeys.BitbucketCloud), | |||
githubSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitHub), | |||
gitlabSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitLab), | |||
loading: false, | |||
@@ -254,11 +249,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
}; | |||
renderProjectCreation(mode?: CreateProjectModes) { | |||
const { | |||
appState: { canAdmin }, | |||
location, | |||
router, | |||
} = this.props; | |||
const { location, router } = this.props; | |||
const { | |||
azureSettings, | |||
bitbucketSettings, | |||
@@ -274,7 +265,6 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
case CreateProjectModes.AzureDevOps: { | |||
return ( | |||
<AzureProjectCreate | |||
canAdmin={!!canAdmin} | |||
dopSettings={azureSettings} | |||
isLoadingBindings={loading} | |||
onProjectSetupDone={this.handleProjectSetupDone} | |||
@@ -284,7 +274,6 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
case CreateProjectModes.BitbucketServer: { | |||
return ( | |||
<BitbucketProjectCreate | |||
canAdmin={!!canAdmin} | |||
almInstances={bitbucketSettings} | |||
loadingBindings={loading} | |||
location={location} | |||
@@ -296,19 +285,15 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
case CreateProjectModes.BitbucketCloud: { | |||
return ( | |||
<BitbucketCloudProjectCreate | |||
canAdmin={!!canAdmin} | |||
loadingBindings={loading} | |||
location={location} | |||
dopSettings={bitbucketCloudSettings} | |||
isLoadingBindings={loading} | |||
onProjectSetupDone={this.handleProjectSetupDone} | |||
router={router} | |||
almInstances={bitbucketCloudSettings} | |||
/> | |||
); | |||
} | |||
case CreateProjectModes.GitHub: { | |||
return ( | |||
<GitHubProjectCreate | |||
canAdmin={!!canAdmin} | |||
isLoadingBindings={loading} | |||
onProjectSetupDone={this.handleProjectSetupDone} | |||
dopSettings={githubSettings} | |||
@@ -318,7 +303,6 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
case CreateProjectModes.GitLab: { | |||
return ( | |||
<GitlabProjectCreate | |||
canAdmin={!!canAdmin} | |||
dopSettings={gitlabSettings} | |||
isLoadingBindings={loading} | |||
onProjectSetupDone={this.handleProjectSetupDone} | |||
@@ -397,4 +381,4 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
} | |||
} | |||
export default withRouter(withAvailableFeatures(withAppStateContext(CreateProjectPage))); | |||
export default withRouter(withAvailableFeatures(CreateProjectPage)); |
@@ -18,54 +18,66 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { LabelValueSelectOption } from 'design-system'; | |||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |||
import React, { useCallback, useEffect, useMemo, useState } from 'react'; | |||
import { getGithubOrganizations, getGithubRepositories } from '../../../../api/alm-integrations'; | |||
import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; | |||
import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration'; | |||
import { AlmSettingsInstance } from '../../../../types/alm-settings'; | |||
import { AlmInstanceBase, AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
import { Paging } from '../../../../types/types'; | |||
import { ImportProjectParam } from '../CreateProjectPage'; | |||
import { REPOSITORY_PAGE_SIZE } from '../constants'; | |||
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate'; | |||
import { CreateProjectModes } from '../types'; | |||
import { useProjectCreate } from '../useProjectCreate'; | |||
import { useProjectRepositorySearch } from '../useProjectRepositorySearch'; | |||
import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer'; | |||
import { redirectToGithub } from './utils'; | |||
interface Props { | |||
canAdmin: boolean; | |||
isLoadingBindings: boolean; | |||
onProjectSetupDone: (importProjects: ImportProjectParam) => void; | |||
dopSettings: DopSetting[]; | |||
} | |||
const REPOSITORY_PAGE_SIZE = 50; | |||
const REPOSITORY_SEARCH_DEBOUNCE_TIME = 250; | |||
export default function GitHubProjectCreate(props: Readonly<Props>) { | |||
const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const { dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>(); | |||
const { | |||
handleSelectRepository, | |||
isInitialized, | |||
isLoadingOrganizations, | |||
isLoadingRepositories, | |||
isMonorepoSetup, | |||
onSelectedAlmInstanceChange, | |||
onSelectDopSetting, | |||
projectsPaging, | |||
organizations, | |||
repositories, | |||
searchQuery, | |||
selectedDopSetting, | |||
selectedRepository, | |||
setIsInitialized, | |||
setIsLoadingRepositories, | |||
setProjectsPaging, | |||
setOrganizations, | |||
setRepositories, | |||
setSearchQuery, | |||
setSelectedOrganization, | |||
selectedOrganization, | |||
setIsLoadingOrganizations, | |||
} = useProjectCreate<GithubRepository, GithubOrganization>( | |||
AlmKeys.GitHub, | |||
dopSettings, | |||
({ key }) => key, | |||
REPOSITORY_PAGE_SIZE, | |||
); | |||
const [isInError, setIsInError] = useState(false); | |||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true); | |||
const [isLoadingRepositories, setIsLoadingRepositories] = useState(false); | |||
const [organizations, setOrganizations] = useState<GithubOrganization[]>([]); | |||
const [repositories, setRepositories] = useState<GithubRepository[]>([]); | |||
const [repositoryPaging, setRepositoryPaging] = useState<Paging>({ | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
total: 0, | |||
pageIndex: 1, | |||
}); | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>(); | |||
const [selectedOrganization, setSelectedOrganization] = useState<GithubOrganization>(); | |||
const [selectedRepository, setSelectedRepository] = useState<GithubRepository>(); | |||
const [isAuthenticated, setIsAuthenticated] = useState(false); | |||
const location = useLocation(); | |||
const router = useRouter(); | |||
const isMonorepoSetup = location.query?.mono === 'true'; | |||
const hasDopSettings = Boolean(dopSettings?.length); | |||
const organizationOptions = useMemo(() => { | |||
return organizations.map(transformToOption); | |||
}, [organizations]); | |||
@@ -74,37 +86,59 @@ export default function GitHubProjectCreate(props: Readonly<Props>) { | |||
}, [repositories]); | |||
const fetchRepositories = useCallback( | |||
async (params: { organizationKey: string; page?: number; query?: string }) => { | |||
const { organizationKey, page = 1, query } = params; | |||
(orgKey: string, query?: string, pageIndex = 1) => { | |||
if (selectedDopSetting === undefined) { | |||
setIsInError(true); | |||
return; | |||
return Promise.resolve(); | |||
} | |||
setIsLoadingRepositories(true); | |||
try { | |||
const { paging, repositories } = await getGithubRepositories({ | |||
almSetting: selectedDopSetting.key, | |||
organization: organizationKey, | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
page, | |||
query, | |||
return getGithubRepositories({ | |||
almSetting: selectedDopSetting.key, | |||
organization: orgKey, | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
page: pageIndex, | |||
query, | |||
}) | |||
.then(({ paging, repositories }) => { | |||
setProjectsPaging(paging); | |||
setRepositories((prevRepositories) => | |||
pageIndex === 1 ? repositories : [...prevRepositories, ...repositories], | |||
); | |||
setIsInitialized(true); | |||
}) | |||
.finally(() => { | |||
setIsLoadingRepositories(false); | |||
}) | |||
.catch(() => { | |||
setProjectsPaging({ pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 }); | |||
setRepositories([]); | |||
}); | |||
}, | |||
[ | |||
selectedDopSetting, | |||
setIsInitialized, | |||
setIsLoadingRepositories, | |||
setProjectsPaging, | |||
setRepositories, | |||
], | |||
); | |||
setRepositoryPaging(paging); | |||
setRepositories((prevRepositories) => | |||
page === 1 ? repositories : [...prevRepositories, ...repositories], | |||
); | |||
} catch (_) { | |||
setRepositoryPaging({ pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 }); | |||
setRepositories([]); | |||
} finally { | |||
setIsLoadingRepositories(false); | |||
} | |||
const onSelectDopSettingReauthenticate = useCallback( | |||
(setting?: DopSetting) => { | |||
onSelectDopSetting(setting); | |||
setIsAuthenticated(false); | |||
}, | |||
[onSelectDopSetting], | |||
); | |||
const onSelectAlmSettingReauthenticate = useCallback( | |||
(setting?: AlmInstanceBase) => { | |||
onSelectedAlmInstanceChange(setting); | |||
setIsAuthenticated(false); | |||
}, | |||
[selectedDopSetting], | |||
[onSelectedAlmInstanceChange], | |||
); | |||
const handleImportRepository = useCallback( | |||
@@ -123,73 +157,18 @@ export default function GitHubProjectCreate(props: Readonly<Props>) { | |||
const handleLoadMore = useCallback(() => { | |||
if (selectedOrganization) { | |||
fetchRepositories({ | |||
organizationKey: selectedOrganization.key, | |||
page: repositoryPaging.pageIndex + 1, | |||
query: searchQuery, | |||
}); | |||
fetchRepositories(selectedOrganization.key, searchQuery, projectsPaging.pageIndex + 1); | |||
} | |||
}, [fetchRepositories, repositoryPaging.pageIndex, searchQuery, selectedOrganization]); | |||
}, [fetchRepositories, projectsPaging.pageIndex, searchQuery, selectedOrganization]); | |||
const handleSelectOrganization = useCallback( | |||
(organizationKey: string) => { | |||
setSearchQuery(''); | |||
setSelectedOrganization(organizations.find(({ key }) => key === organizationKey)); | |||
fetchRepositories({ organizationKey }); | |||
}, | |||
[fetchRepositories, organizations], | |||
); | |||
const handleSelectRepository = useCallback( | |||
(repositoryIdentifier: string) => { | |||
setSelectedRepository(repositories.find(({ key }) => key === repositoryIdentifier)); | |||
}, | |||
[repositories], | |||
); | |||
const authenticateToGithub = useCallback(async () => { | |||
try { | |||
await redirectToGithub({ isMonorepoSetup, selectedDopSetting }); | |||
} catch { | |||
setIsInError(true); | |||
} | |||
}, [isMonorepoSetup, selectedDopSetting]); | |||
const onSelectDopSetting = useCallback((setting: DopSetting | undefined) => { | |||
setSelectedDopSetting(setting); | |||
setOrganizations([]); | |||
setRepositories([]); | |||
setSearchQuery(''); | |||
}, []); | |||
const onSelectedAlmInstanceChange = useCallback( | |||
(instance: AlmSettingsInstance) => { | |||
onSelectDopSetting(dopSettings.find((dopSetting) => dopSetting.key === instance.key)); | |||
}, | |||
[dopSettings, onSelectDopSetting], | |||
[organizations, setSearchQuery, setSelectedOrganization], | |||
); | |||
useEffect(() => { | |||
const selectedDopSettingId = location.query?.dopSetting; | |||
if (selectedDopSettingId !== undefined) { | |||
const selectedDopSetting = dopSettings.find(({ id }) => id === selectedDopSettingId); | |||
if (selectedDopSetting) { | |||
setSelectedDopSetting(selectedDopSetting); | |||
} | |||
return; | |||
} | |||
if (dopSettings.length > 1) { | |||
setSelectedDopSetting(undefined); | |||
return; | |||
} | |||
setSelectedDopSetting(dopSettings[0]); | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [hasDopSettings]); | |||
useEffect(() => { | |||
if (selectedDopSetting?.url === undefined) { | |||
setIsInError(true); | |||
@@ -198,53 +177,49 @@ export default function GitHubProjectCreate(props: Readonly<Props>) { | |||
setIsInError(false); | |||
const code = location.query?.code; | |||
if (code === undefined) { | |||
authenticateToGithub().catch(() => { | |||
setIsInError(true); | |||
}); | |||
} else { | |||
delete location.query.code; | |||
router.replace(location); | |||
getGithubOrganizations(selectedDopSetting.key, code) | |||
.then(({ organizations }) => { | |||
setOrganizations(organizations); | |||
setIsLoadingOrganizations(false); | |||
}) | |||
.catch(() => { | |||
if (!isAuthenticated) { | |||
if (code === undefined) { | |||
redirectToGithub({ isMonorepoSetup, selectedDopSetting }).catch(() => { | |||
setIsInError(true); | |||
}); | |||
} else { | |||
setIsAuthenticated(true); | |||
delete location.query.code; | |||
router.replace(location); | |||
getGithubOrganizations(selectedDopSetting.key, code) | |||
.then(({ organizations }) => { | |||
setOrganizations(organizations); | |||
setIsLoadingOrganizations(false); | |||
}) | |||
.catch(() => { | |||
setIsInError(true); | |||
}); | |||
} | |||
} | |||
// Disabling rule as it causes an infinite loop and should only be called for dopSetting changes. | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [selectedDopSetting]); | |||
useEffect(() => { | |||
repositorySearchDebounceId.current = setTimeout(() => { | |||
if (selectedOrganization) { | |||
fetchRepositories({ | |||
organizationKey: selectedOrganization.key, | |||
query: searchQuery, | |||
}); | |||
} | |||
}, REPOSITORY_SEARCH_DEBOUNCE_TIME); | |||
return () => { | |||
clearTimeout(repositorySearchDebounceId.current); | |||
}; | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [searchQuery]); | |||
const { onSearch } = useProjectRepositorySearch( | |||
AlmKeys.GitHub, | |||
fetchRepositories, | |||
isInitialized, | |||
selectedDopSetting, | |||
selectedOrganization?.key, | |||
setSearchQuery, | |||
); | |||
return isMonorepoSetup ? ( | |||
<MonorepoProjectCreate | |||
dopSettings={dopSettings} | |||
canAdmin={canAdmin} | |||
error={isInError} | |||
loadingBindings={isLoadingBindings} | |||
loadingOrganizations={isLoadingOrganizations} | |||
loadingRepositories={isLoadingRepositories} | |||
onProjectSetupDone={onProjectSetupDone} | |||
onSearchRepositories={setSearchQuery} | |||
onSelectDopSetting={onSelectDopSetting} | |||
onSearchRepositories={onSearch} | |||
onSelectDopSetting={onSelectDopSettingReauthenticate} | |||
onSelectOrganization={handleSelectOrganization} | |||
onSelectRepository={handleSelectRepository} | |||
organizationOptions={organizationOptions} | |||
@@ -262,19 +237,18 @@ export default function GitHubProjectCreate(props: Readonly<Props>) { | |||
key, | |||
url, | |||
}))} | |||
canAdmin={canAdmin} | |||
error={isInError} | |||
loadingBindings={isLoadingBindings} | |||
loadingOrganizations={isLoadingOrganizations} | |||
loadingRepositories={isLoadingRepositories} | |||
onImportRepository={handleImportRepository} | |||
onLoadMore={handleLoadMore} | |||
onSearch={setSearchQuery} | |||
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange} | |||
onSearch={onSearch} | |||
onSelectedAlmInstanceChange={onSelectAlmSettingReauthenticate} | |||
onSelectOrganization={handleSelectOrganization} | |||
organizations={organizations} | |||
repositories={repositories} | |||
repositoryPaging={repositoryPaging} | |||
repositoryPaging={projectsPaging} | |||
searchQuery={searchQuery} | |||
selectedAlmInstance={ | |||
selectedDopSetting && { |
@@ -23,6 +23,7 @@ import { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { DarkLabel, FlagMessage, InputSelect, LightPrimary, Title } from 'design-system'; | |||
import React, { useContext, useEffect, useState } from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { useAppState } from '../../../../app/components/app-state/withAppStateContext'; | |||
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { LabelValueSelectOption } from '../../../../helpers/search'; | |||
@@ -36,7 +37,6 @@ import RepositoryList from '../components/RepositoryList'; | |||
import { CreateProjectModes } from '../types'; | |||
interface GitHubProjectCreateRendererProps { | |||
canAdmin: boolean; | |||
error: boolean; | |||
loadingBindings: boolean; | |||
loadingOrganizations: boolean; | |||
@@ -67,7 +67,6 @@ export default function GitHubProjectCreateRenderer( | |||
); | |||
const { | |||
canAdmin, | |||
error, | |||
loadingBindings, | |||
loadingOrganizations, | |||
@@ -78,6 +77,7 @@ export default function GitHubProjectCreateRenderer( | |||
repositories, | |||
} = props; | |||
const [selected, setSelected] = useState<Set<string>>(new Set()); | |||
const { canAdmin } = useAppState(); | |||
useEffect(() => { | |||
const selectedKeys = Array.from(selected).filter((key) => |
@@ -18,116 +18,112 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { LabelValueSelectOption } from 'design-system'; | |||
import { orderBy } from 'lodash'; | |||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |||
import React, { useCallback, useMemo } from 'react'; | |||
import { getGitlabProjects } from '../../../../api/alm-integrations'; | |||
import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; | |||
import { useLocation } from '../../../../components/hoc/withRouter'; | |||
import { GitlabProject } from '../../../../types/alm-integration'; | |||
import { AlmInstanceBase } from '../../../../types/alm-settings'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
import { Paging } from '../../../../types/types'; | |||
import { ImportProjectParam } from '../CreateProjectPage'; | |||
import { REPOSITORY_PAGE_SIZE } from '../constants'; | |||
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate'; | |||
import { CreateProjectModes } from '../types'; | |||
import { useProjectCreate } from '../useProjectCreate'; | |||
import { useProjectRepositorySearch } from '../useProjectRepositorySearch'; | |||
import GitlabPersonalAccessTokenForm from './GItlabPersonalAccessTokenForm'; | |||
import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer'; | |||
interface Props { | |||
canAdmin: boolean; | |||
isLoadingBindings: boolean; | |||
onProjectSetupDone: (importProjects: ImportProjectParam) => void; | |||
dopSettings: DopSetting[]; | |||
} | |||
const REPOSITORY_PAGE_SIZE = 50; | |||
const REPOSITORY_SEARCH_DEBOUNCE_TIME = 250; | |||
export default function GitlabProjectCreate(props: Readonly<Props>) { | |||
const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>(); | |||
const [isLoadingRepositories, setIsLoadingRepositories] = useState(false); | |||
const [repositories, setRepositories] = useState<GitlabProject[]>([]); | |||
const [repositoryPaging, setRepositoryPaging] = useState<Paging>({ | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
total: 0, | |||
pageIndex: 1, | |||
}); | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>(); | |||
const [selectedRepository, setSelectedRepository] = useState<GitlabProject>(); | |||
const [resetPersonalAccessToken, setResetPersonalAccessToken] = useState<boolean>(false); | |||
const [showPersonalAccessTokenForm, setShowPersonalAccessTokenForm] = useState<boolean>(true); | |||
const { dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const { | |||
handlePersonalAccessTokenCreated, | |||
handleSelectRepository, | |||
isInitialized, | |||
isLoadingRepositories, | |||
isMonorepoSetup, | |||
onSelectedAlmInstanceChange, | |||
onSelectDopSetting, | |||
projectsPaging, | |||
repositories, | |||
resetPersonalAccessToken, | |||
searchQuery, | |||
selectedDopSetting, | |||
selectedRepository, | |||
setIsInitialized, | |||
setIsLoadingRepositories, | |||
setProjectsPaging, | |||
setRepositories, | |||
setResetPersonalAccessToken, | |||
setSearchQuery, | |||
setShowPersonalAccessTokenForm, | |||
showPersonalAccessTokenForm, | |||
} = useProjectCreate<GitlabProject, undefined>( | |||
AlmKeys.GitLab, | |||
dopSettings, | |||
({ id }) => id, | |||
REPOSITORY_PAGE_SIZE, | |||
); | |||
const location = useLocation(); | |||
const router = useRouter(); | |||
const isMonorepoSetup = location.query?.mono === 'true'; | |||
const hasDopSettings = useMemo(() => { | |||
if (dopSettings === undefined) { | |||
return false; | |||
} | |||
return dopSettings.length > 0; | |||
}, [dopSettings]); | |||
const repositoryOptions = useMemo(() => { | |||
return repositories.map(transformToOption); | |||
}, [repositories]); | |||
const fetchProjects = useCallback( | |||
(pageIndex = 1, query?: string) => { | |||
if (!selectedDopSetting) { | |||
return Promise.resolve(undefined); | |||
const fetchRepositories = useCallback( | |||
(_orgKey?: string, query = '', pageIndex = 1, more = false) => { | |||
if (showPersonalAccessTokenForm || !selectedDopSetting) { | |||
return Promise.resolve(); | |||
} | |||
setIsLoadingRepositories(true); | |||
// eslint-disable-next-line local-rules/no-api-imports | |||
return getGitlabProjects({ | |||
almSetting: selectedDopSetting.key, | |||
page: pageIndex, | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
query, | |||
}); | |||
}, | |||
[selectedDopSetting], | |||
); | |||
const fetchInitialData = useCallback(() => { | |||
if (!showPersonalAccessTokenForm) { | |||
setIsLoadingRepositories(true); | |||
fetchProjects() | |||
}) | |||
.then((result) => { | |||
if (result?.projects) { | |||
setIsLoadingRepositories(false); | |||
setProjectsPaging(result.projectsPaging); | |||
setRepositories( | |||
isMonorepoSetup | |||
? orderBy(result.projects, [(res) => res.name.toLowerCase()], ['asc']) | |||
more && repositories && repositories.length > 0 | |||
? [...repositories, ...result.projects] | |||
: result.projects, | |||
); | |||
setRepositoryPaging(result.projectsPaging); | |||
} else { | |||
setIsLoadingRepositories(false); | |||
setIsInitialized(true); | |||
} | |||
}) | |||
.finally(() => { | |||
setIsLoadingRepositories(false); | |||
}) | |||
.catch(() => { | |||
setResetPersonalAccessToken(true); | |||
setShowPersonalAccessTokenForm(true); | |||
setIsLoadingRepositories(false); | |||
}); | |||
} | |||
}, [fetchProjects, isMonorepoSetup, showPersonalAccessTokenForm]); | |||
const cleanUrl = useCallback(() => { | |||
delete location.query.resetPat; | |||
router.replace(location); | |||
}, [location, router]); | |||
const handlePersonalAccessTokenCreated = useCallback(() => { | |||
cleanUrl(); | |||
setShowPersonalAccessTokenForm(false); | |||
setResetPersonalAccessToken(false); | |||
fetchInitialData(); | |||
}, [cleanUrl, fetchInitialData]); | |||
}, | |||
[ | |||
repositories, | |||
selectedDopSetting, | |||
setIsInitialized, | |||
setIsLoadingRepositories, | |||
setProjectsPaging, | |||
setRepositories, | |||
setResetPersonalAccessToken, | |||
setShowPersonalAccessTokenForm, | |||
showPersonalAccessTokenForm, | |||
], | |||
); | |||
const handleImportRepository = useCallback( | |||
(repoKeys: string[]) => { | |||
@@ -143,76 +139,29 @@ export default function GitlabProjectCreate(props: Readonly<Props>) { | |||
[onProjectSetupDone, selectedDopSetting], | |||
); | |||
const handleLoadMore = useCallback(async () => { | |||
const result = await fetchProjects(repositoryPaging.pageIndex + 1, searchQuery); | |||
if (result?.projects) { | |||
setRepositoryPaging(result ? result.projectsPaging : repositoryPaging); | |||
setRepositories(result ? [...repositories, ...result.projects] : repositories); | |||
} | |||
}, [fetchProjects, repositories, repositoryPaging, searchQuery]); | |||
const handleSelectRepository = useCallback( | |||
(repositoryKey: string) => { | |||
setSelectedRepository(repositories.find(({ id }) => id === repositoryKey)); | |||
}, | |||
[repositories], | |||
); | |||
const onSelectDopSetting = useCallback((setting: DopSetting | undefined) => { | |||
setSelectedDopSetting(setting); | |||
setShowPersonalAccessTokenForm(true); | |||
setRepositories([]); | |||
setSearchQuery(''); | |||
}, []); | |||
const onSelectedAlmInstanceChange = useCallback( | |||
(instance: AlmInstanceBase) => { | |||
onSelectDopSetting(dopSettings.find((dopSetting) => dopSetting.key === instance.key)); | |||
}, | |||
[dopSettings, onSelectDopSetting], | |||
const handleLoadMore = useCallback(() => { | |||
fetchRepositories(undefined, searchQuery, projectsPaging.pageIndex + 1, true); | |||
}, [fetchRepositories, projectsPaging, searchQuery]); | |||
const { onSearch } = useProjectRepositorySearch( | |||
AlmKeys.GitLab, | |||
fetchRepositories, | |||
isInitialized, | |||
selectedDopSetting, | |||
undefined, | |||
setSearchQuery, | |||
showPersonalAccessTokenForm, | |||
); | |||
useEffect(() => { | |||
if (dopSettings.length > 0) { | |||
setSelectedDopSetting(dopSettings[0]); | |||
return; | |||
} | |||
setSelectedDopSetting(undefined); | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [hasDopSettings]); | |||
useEffect(() => { | |||
if (selectedDopSetting) { | |||
fetchInitialData(); | |||
} | |||
}, [fetchInitialData, selectedDopSetting]); | |||
useEffect(() => { | |||
repositorySearchDebounceId.current = setTimeout(async () => { | |||
const result = await fetchProjects(1, searchQuery); | |||
if (result?.projects) { | |||
setRepositories(orderBy(result.projects, [(res) => res.name.toLowerCase()], ['asc'])); | |||
setRepositoryPaging(result.projectsPaging); | |||
} | |||
}, REPOSITORY_SEARCH_DEBOUNCE_TIME); | |||
return () => { | |||
clearTimeout(repositorySearchDebounceId.current); | |||
}; | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [searchQuery]); | |||
return isMonorepoSetup ? ( | |||
<MonorepoProjectCreate | |||
canAdmin={canAdmin} | |||
dopSettings={dopSettings} | |||
error={false} | |||
loadingBindings={isLoadingBindings} | |||
loadingOrganizations={false} | |||
loadingRepositories={isLoadingRepositories} | |||
onProjectSetupDone={onProjectSetupDone} | |||
onSearchRepositories={setSearchQuery} | |||
onSearchRepositories={onSearch} | |||
onSelectDopSetting={onSelectDopSetting} | |||
onSelectRepository={handleSelectRepository} | |||
personalAccessTokenComponent={ | |||
@@ -238,15 +187,14 @@ export default function GitlabProjectCreate(props: Readonly<Props>) { | |||
key: dopSetting.key, | |||
url: dopSetting.url, | |||
}))} | |||
canAdmin={canAdmin} | |||
loading={isLoadingRepositories || isLoadingBindings} | |||
onImport={handleImportRepository} | |||
onLoadMore={handleLoadMore} | |||
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated} | |||
onSearch={setSearchQuery} | |||
onSearch={onSearch} | |||
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange} | |||
projects={repositories} | |||
projectsPaging={repositoryPaging} | |||
projectsPaging={projectsPaging} | |||
resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)} | |||
searchQuery={searchQuery} | |||
selectedAlmInstance={ |
@@ -35,7 +35,7 @@ import { CreateProjectModes } from '../types'; | |||
import GitlabPersonalAccessTokenForm from './GItlabPersonalAccessTokenForm'; | |||
export interface GitlabProjectCreateRendererProps { | |||
canAdmin?: boolean; | |||
almInstances?: AlmSettingsInstance[]; | |||
loading: boolean; | |||
onImport: (id: string[]) => void; | |||
onLoadMore: () => void; | |||
@@ -45,10 +45,9 @@ export interface GitlabProjectCreateRendererProps { | |||
projectsPaging: Paging; | |||
resetPat: boolean; | |||
searchQuery: string; | |||
almInstances?: AlmSettingsInstance[]; | |||
selectedAlmInstance?: AlmSettingsInstance; | |||
showPersonalAccessTokenForm?: boolean; | |||
onSelectedAlmInstanceChange: (instance: AlmInstanceBase) => void; | |||
showPersonalAccessTokenForm?: boolean; | |||
} | |||
export default function GitlabProjectCreateRenderer( | |||
@@ -60,7 +59,6 @@ export default function GitlabProjectCreateRenderer( | |||
const { | |||
almInstances, | |||
canAdmin, | |||
loading, | |||
onLoadMore, | |||
onSearch, | |||
@@ -139,8 +137,8 @@ export default function GitlabProjectCreateRenderer( | |||
<Spinner isLoading={loading} /> | |||
{!loading && !selectedAlmInstance && ( | |||
<WrongBindingCountAlert alm={AlmKeys.GitLab} canAdmin={!!canAdmin} /> | |||
{!loading && almInstances && almInstances.length === 0 && !selectedAlmInstance && ( | |||
<WrongBindingCountAlert alm={AlmKeys.GitLab} /> | |||
)} | |||
{!loading && |
@@ -28,8 +28,10 @@ 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 { BITBUCKET_CLOUD_PROJECTS_PAGESIZE } from '../constants'; | |||
import { REPOSITORY_PAGE_SIZE } from '../constants'; | |||
import { CreateProjectModes } from '../types'; | |||
jest.mock('../../../../api/alm-integrations'); | |||
jest.mock('../../../../api/alm-settings'); | |||
@@ -39,13 +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', | |||
}), | |||
monorepoSetupLink: byRole('link', { | |||
name: 'onboarding.create_project.subtitle_monorepo_setup_link', | |||
}), | |||
monorepoTitle: byRole('heading', { | |||
name: 'onboarding.create_project.monorepo.titlealm.bitbucketcloud', | |||
}), | |||
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; | |||
@@ -78,8 +96,10 @@ it('should ask for PAT when it is not set yet and show the import project featur | |||
expect(screen.getByText('onboarding.create_project.bitbucketcloud.title')).toBeInTheDocument(); | |||
expect(await ui.instanceSelector.find()).toBeInTheDocument(); | |||
await selectEvent.select(ui.instanceSelector.get(), [/conf-bitbucketcloud-1/]); | |||
expect( | |||
screen.getByText('onboarding.create_project.bitbucket_cloud.enter_password'), | |||
await screen.findByText('onboarding.create_project.bitbucket_cloud.enter_password'), | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByText('onboarding.create_project.enter_password.instructions.bitbucket_cloud'), | |||
@@ -93,21 +113,15 @@ it('should ask for PAT when it is not set yet and show the import project featur | |||
expect(screen.getByRole('button', { name: 'save' })).toBeDisabled(); | |||
await user.click( | |||
screen.getByRole('textbox', { | |||
name: /onboarding.create_project.bitbucket_cloud.enter_username/, | |||
}), | |||
); | |||
await user.click(ui.userName.get()); | |||
await user.type(ui.userName.get(), 'username'); | |||
await user.keyboard('username'); | |||
expect(ui.userName.get()).toHaveValue('username'); | |||
await user.click( | |||
screen.getByRole('textbox', { | |||
name: /onboarding.create_project.bitbucket_cloud.enter_password/, | |||
}), | |||
); | |||
await user.click(ui.password.get()); | |||
await user.type(ui.password.get(), 'password'); | |||
await user.keyboard('password'); | |||
expect(ui.password.get()).toHaveValue('password'); | |||
expect(screen.getByRole('button', { name: 'save' })).toBeEnabled(); | |||
await user.click(screen.getByRole('button', { name: 'save' })); | |||
@@ -176,7 +190,7 @@ it('should show search filter when PAT is already set', async () => { | |||
expect(searchForBitbucketCloudRepositories).toHaveBeenLastCalledWith( | |||
'conf-bitbucketcloud-2', | |||
'', | |||
BITBUCKET_CLOUD_PROJECTS_PAGESIZE, | |||
REPOSITORY_PAGE_SIZE, | |||
1, | |||
), | |||
); | |||
@@ -185,13 +199,15 @@ it('should show search filter when PAT is already set', async () => { | |||
name: 'onboarding.create_project.search_prompt', | |||
}); | |||
await user.click(inputSearch); | |||
await user.keyboard('search'); | |||
await user.type(inputSearch, 'search'); | |||
expect(searchForBitbucketCloudRepositories).toHaveBeenLastCalledWith( | |||
'conf-bitbucketcloud-2', | |||
'search', | |||
BITBUCKET_CLOUD_PROJECTS_PAGESIZE, | |||
1, | |||
await waitFor(() => | |||
expect(searchForBitbucketCloudRepositories).toHaveBeenLastCalledWith( | |||
'conf-bitbucketcloud-2', | |||
'search', | |||
REPOSITORY_PAGE_SIZE, | |||
1, | |||
), | |||
); | |||
}); | |||
@@ -212,8 +228,8 @@ it('should show no result message when there are no projects', async () => { | |||
it('should have load more', async () => { | |||
const user = userEvent.setup(); | |||
almIntegrationHandler.createRandomBitbucketCloudProjectsWithLoadMore( | |||
BITBUCKET_CLOUD_PROJECTS_PAGESIZE, | |||
BITBUCKET_CLOUD_PROJECTS_PAGESIZE + 1, | |||
REPOSITORY_PAGE_SIZE, | |||
REPOSITORY_PAGE_SIZE + 1, | |||
); | |||
renderCreateProject(); | |||
@@ -229,16 +245,18 @@ it('should have load more', async () => { | |||
* loadmore button disapperance. | |||
*/ | |||
almIntegrationHandler.createRandomBitbucketCloudProjectsWithLoadMore( | |||
BITBUCKET_CLOUD_PROJECTS_PAGESIZE + 1, | |||
BITBUCKET_CLOUD_PROJECTS_PAGESIZE + 1, | |||
REPOSITORY_PAGE_SIZE + 1, | |||
REPOSITORY_PAGE_SIZE + 1, | |||
); | |||
await user.click(screen.getByRole('button', { name: 'show_more' })); | |||
expect(searchForBitbucketCloudRepositories).toHaveBeenLastCalledWith( | |||
'conf-bitbucketcloud-2', | |||
'', | |||
BITBUCKET_CLOUD_PROJECTS_PAGESIZE, | |||
2, | |||
await waitFor(() => | |||
expect(searchForBitbucketCloudRepositories).toHaveBeenLastCalledWith( | |||
'conf-bitbucketcloud-2', | |||
'', | |||
REPOSITORY_PAGE_SIZE, | |||
2, | |||
), | |||
); | |||
await waitFor(() => { | |||
@@ -246,8 +264,38 @@ it('should have load more', async () => { | |||
}); | |||
}); | |||
function renderCreateProject() { | |||
renderApp('project/create', <CreateProjectPage />, { | |||
navigateTo: 'project/create?mode=bitbucketcloud', | |||
describe('Bitbucket Cloud monorepo project navigation', () => { | |||
it('should be able to access monorepo setup page from Bitbucket Cloud 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 Cloud onboarding page from monorepo setup page', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject({ isMonorepo: true }); | |||
await user.click(await ui.cancelButton.find()); | |||
expect(ui.bitbucketCloudOnboardingTitle.get()).toBeInTheDocument(); | |||
}); | |||
}); | |||
function renderCreateProject({ | |||
isMonorepo = false, | |||
}: { | |||
isMonorepo?: boolean; | |||
} = {}) { | |||
let queryString = `mode=${CreateProjectModes.BitbucketCloud}`; | |||
if (isMonorepo) { | |||
queryString += '&mono=true'; | |||
} | |||
renderApp('projects/create', <CreateProjectPage />, { | |||
navigateTo: `projects/create?${queryString}`, | |||
featureList: [Feature.MonoRepositoryPullRequestDecoration], | |||
}); | |||
} |
@@ -113,8 +113,9 @@ it('should ask for PAT when it is not set yet and show the import project featur | |||
expect(await ui.importProjectsTitle.find()).toBeInTheDocument(); | |||
expect(ui.instanceSelector.get()).toBeInTheDocument(); | |||
await selectEvent.select(ui.instanceSelector.get(), [/conf-final-1/]); | |||
expect(screen.getByText('onboarding.create_project.enter_pat')).toBeInTheDocument(); | |||
expect(await screen.findByText('onboarding.create_project.enter_pat')).toBeInTheDocument(); | |||
expect(ui.patHelpInstructions.get()).toBeInTheDocument(); | |||
expect(ui.saveButton.get()).toBeInTheDocument(); | |||
@@ -17,9 +17,11 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { FlagMessage, Link } from 'design-system'; | |||
import { Link } from '@sonarsource/echoes-react'; | |||
import { FlagMessage } from 'design-system'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { useAppState } from '../../../../app/components/app-state/withAppStateContext'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { getGlobalSettingsUrl } from '../../../../helpers/urls'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
@@ -27,11 +29,11 @@ import { ALM_INTEGRATION_CATEGORY } from '../../../settings/constants'; | |||
export interface WrongBindingCountAlertProps { | |||
alm: AlmKeys; | |||
canAdmin: boolean; | |||
} | |||
export default function WrongBindingCountAlert(props: WrongBindingCountAlertProps) { | |||
const { alm, canAdmin } = props; | |||
const { alm } = props; | |||
const { canAdmin } = useAppState(); | |||
return ( | |||
<FlagMessage variant="error" className="sw-mb-2"> |
@@ -22,4 +22,6 @@ export const PROJECT_NAME_MAX_LEN = 255; | |||
export const DEFAULT_BBS_PAGE_SIZE = 25; | |||
export const BITBUCKET_CLOUD_PROJECTS_PAGESIZE = 20; | |||
export const REPOSITORY_PAGE_SIZE = 50; | |||
export const REPOSITORY_SEARCH_DEBOUNCE_TIME = 250; |
@@ -35,7 +35,6 @@ interface Props { | |||
projectId: string; | |||
projectName: string; | |||
}[]; | |||
canAdmin: boolean; | |||
dopSettings: DopSetting[]; | |||
error: boolean; | |||
isFetchingAlreadyBoundProjects: boolean; | |||
@@ -61,7 +60,6 @@ interface Props { | |||
export function MonorepoConnectionSelector({ | |||
almKey, | |||
alreadyBoundProjects, | |||
canAdmin, | |||
dopSettings, | |||
error, | |||
isFetchingAlreadyBoundProjects, | |||
@@ -106,14 +104,13 @@ export function MonorepoConnectionSelector({ | |||
) : ( | |||
<> | |||
{showOrganizations && error && selectedDopSetting && !loadingOrganizations && ( | |||
<MonorepoNoOrganisations almKey={almKey} canAdmin={canAdmin} /> | |||
<MonorepoNoOrganisations almKey={almKey} /> | |||
)} | |||
{showOrganizations && organizationOptions && ( | |||
<div className="sw-flex sw-flex-col"> | |||
<MonorepoOrganisationSelector | |||
almKey={almKey} | |||
canAdmin={canAdmin} | |||
error={error} | |||
organizationOptions={organizationOptions} | |||
loadingOrganizations={loadingOrganizations} |
@@ -18,16 +18,15 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { Link } from '@sonarsource/echoes-react'; | |||
import { FlagMessage } from 'design-system/lib'; | |||
import { FlagMessage } from 'design-system'; | |||
import React from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { useAppState } from '../../../../app/components/app-state/withAppStateContext'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
export default function MonorepoNoOrganisations({ | |||
almKey, | |||
canAdmin, | |||
}: Readonly<{ almKey: AlmKeys; canAdmin: boolean }>) { | |||
export default function MonorepoNoOrganisations({ almKey }: Readonly<{ almKey: AlmKeys }>) { | |||
const { formatMessage } = useIntl(); | |||
const { canAdmin } = useAppState(); | |||
return ( | |||
<FlagMessage variant="warning"> |
@@ -21,12 +21,12 @@ import { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { DarkLabel, FlagMessage, InputSelect } from 'design-system'; | |||
import React from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { useAppState } from '../../../../app/components/app-state/withAppStateContext'; | |||
import { LabelValueSelectOption } from '../../../../helpers/search'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
interface Props { | |||
almKey: AlmKeys; | |||
canAdmin: boolean; | |||
error: boolean; | |||
loadingOrganizations?: boolean; | |||
onSelectOrganization?: (organizationKey: string) => void; | |||
@@ -36,7 +36,6 @@ interface Props { | |||
export function MonorepoOrganisationSelector({ | |||
almKey, | |||
canAdmin, | |||
error, | |||
loadingOrganizations, | |||
onSelectOrganization, | |||
@@ -44,6 +43,7 @@ export function MonorepoOrganisationSelector({ | |||
selectedOrganization, | |||
}: Readonly<Props>) { | |||
const { formatMessage } = useIntl(); | |||
const { canAdmin } = useAppState(); | |||
return ( | |||
!error && ( | |||
@@ -55,7 +55,7 @@ export function MonorepoOrganisationSelector({ | |||
<Spinner isLoading={loadingOrganizations && !error}> | |||
{organizationOptions.length > 0 ? ( | |||
<InputSelect | |||
size="large" | |||
size="full" | |||
isSearchable | |||
inputId={`${almKey}-monorepo-choose-organization`} | |||
options={organizationOptions} |
@@ -38,7 +38,6 @@ import { MonorepoProjectHeader } from './MonorepoProjectHeader'; | |||
import { MonorepoProjectsList } from './MonorepoProjectsList'; | |||
interface MonorepoProjectCreateProps { | |||
canAdmin: boolean; | |||
dopSettings: DopSetting[]; | |||
error: boolean; | |||
loadingBindings: boolean; |
@@ -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 { LinkHighlight, LinkStandalone, Spinner } from '@sonarsource/echoes-react'; | |||
import { LinkHighlight, LinkStandalone } from '@sonarsource/echoes-react'; | |||
import { DarkLabel, FlagMessage, InputSelect } from 'design-system'; | |||
import React from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
@@ -67,70 +67,71 @@ export function MonorepoRepositorySelector({ | |||
!loadingRepositories && | |||
((showOrganizations && !!selectedOrganization) || !showOrganizations); | |||
const showWarningMessage = | |||
error || (repositorySelectorEnabled && repositoryOptions && repositoryOptions.length === 0); | |||
error || | |||
(repositorySelectorEnabled && | |||
repositoryOptions && | |||
repositoryOptions.length === 0 && | |||
repositorySearchQuery === ''); | |||
return ( | |||
<> | |||
<DarkLabel htmlFor={`${almKey}-monorepo-choose-repository`} className="sw-mb-2"> | |||
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository" /> | |||
</DarkLabel> | |||
<Spinner isLoading={loadingRepositories && !error}> | |||
{showWarningMessage ? ( | |||
<FormattedMessage | |||
id="onboarding.create_project.monorepo.no_projects" | |||
defaultMessage={formatMessage({ id: 'onboarding.create_project.monorepo.no_projects' })} | |||
values={{ | |||
almKey: formatMessage({ id: `alm.${almKey}` }), | |||
{showWarningMessage ? ( | |||
<FormattedMessage | |||
id="onboarding.create_project.monorepo.no_projects" | |||
defaultMessage={formatMessage({ id: 'onboarding.create_project.monorepo.no_projects' })} | |||
values={{ | |||
almKey: formatMessage({ id: `alm.${almKey}` }), | |||
}} | |||
/> | |||
) : ( | |||
<> | |||
<InputSelect | |||
inputId={`${almKey}-monorepo-choose-repository`} | |||
inputValue={repositorySearchQuery} | |||
isLoading={loadingRepositories} | |||
isSearchable | |||
noOptionsMessage={() => formatMessage({ id: 'no_results' })} | |||
onChange={({ value }: LabelValueSelectOption) => { | |||
onSelectRepository(value); | |||
}} | |||
onInputChange={onSearchRepositories} | |||
options={repositoryOptions} | |||
placeholder={formatMessage({ | |||
id: `onboarding.create_project.monorepo.choose_repository.placeholder`, | |||
})} | |||
size="full" | |||
value={selectedRepository} | |||
/> | |||
) : ( | |||
<> | |||
<InputSelect | |||
inputId={`${almKey}-monorepo-choose-repository`} | |||
inputValue={repositorySearchQuery} | |||
isDisabled={!repositorySelectorEnabled} | |||
isLoading={loadingRepositories} | |||
isSearchable | |||
noOptionsMessage={() => formatMessage({ id: 'no_results' })} | |||
onChange={({ value }: LabelValueSelectOption) => { | |||
onSelectRepository(value); | |||
}} | |||
onInputChange={onSearchRepositories} | |||
options={repositoryOptions} | |||
placeholder={formatMessage({ | |||
id: `onboarding.create_project.monorepo.choose_repository.placeholder`, | |||
})} | |||
size="full" | |||
value={selectedRepository} | |||
/> | |||
{selectedRepository && | |||
!isLoadingAlreadyBoundProjects && | |||
!isFetchingAlreadyBoundProjects && ( | |||
<FlagMessage className="sw-mt-2" variant="info"> | |||
{alreadyBoundProjects.length === 0 ? ( | |||
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.no_already_bound_projects" /> | |||
) : ( | |||
<div> | |||
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects" /> | |||
<ul className="sw-mt-4"> | |||
{alreadyBoundProjects.map(({ projectId, projectName }) => ( | |||
<li key={projectId}> | |||
<LinkStandalone | |||
to={getProjectUrl(projectId)} | |||
highlight={LinkHighlight.Subdued} | |||
> | |||
{projectName} | |||
</LinkStandalone> | |||
</li> | |||
))} | |||
</ul> | |||
</div> | |||
)} | |||
</FlagMessage> | |||
)} | |||
</> | |||
)} | |||
</Spinner> | |||
{selectedRepository && | |||
!isLoadingAlreadyBoundProjects && | |||
!isFetchingAlreadyBoundProjects && ( | |||
<FlagMessage className="sw-mt-2" variant="info"> | |||
{alreadyBoundProjects.length === 0 ? ( | |||
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.no_already_bound_projects" /> | |||
) : ( | |||
<div> | |||
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects" /> | |||
<ul className="sw-mt-4"> | |||
{alreadyBoundProjects.map(({ projectId, projectName }) => ( | |||
<li key={projectId}> | |||
<LinkStandalone | |||
to={getProjectUrl(projectId)} | |||
highlight={LinkHighlight.Subdued} | |||
> | |||
{projectName} | |||
</LinkStandalone> | |||
</li> | |||
))} | |||
</ul> | |||
</div> | |||
)} | |||
</FlagMessage> | |||
)} | |||
</> | |||
)} | |||
</> | |||
); | |||
} |
@@ -0,0 +1,161 @@ | |||
/* | |||
* 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, useState } from 'react'; | |||
import { useLocation, useRouter } from '../../../components/hoc/withRouter'; | |||
import { isDefined } from '../../../helpers/types'; | |||
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, | |||
) { | |||
const [isInitialized, setIsInitialized] = useState(false); | |||
const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>(); | |||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true); | |||
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 [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, | |||
total: 0, | |||
}); | |||
const router = useRouter(); | |||
const location = useLocation(); | |||
const isMonorepoSetup = location.query?.mono === 'true'; | |||
const hasDopSettings = useMemo(() => Boolean(dopSettings?.length), [dopSettings]); | |||
const cleanUrl = useCallback(() => { | |||
delete location.query.resetPat; | |||
router.replace(location); | |||
}, [location, router]); | |||
const handlePersonalAccessTokenCreated = useCallback(() => { | |||
cleanUrl(); | |||
setShowPersonalAccessTokenForm(false); | |||
setResetPersonalAccessToken(false); | |||
}, [cleanUrl]); | |||
const onSelectDopSetting = useCallback((setting: DopSetting | undefined) => { | |||
setIsInitialized(false); | |||
setSelectedDopSetting(setting); | |||
setShowPersonalAccessTokenForm(true); | |||
setOrganizations([]); | |||
setRepositories([]); | |||
setSearchQuery(''); | |||
}, []); | |||
const resetLoading = useCallback((value: boolean, more = false) => { | |||
if (more) { | |||
setIsLoadingMoreRepositories(value); | |||
} else { | |||
setIsLoadingRepositories(value); | |||
} | |||
}, []); | |||
const onSelectedAlmInstanceChange = useCallback( | |||
(instance?: AlmInstanceBase) => { | |||
onSelectDopSetting( | |||
instance ? dopSettings.find((dopSetting) => dopSetting.key === instance.key) : undefined, | |||
); | |||
}, | |||
[dopSettings, onSelectDopSetting], | |||
); | |||
const handleSelectRepository = useCallback( | |||
(repositoryKey: string) => { | |||
setSelectedRepository(repositories.find((repo) => getKey(repo) === repositoryKey)); | |||
}, | |||
[getKey, repositories, setSelectedRepository], | |||
); | |||
useEffect(() => { | |||
if (!hasDopSettings || (hasDopSettings && isDefined(selectedDopSetting))) { | |||
return; | |||
} | |||
if (almKey === AlmKeys.GitHub) { | |||
const selectedDopSettingId = location.query?.dopSetting; | |||
if (selectedDopSettingId !== undefined) { | |||
const selectedDopSetting = dopSettings.find(({ id }) => id === selectedDopSettingId); | |||
if (selectedDopSetting) { | |||
setSelectedDopSetting(selectedDopSetting); | |||
} | |||
return; | |||
} | |||
} | |||
if (dopSettings.length > 1) { | |||
setSelectedDopSetting(undefined); | |||
} else { | |||
setSelectedDopSetting(dopSettings[0]); | |||
} | |||
}, [almKey, dopSettings, hasDopSettings, location, selectedDopSetting, setSelectedDopSetting]); | |||
return { | |||
handlePersonalAccessTokenCreated, | |||
handleSelectRepository, | |||
hasDopSettings, | |||
isInitialized, | |||
isLoadingOrganizations, | |||
isLoadingRepositories, | |||
isLoadingMoreRepositories, | |||
isMonorepoSetup, | |||
onSelectedAlmInstanceChange, | |||
onSelectDopSetting, | |||
projectsPaging, | |||
organizations, | |||
repositories, | |||
resetLoading, | |||
resetPersonalAccessToken, | |||
searchQuery, | |||
selectedDopSetting, | |||
selectedRepository, | |||
setIsInitialized, | |||
setIsLoadingRepositories, | |||
setIsLoadingMoreRepositories, | |||
setIsLoadingOrganizations, | |||
setProjectsPaging, | |||
setOrganizations, | |||
selectedOrganization, | |||
setRepositories, | |||
setResetPersonalAccessToken, | |||
setSearchQuery, | |||
setSelectedDopSetting, | |||
setSelectedOrganization, | |||
setSelectedRepository, | |||
setShowPersonalAccessTokenForm, | |||
showPersonalAccessTokenForm, | |||
}; | |||
} |
@@ -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 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], | |||
); | |||
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, | |||
}; | |||
} |
@@ -4406,6 +4406,7 @@ onboarding.create_project.azure.no_repositories=Could not fetch repositories for | |||
onboarding.create_project.azure.no_results=No repositories match your search query. | |||
onboarding.create_project.bitbucketcloud.title=Bitbucket Cloud project onboarding | |||
onboarding.create_project.bitbucketcloud.subtitle=Import projects from one of your Bitbucket Cloud workspaces | |||
onboarding.create_project.bitbucketcloud.subtitle.with_monorepo=Import projects from one of your Bitbucket Cloud workspaces or {monorepoSetupLink}. | |||
onboarding.create_project.bitbucketcloud.no_projects=No projects could be fetched from Bitbucket. Contact your system administrator, or {link}. | |||
onboarding.create_project.bitbucketcloud.link=See on Bitbucket | |||
onboarding.create_project.github.title=GitHub project onboarding |