瀏覽代碼

SONAR-21824 Bitbucket monorepo import functionality (#11005)

master
Shane Findley 3 週之前
父節點
當前提交
508bdf5808
共有 25 個檔案被更改,包括 876 行新增664 行删除
  1. 2
    5
      server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx
  2. 4
    3
      server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx
  3. 8
    9
      server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudPersonalAccessTokenForm.tsx
  4. 193
    220
      server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
  5. 39
    10
      server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreateRender.tsx
  6. 2
    2
      server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudSearchForm.tsx
  7. 1
    3
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
  8. 1
    3
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx
  9. 6
    22
      server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
  10. 114
    140
      server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx
  11. 2
    2
      server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
  12. 79
    131
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
  13. 4
    6
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx
  14. 81
    33
      server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx
  15. 2
    1
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
  16. 5
    3
      server/sonar-web/src/main/js/apps/create/project/components/WrongBindingCountAlert.tsx
  17. 3
    1
      server/sonar-web/src/main/js/apps/create/project/constants.ts
  18. 1
    4
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoConnectionSelector.tsx
  19. 4
    5
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoNoOrganisations.tsx
  20. 3
    3
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoOrganisationSelector.tsx
  21. 0
    1
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx
  22. 58
    57
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoRepositorySelector.tsx
  23. 161
    0
      server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx
  24. 102
    0
      server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx
  25. 1
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 2
- 5
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx 查看文件

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { 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}

+ 4
- 3
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx 查看文件

@@ -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 &&

+ 8
- 9
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudPersonalAccessTokenForm.tsx 查看文件

@@ -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>
);
}

+ 193
- 220
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx 查看文件

@@ -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 };
}

+ 39
- 10
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreateRender.tsx 查看文件

@@ -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 &&

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudSearchForm.tsx 查看文件

@@ -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}
/>

+ 1
- 3
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx 查看文件

@@ -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}

+ 1
- 3
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx 查看文件

@@ -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 &&

+ 6
- 22
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx 查看文件

@@ -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));

+ 114
- 140
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx 查看文件

@@ -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 && {

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx 查看文件

@@ -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) =>

+ 79
- 131
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx 查看文件

@@ -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={

+ 4
- 6
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx 查看文件

@@ -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 &&

+ 81
- 33
server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx 查看文件

@@ -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],
});
}

+ 2
- 1
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx 查看文件

@@ -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();


+ 5
- 3
server/sonar-web/src/main/js/apps/create/project/components/WrongBindingCountAlert.tsx 查看文件

@@ -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">

+ 3
- 1
server/sonar-web/src/main/js/apps/create/project/constants.ts 查看文件

@@ -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;

+ 1
- 4
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoConnectionSelector.tsx 查看文件

@@ -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}

+ 4
- 5
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoNoOrganisations.tsx 查看文件

@@ -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">

+ 3
- 3
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoOrganisationSelector.tsx 查看文件

@@ -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}

+ 0
- 1
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx 查看文件

@@ -38,7 +38,6 @@ import { MonorepoProjectHeader } from './MonorepoProjectHeader';
import { MonorepoProjectsList } from './MonorepoProjectsList';

interface MonorepoProjectCreateProps {
canAdmin: boolean;
dopSettings: DopSetting[];
error: boolean;
loadingBindings: boolean;

+ 58
- 57
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoRepositorySelector.tsx 查看文件

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { 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>
)}
</>
)}
</>
);
}

+ 161
- 0
server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx 查看文件

@@ -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,
};
}

+ 102
- 0
server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx 查看文件

@@ -0,0 +1,102 @@
/*
* SonarQube
* Copyright (C) 2009-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlmKeys } from '../../../types/alm-settings';
import { DopSetting } from '../../../types/dop-translation';
import { REPOSITORY_SEARCH_DEBOUNCE_TIME } from './constants';

export function 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,
};
}

+ 1
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties 查看文件

@@ -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

Loading…
取消
儲存