Browse Source

SONAR 22131 Monorepo for bitbucket server (#11064)

pull/3361/head
Shane Findley 1 month ago
parent
commit
4cadd79324
20 changed files with 682 additions and 534 deletions
  1. 86
    125
      server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx
  2. 1
    1
      server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx
  3. 2
    2
      server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectsList.tsx
  4. 8
    19
      server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx
  5. 3
    6
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketImportRepositoryForm.tsx
  6. 203
    212
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx
  7. 48
    19
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx
  8. 6
    8
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketRepositories.tsx
  9. 2
    2
      server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketServerPersonalAccessTokenForm.tsx
  10. 5
    10
      server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
  11. 11
    20
      server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx
  12. 2
    2
      server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
  13. 8
    17
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
  14. 11
    7
      server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
  15. 53
    8
      server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx
  16. 5
    5
      server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx
  17. 59
    13
      server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx
  18. 66
    58
      server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx
  19. 102
    0
      server/sonar-web/src/main/js/apps/create/project/useRepositorySearch.tsx
  20. 1
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 86
- 125
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx View File

@@ -18,21 +18,23 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { LabelValueSelectOption } from 'design-system';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { GroupBase } from 'react-select';
import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter';
import { useLocation } from '~sonar-aligned/components/hoc/withRouter';
import {
getAzureProjects,
getAzureRepositories,
searchAzureRepositories,
} from '../../../../api/alm-integrations';
import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
import { AlmSettingsInstance } from '../../../../types/alm-settings';
import { AlmKeys } from '../../../../types/alm-settings';
import { DopSetting } from '../../../../types/dop-translation';
import { Dict } from '../../../../types/types';
import { ImportProjectParam } from '../CreateProjectPage';
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import { CreateProjectModes } from '../types';
import { useProjectCreate } from '../useProjectCreate';
import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';

@@ -42,51 +44,41 @@ interface Props {
onProjectSetupDone: (importProjects: ImportProjectParam) => void;
}

export default function AzureProjectCreate(props: Readonly<Props>) {
const { dopSettings, isLoadingBindings, onProjectSetupDone } = props;
const [isLoading, setIsLoading] = useState(false);
export default function AzureProjectCreate({
dopSettings,
isLoadingBindings,
onProjectSetupDone,
}: Readonly<Props>) {
const {
almInstances,
handlePersonalAccessTokenCreated,
handleSelectRepository: defaultRepositorySelect,
isLoadingRepositories,
isMonorepoSetup,
onSelectedAlmInstanceChange,
organizations: projects,
repositories,
searchQuery,
selectedAlmInstance,
selectedDopSetting,
selectedRepository,
setSearchQuery,
setIsLoadingRepositories,
setOrganizations: setProjects,
setRepositories,
setSelectedDopSetting,
setSelectedRepository,
setShowPersonalAccessTokenForm,
showPersonalAccessTokenForm,
} = useProjectCreate<AzureRepository, Dict<AzureRepository[]>, AzureProject>(
AlmKeys.Azure,
dopSettings,
({ name }) => name,
);

const [loadingRepositories, setLoadingRepositories] = useState<Dict<boolean>>({});
const [isSearching, setIsSearching] = useState(false);
const [projects, setProjects] = useState<AzureProject[] | undefined>();
const [repositories, setRepositories] = useState<Dict<AzureRepository[]>>({});
const [searchQuery, setSearchQuery] = useState<string>('');
const [searchResults, setSearchResults] = useState<AzureRepository[] | undefined>();
const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting | undefined>();
const [selectedRepository, setSelectedRepository] = useState<AzureRepository>();
const [showPersonalAccessTokenForm, setShowPersonalAccessTokenForm] = useState(true);

const location = useLocation();
const router = useRouter();

const almInstances = useMemo(
() =>
dopSettings?.map((dopSetting) => ({
alm: dopSetting.type,
key: dopSetting.key,
url: dopSetting.url,
})) ?? [],
[dopSettings],
);
const isMonorepoSetup = location.query?.mono === 'true';
const hasDopSettings = Boolean(dopSettings?.length);
const selectedAlmInstance = useMemo(
() =>
selectedDopSetting && {
alm: selectedDopSetting.type,
key: selectedDopSetting.key,
url: selectedDopSetting.url,
},
[selectedDopSetting],
);
const repositoryOptions = useMemo(
() => transformToOptions(projects ?? [], repositories),
[projects, repositories],
);

const cleanUrl = useCallback(() => {
delete location.query.resetPat;
router.replace(location);
}, [location, router]);

const fetchAzureProjects = useCallback(async (): Promise<AzureProject[] | undefined> => {
if (selectedDopSetting === undefined) {
@@ -119,13 +111,13 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
return;
}

setIsLoading(true);
setIsLoadingRepositories(true);
let projects: AzureProject[] | undefined;
try {
projects = await fetchAzureProjects();
} catch (_) {
setShowPersonalAccessTokenForm(true);
setIsLoading(false);
setIsLoadingRepositories(false);
return;
}

@@ -171,8 +163,17 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
}

setProjects(projects);
setIsLoading(false);
}, [fetchAzureProjects, fetchAzureRepositories, isMonorepoSetup, showPersonalAccessTokenForm]);
setIsLoadingRepositories(false);
}, [
fetchAzureProjects,
fetchAzureRepositories,
isMonorepoSetup,
setIsLoadingRepositories,
setProjects,
setRepositories,
setShowPersonalAccessTokenForm,
showPersonalAccessTokenForm,
]);

const handleImportRepository = useCallback(
(selectedRepository: AzureRepository) => {
@@ -205,6 +206,19 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
[onProjectSetupDone, selectedRepository?.projectName],
);

const { isSearching, onSearch, onSelectRepository, searchResults } =
useProjectRepositorySearch<AzureRepository>({
defaultRepositorySelect,
fetchData,
fetchSearchResults: (query: string, dopKey: string) => searchAzureRepositories(dopKey, query),
getRepositoryKey: ({ name }) => name,
isMonorepoSetup,
selectedDopSetting,
setSearchQuery,
setSelectedRepository,
setShowPersonalAccessTokenForm,
});

const handleOpenProject = useCallback(
async (projectName: string) => {
if (searchResults !== undefined) {
@@ -224,75 +238,19 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
}));
setRepositories((repositories) => ({ ...repositories, [projectName]: projectRepos }));
},
[fetchAzureRepositories, searchResults],
[fetchAzureRepositories, searchResults, setRepositories],
);

const handlePersonalAccessTokenCreate = useCallback(() => {
cleanUrl();
setShowPersonalAccessTokenForm(false);
}, [cleanUrl]);

const handleSearchRepositories = useCallback(
async (searchQuery: string) => {
if (!selectedDopSetting) {
return;
}

if (searchQuery.length === 0) {
setSearchResults(undefined);
setSearchQuery('');
return;
}

setIsSearching(true);

const searchResults: AzureRepository[] = await searchAzureRepositories(
selectedDopSetting.key,
searchQuery,
)
.then(({ repositories }) => repositories)
.catch(() => []);

setIsSearching(false);
setSearchQuery(searchQuery);
setSearchResults(searchResults);
},
[selectedDopSetting],
);

const handleSelectRepository = useCallback(
(repositoryKey: string) => {
setSelectedRepository(
Object.values(repositories)
.flat()
.find(({ name }) => name === repositoryKey),
);
},
[repositories],
);

const onSelectedAlmInstanceChange = useCallback(
(almInstance: AlmSettingsInstance) => {
setSelectedDopSetting(dopSettings?.find((dopSetting) => dopSetting.key === almInstance.key));
},
[dopSettings],
);

useEffect(() => {
setSelectedDopSetting(dopSettings?.[0]);
// We want to update this value only when the list of DOP settings changes from empty to not-empty (or vice-versa)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasDopSettings]);

useEffect(() => {
setSearchResults(undefined);
setSearchQuery('');
setShowPersonalAccessTokenForm(true);
}, [isMonorepoSetup, selectedDopSetting]);
const repositoryOptions = useMemo(() => {
if (searchResults) {
const dict = projects?.reduce((acc: Dict<AzureRepository[]>, { name }) => {
return { ...acc, [name]: searchResults?.filter((o) => o.projectName === name) };
}, {});
return transformToOptions(projects ?? [], dict);
}

useEffect(() => {
fetchData();
}, [fetchData]);
return transformToOptions(projects ?? [], repositories);
}, [projects, repositories, searchResults]);

return isMonorepoSetup ? (
<MonorepoProjectCreate
@@ -300,17 +258,17 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
error={false}
loadingBindings={isLoadingBindings}
loadingOrganizations={false}
loadingRepositories={isLoading}
loadingRepositories={isLoadingRepositories}
onProjectSetupDone={handleMonorepoSetupDone}
onSearchRepositories={setSearchQuery}
onSearchRepositories={onSearch}
onSelectDopSetting={setSelectedDopSetting}
onSelectRepository={handleSelectRepository}
onSelectRepository={onSelectRepository}
personalAccessTokenComponent={
!isLoading &&
!isLoadingRepositories &&
selectedAlmInstance && (
<AzurePersonalAccessTokenForm
almSetting={selectedAlmInstance}
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate}
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreated}
resetPat={Boolean(location.query.resetPat)}
/>
)
@@ -324,12 +282,12 @@ export default function AzureProjectCreate(props: Readonly<Props>) {
) : (
<AzureCreateProjectRenderer
almInstances={almInstances}
loading={isLoading || isLoadingBindings}
loading={isLoadingRepositories || isLoadingBindings}
loadingRepositories={loadingRepositories}
onImportRepository={handleImportRepository}
onOpenProject={handleOpenProject}
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate}
onSearch={handleSearchRepositories}
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreated}
onSearch={onSearch}
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
projects={projects}
repositories={repositories}
@@ -345,11 +303,14 @@ export default function AzureProjectCreate(props: Readonly<Props>) {

function transformToOptions(
projects: AzureProject[],
repositories: Dict<AzureRepository[]>,
repositories?: Dict<AzureRepository[]>,
): Array<GroupBase<LabelValueSelectOption<string>>> {
return projects.map(({ name: projectName }) => ({
label: projectName,
options: repositories[projectName]?.map(transformToOption) ?? [],
options:
repositories?.[projectName] !== undefined
? repositories[projectName].map(transformToOption)
: [],
}));
}


+ 1
- 1
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx View File

@@ -51,7 +51,7 @@ export interface AzureProjectCreateRendererProps {
onPersonalAccessTokenCreate: () => void;
onSearch: (query: string) => void;
projects?: AzureProject[];
repositories: Dict<AzureRepository[]>;
repositories?: Dict<AzureRepository[]>;
searching?: boolean;
searchResults?: AzureRepository[];
searchQuery?: string;

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectsList.tsx View File

@@ -34,7 +34,7 @@ export interface AzureProjectsListProps {
onOpenProject: (key: string) => void;
onImportRepository: (repository: AzureRepository) => void;
projects?: AzureProject[];
repositories: Dict<AzureRepository[]>;
repositories?: Dict<AzureRepository[]>;
searchResults?: AzureRepository[];
searchQuery?: string;
}
@@ -121,7 +121,7 @@ export default function AzureProjectsList(props: AzureProjectsListProps) {
repositories={
searchResults
? searchResults.filter((s) => s.projectName === p.name)
: repositories[p.name]
: repositories?.[p.name]
}
searchQuery={searchQuery}
startsOpen={searchResults !== undefined || i === 0}

+ 8
- 19
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudProjectCreate.tsx View File

@@ -29,7 +29,7 @@ import { REPOSITORY_PAGE_SIZE } from '../constants';
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import { CreateProjectModes } from '../types';
import { useProjectCreate } from '../useProjectCreate';
import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
import { useRepositorySearch } from '../useRepositorySearch';
import BitbucketCloudPersonalAccessTokenForm from './BitbucketCloudPersonalAccessTokenForm';
import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender';

@@ -49,6 +49,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
});

const {
almInstances,
handlePersonalAccessTokenCreated,
handleSelectRepository,
isInitialized,
@@ -61,6 +62,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
resetLoading,
resetPersonalAccessToken,
searchQuery,
selectedAlmInstance,
selectedDopSetting,
selectedRepository,
setIsInitialized,
@@ -69,11 +71,10 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
setSearchQuery,
setShowPersonalAccessTokenForm,
showPersonalAccessTokenForm,
} = useProjectCreate<BitbucketCloudRepository, undefined>(
} = useProjectCreate<BitbucketCloudRepository, BitbucketCloudRepository[], undefined>(
AlmKeys.BitbucketCloud,
dopSettings,
({ slug }) => slug,
REPOSITORY_PAGE_SIZE,
);

const location = useLocation();
@@ -153,7 +154,7 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
[onProjectSetupDone, selectedDopSetting],
);

const { isSearching, onSearch } = useProjectRepositorySearch(
const { isSearching, onSearch } = useRepositorySearch(
AlmKeys.BitbucketCloud,
fetchRepositories,
isInitialized,
@@ -192,21 +193,8 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
/>
) : (
<BitbucketCloudProjectCreateRenderer
almInstances={almInstances}
isLastPage={isLastPage}
selectedAlmInstance={
selectedDopSetting
? {
alm: selectedDopSetting.type,
key: selectedDopSetting.key,
url: selectedDopSetting.url,
}
: undefined
}
almInstances={dopSettings?.map((instance) => ({
alm: instance.type,
key: instance.key,
url: instance.url,
}))}
loadingMore={isLoadingMoreRepositories}
loading={isLoadingRepositories || isLoadingBindings}
onImport={handleImportRepository}
@@ -215,9 +203,10 @@ export default function BitbucketCloudProjectCreate(props: Readonly<Props>) {
onSearch={onSearch}
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
repositories={repositories}
resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
searching={isSearching}
searchQuery={searchQuery}
resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
selectedAlmInstance={selectedAlmInstance}
showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
);

+ 3
- 6
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketImportRepositoryForm.tsx View File

@@ -22,11 +22,8 @@ import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { queryToSearchString } from '~sonar-aligned/helpers/urls';
import { translate } from '../../../../helpers/l10n';
import {
BitbucketProject,
BitbucketProjectRepositories,
BitbucketRepository,
} from '../../../../types/alm-integration';
import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
import { Dict } from '../../../../types/types';
import { CreateProjectModes } from '../types';
import BitbucketRepositories from './BitbucketRepositories';
import BitbucketSearchResults from './BitbucketSearchResults';
@@ -35,7 +32,7 @@ export interface BitbucketImportRepositoryFormProps {
onSearch: (query: string) => void;
onImportRepository: (repo: BitbucketRepository) => void;
projects?: BitbucketProject[];
projectRepositories?: BitbucketProjectRepositories;
projectRepositories?: Dict<BitbucketRepository[]>;
searching: boolean;
searchResults?: BitbucketRepository[];
}

+ 203
- 212
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreate.tsx View File

@@ -17,253 +17,244 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { Location, Router } from '~sonar-aligned/types/router';
import { LabelValueSelectOption } from 'design-system';
import React, { useCallback, useMemo } from 'react';
import { GroupBase } from 'react-select';
import { useLocation } from '~sonar-aligned/components/hoc/withRouter';
import {
getBitbucketServerProjects,
getBitbucketServerRepositories,
searchForBitbucketServerRepositories,
} from '../../../../api/alm-integrations';
import {
BitbucketProject,
BitbucketProjectRepositories,
BitbucketRepository,
} from '../../../../types/alm-integration';
import { AlmSettingsInstance } from '../../../../types/alm-settings';
import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
import { AlmKeys } from '../../../../types/alm-settings';
import { DopSetting } from '../../../../types/dop-translation';
import { Dict } from '../../../../types/types';
import { ImportProjectParam } from '../CreateProjectPage';
import { DEFAULT_BBS_PAGE_SIZE } from '../constants';
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import { CreateProjectModes } from '../types';
import { useProjectCreate } from '../useProjectCreate';
import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer';
import BitbucketServerPersonalAccessTokenForm from './BitbucketServerPersonalAccessTokenForm';

interface Props {
almInstances: AlmSettingsInstance[];
loadingBindings: boolean;
location: Location;
router: Router;
dopSettings: DopSetting[];
isLoadingBindings: boolean;
onProjectSetupDone: (importProjects: ImportProjectParam) => void;
}

interface State {
selectedAlmInstance?: AlmSettingsInstance;
loading: boolean;
projects?: BitbucketProject[];
projectRepositories?: BitbucketProjectRepositories;
searching: boolean;
searchResults?: BitbucketRepository[];
showPersonalAccessTokenForm: boolean;
}

export default class BitbucketProjectCreate extends React.PureComponent<Props, State> {
mounted = false;
export default function BitbucketProjectCreate({
dopSettings,
isLoadingBindings,
onProjectSetupDone,
}: Readonly<Props>) {
const {
almInstances,
handlePersonalAccessTokenCreated,
handleSelectRepository: defaultRepositorySelect,
isLoadingRepositories,
isMonorepoSetup,
onSelectedAlmInstanceChange,
organizations: projects,
repositories,
resetPersonalAccessToken,
searchQuery,
selectedAlmInstance,
selectedDopSetting,
selectedRepository,
setIsLoadingRepositories,
setOrganizations: setProjects,
setRepositories,
setSearchQuery,
setSelectedDopSetting,
setSelectedRepository,
setShowPersonalAccessTokenForm,
showPersonalAccessTokenForm,
} = useProjectCreate<BitbucketRepository, Dict<BitbucketRepository[]>, BitbucketProject>(
AlmKeys.BitbucketServer,
dopSettings,
({ slug }) => slug,
);

constructor(props: Props) {
super(props);
this.state = {
selectedAlmInstance: props.almInstances[0],
loading: false,
searching: false,
showPersonalAccessTokenForm: true,
};
}
const location = useLocation();

componentDidMount() {
this.mounted = true;
}

componentDidUpdate(prevProps: Props) {
if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => {
this.fetchInitialData().catch(() => {
/* noop */
});
});
const fetchBitbucketProjects = useCallback((): Promise<BitbucketProject[] | undefined> => {
if (!selectedDopSetting) {
return Promise.resolve(undefined);
}
}

componentWillUnmount() {
this.mounted = false;
}
return getBitbucketServerProjects(selectedDopSetting.key).then(({ projects }) => projects);
}, [selectedDopSetting]);

fetchInitialData = async () => {
const { showPersonalAccessTokenForm } = this.state;
const fetchBitbucketRepositories = useCallback(
(projects: BitbucketProject[]): Promise<Dict<BitbucketRepository[]> | undefined> => {
if (!selectedDopSetting) {
return Promise.resolve(undefined);
}

if (!showPersonalAccessTokenForm) {
this.setState({ loading: true });
const projects = await this.fetchBitbucketProjects().catch(() => undefined);
return Promise.all(
projects.map((p) => {
return getBitbucketServerRepositories(selectedDopSetting.key, p.name).then(
({ repositories }) => {
// Because the WS uses the project name rather than its key to find
// repositories, we can match more repositories than we expect. For
// example, p.name = "A1" would find repositories for projects "A1",
// "A10", "A11", etc. This is a limitation of BBS. To make sure we
// don't display incorrect information, filter on the project key.
const filteredRepositories = repositories.filter((r) => r.projectKey === p.key);

let projectRepositories;
if (projects && projects.length > 0) {
projectRepositories = await this.fetchBitbucketRepositories(projects).catch(
() => undefined,
);
}
return {
repositories: filteredRepositories,
projectKey: p.key,
};
},
);
}),
).then((results) => {
return results.reduce((acc: Dict<BitbucketRepository[]>, { projectKey, repositories }) => {
return { ...acc, [projectKey]: repositories };
}, {});
});
},
[selectedDopSetting],
);

if (this.mounted) {
this.setState({
projects,
projectRepositories,
loading: false,
const handleImportRepository = useCallback(
(selectedRepository: BitbucketRepository) => {
if (selectedDopSetting) {
onProjectSetupDone({
creationMode: CreateProjectModes.BitbucketServer,
almSetting: selectedDopSetting.key,
monorepo: false,
projects: [
{
projectKey: selectedRepository.projectKey,
repositorySlug: selectedRepository.slug,
},
],
});
}
}
};
},
[onProjectSetupDone, selectedDopSetting],
);

fetchBitbucketProjects = (): Promise<BitbucketProject[] | undefined> => {
const { selectedAlmInstance } = this.state;
const handleMonorepoSetupDone = useCallback(
(monorepoSetup: ImportProjectParam) => {
const bitbucketMonorepoSetup = {
...monorepoSetup,
projectIdentifier: selectedRepository?.projectKey,
};

if (!selectedAlmInstance) {
return Promise.resolve(undefined);
}
onProjectSetupDone(bitbucketMonorepoSetup);
},
[onProjectSetupDone, selectedRepository?.projectKey],
);

return getBitbucketServerProjects(selectedAlmInstance.key).then(({ projects }) => projects);
};
const fetchData = useCallback(async () => {
if (!showPersonalAccessTokenForm) {
setIsLoadingRepositories(true);
const projects = await fetchBitbucketProjects().catch(() => undefined);

fetchBitbucketRepositories = (
projects: BitbucketProject[],
): Promise<BitbucketProjectRepositories | undefined> => {
const { selectedAlmInstance } = this.state;
let projectRepositories;
if (projects && projects.length > 0) {
projectRepositories = await fetchBitbucketRepositories(projects).catch(() => undefined);
}

if (!selectedAlmInstance) {
return Promise.resolve(undefined);
setProjects(projects ?? []);
setRepositories(projectRepositories ?? {});
setIsLoadingRepositories(false);
}
}, [
fetchBitbucketProjects,
fetchBitbucketRepositories,
showPersonalAccessTokenForm,
setIsLoadingRepositories,
setProjects,
setRepositories,
]);

return Promise.all(
projects.map((p) => {
return getBitbucketServerRepositories(selectedAlmInstance.key, p.name).then(
({ isLastPage, repositories }) => {
// Because the WS uses the project name rather than its key to find
// repositories, we can match more repositories than we expect. For
// example, p.name = "A1" would find repositories for projects "A1",
// "A10", "A11", etc. This is a limitation of BBS. To make sure we
// don't display incorrect information, filter on the project key.
const filteredRepositories = repositories.filter((r) => r.projectKey === p.key);

// And because of the above, the "isLastPage" cannot be relied upon
// either. This one is impossible to get 100% for now. We can only
// make some assumptions: by default, the page size for BBS is 25
// (this is not part of the payload, so we don't know the actual
// number; but changing this implies changing some advanced config,
// so it's not likely). If the filtered repos is larger than this
// number AND isLastPage is false, we'll keep it at false.
// Otherwise, we assume it's true.
const realIsLastPage =
isLastPage || filteredRepositories.length < DEFAULT_BBS_PAGE_SIZE;

return {
repositories: filteredRepositories,
isLastPage: realIsLastPage,
projectKey: p.key,
};
},
);
}),
).then((results) => {
return results.reduce(
(acc: BitbucketProjectRepositories, { isLastPage, projectKey, repositories }) => {
return { ...acc, [projectKey]: { allShown: isLastPage, repositories } };
},
{},
);
});
};

cleanUrl = () => {
const { location, router } = this.props;
delete location.query.resetPat;
router.replace(location);
};

handlePersonalAccessTokenCreated = () => {
this.cleanUrl();

this.setState({ showPersonalAccessTokenForm: false }, () => {
this.fetchInitialData();
const { isSearching, onSearch, onSelectRepository, searchResults } =
useProjectRepositorySearch<BitbucketRepository>({
defaultRepositorySelect,
fetchData,
fetchSearchResults: (query: string, dopKey: string) =>
searchForBitbucketServerRepositories(dopKey, query),
getRepositoryKey: ({ slug }) => slug,
isMonorepoSetup,
selectedDopSetting,
setSearchQuery,
setSelectedRepository,
setShowPersonalAccessTokenForm,
});
};

handleImportRepository = (selectedRepository: BitbucketRepository) => {
const { selectedAlmInstance } = this.state;

if (selectedAlmInstance) {
this.props.onProjectSetupDone({
creationMode: CreateProjectModes.BitbucketServer,
almSetting: selectedAlmInstance.key,
monorepo: false,
projects: [
{
projectKey: selectedRepository.projectKey,
repositorySlug: selectedRepository.slug,
},
],
});
const repositoryOptions = useMemo(() => {
if (searchResults) {
const dict = projects?.reduce((acc: Dict<BitbucketRepository[]>, { key }) => {
return { ...acc, [key]: searchResults?.filter((o) => o.projectKey === key) };
}, {});
return transformToOptions(projects ?? [], dict);
}
};

handleSearch = (query: string) => {
const { selectedAlmInstance } = this.state;
return transformToOptions(projects ?? [], repositories);
}, [projects, repositories, searchResults]);

if (!selectedAlmInstance) {
return;
}

if (!query) {
this.setState({ searching: false, searchResults: undefined });
return;
}

this.setState({ searching: true });
searchForBitbucketServerRepositories(selectedAlmInstance.key, query)
.then(({ repositories }) => {
if (this.mounted) {
this.setState({ searching: false, searchResults: repositories });
}
})
.catch(() => {
if (this.mounted) {
this.setState({ searching: false });
}
});
};

onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
this.setState({
selectedAlmInstance: instance,
showPersonalAccessTokenForm: true,
searching: false,
searchResults: undefined,
});
};
return isMonorepoSetup ? (
<MonorepoProjectCreate
dopSettings={dopSettings}
error={false}
loadingBindings={isLoadingBindings}
loadingOrganizations={false}
loadingRepositories={isLoadingRepositories}
onProjectSetupDone={handleMonorepoSetupDone}
onSearchRepositories={onSearch}
onSelectDopSetting={setSelectedDopSetting}
onSelectRepository={onSelectRepository}
personalAccessTokenComponent={
!isLoadingRepositories &&
selectedDopSetting && (
<BitbucketServerPersonalAccessTokenForm
almSetting={selectedDopSetting}
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated}
resetPat={resetPersonalAccessToken}
/>
)
}
repositoryOptions={repositoryOptions}
repositorySearchQuery={searchQuery}
selectedDopSetting={selectedDopSetting}
selectedRepository={selectedRepository ? transformToOption(selectedRepository) : undefined}
showPersonalAccessToken={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
) : (
<BitbucketCreateProjectRenderer
almInstances={almInstances}
isLoading={isLoadingRepositories || isLoadingBindings}
onImportRepository={handleImportRepository}
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated}
onSearch={onSearch}
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
projectRepositories={repositories}
projects={projects}
resetPat={Boolean(location.query.resetPat)}
searchResults={searchResults}
searching={isSearching}
selectedAlmInstance={selectedAlmInstance}
showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
);
}

render() {
const { loadingBindings, location, almInstances } = this.props;
const {
selectedAlmInstance,
loading,
projectRepositories,
projects,
searching,
searchResults,
showPersonalAccessTokenForm,
} = this.state;
function transformToOptions(
projects: BitbucketProject[],
repositories?: Dict<BitbucketRepository[]>,
): Array<GroupBase<LabelValueSelectOption<string>>> {
return projects.map(({ name, key }) => ({
label: name,
options: repositories?.[key] !== undefined ? repositories[key].map(transformToOption) : [],
}));
}

return (
<BitbucketCreateProjectRenderer
selectedAlmInstance={selectedAlmInstance}
almInstances={almInstances}
loading={loading || loadingBindings}
onImportRepository={this.handleImportRepository}
onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
onSearch={this.handleSearch}
onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
projectRepositories={projectRepositories}
projects={projects}
resetPat={Boolean(location.query.resetPat)}
searchResults={searchResults}
searching={searching}
showPersonalAccessTokenForm={
showPersonalAccessTokenForm || Boolean(location.query.resetPat)
}
/>
);
}
function transformToOption({ name, slug }: BitbucketRepository): LabelValueSelectOption<string> {
return { value: slug, label: name };
}

+ 48
- 19
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectCreateRenderer.tsx View File

@@ -17,56 +17,85 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { LightPrimary, PageContentFontWrapper, Spinner, Title } from 'design-system';
import * as React from 'react';
import { Link, Spinner } from '@sonarsource/echoes-react';
import { LightPrimary, PageContentFontWrapper, Title } from 'design-system';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { queryToSearchString } from '~sonar-aligned/helpers/urls';
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
import { translate } from '../../../../helpers/l10n';
import {
BitbucketProject,
BitbucketProjectRepositories,
BitbucketRepository,
} from '../../../../types/alm-integration';
import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
import { Feature } from '../../../../types/features';
import { Dict } from '../../../../types/types';
import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
import { CreateProjectModes } from '../types';
import BitbucketImportRepositoryForm from './BitbucketImportRepositoryForm';
import BitbucketServerPersonalAccessTokenForm from './BitbucketServerPersonalAccessTokenForm';

export interface BitbucketProjectCreateRendererProps {
selectedAlmInstance?: AlmSettingsInstance;
almInstances: AlmSettingsInstance[];
loading: boolean;
isLoading: boolean;
onImportRepository: (repository: BitbucketRepository) => void;
onSearch: (query: string) => void;
onPersonalAccessTokenCreated: () => void;
onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
projects?: BitbucketProject[];
projectRepositories?: BitbucketProjectRepositories;
projectRepositories?: Dict<BitbucketRepository[]>;
resetPat: boolean;
searching: boolean;
searchResults?: BitbucketRepository[];
selectedAlmInstance?: AlmSettingsInstance;
showPersonalAccessTokenForm?: boolean;
}

export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) {
export default function BitbucketProjectCreateRenderer(
props: Readonly<BitbucketProjectCreateRendererProps>,
) {
const {
almInstances,
selectedAlmInstance,
loading,
isLoading,
projects,
projectRepositories,
resetPat,
searching,
searchResults,
selectedAlmInstance,
showPersonalAccessTokenForm,
resetPat,
} = props;

const isMonorepoSupported = React.useContext(AvailableFeaturesContext).includes(
Feature.MonoRepositoryPullRequestDecoration,
);

return (
<PageContentFontWrapper>
<header className="sw-mb-10">
<Title className="sw-mb-4">{translate('onboarding.create_project.bitbucket.title')}</Title>
<LightPrimary className="sw-body-sm">
{translate('onboarding.create_project.bitbucket.subtitle')}
{isMonorepoSupported ? (
<FormattedMessage
id="onboarding.create_project.bitbucket.subtitle.with_monorepo"
values={{
monorepoSetupLink: (
<Link
to={{
pathname: '/projects/create',
search: queryToSearchString({
mode: CreateProjectModes.BitbucketServer,
mono: true,
}),
}}
>
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" />
</Link>
),
}}
/>
) : (
<FormattedMessage id="onboarding.create_project.bitbucket.subtitle" />
)}
</LightPrimary>
</header>

@@ -77,12 +106,12 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr
onChangeConfig={props.onSelectedAlmInstanceChange}
/>

<Spinner loading={loading}>
{!loading && !selectedAlmInstance && (
<Spinner isLoading={isLoading}>
{!isLoading && almInstances && almInstances.length === 0 && !selectedAlmInstance && (
<WrongBindingCountAlert alm={AlmKeys.BitbucketServer} />
)}

{!loading &&
{!isLoading &&
selectedAlmInstance &&
(showPersonalAccessTokenForm ? (
<BitbucketServerPersonalAccessTokenForm

+ 6
- 8
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketRepositories.tsx View File

@@ -19,17 +19,15 @@
*/
import { uniq, without } from 'lodash';
import * as React from 'react';
import {
BitbucketProject,
BitbucketProjectRepositories,
BitbucketRepository,
} from '../../../../types/alm-integration';
import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
import { Dict } from '../../../../types/types';
import { DEFAULT_BBS_PAGE_SIZE } from '../constants';
import BitbucketProjectAccordion from './BitbucketProjectAccordion';

export interface BitbucketRepositoriesProps {
onImportRepository: (repo: BitbucketRepository) => void;
projects: BitbucketProject[];
projectRepositories: BitbucketProjectRepositories;
projectRepositories: Dict<BitbucketRepository[]>;
}

export default function BitbucketRepositories(props: BitbucketRepositoriesProps) {
@@ -49,7 +47,7 @@ export default function BitbucketRepositories(props: BitbucketRepositoriesProps)
<>
{projects.map((project) => {
const isOpen = openProjectKeys.includes(project.key);
const { allShown, repositories = [] } = projectRepositories[project.key] || {};
const repositories = projectRepositories[project.key] ?? [];

return (
<BitbucketProjectAccordion
@@ -58,7 +56,7 @@ export default function BitbucketRepositories(props: BitbucketRepositoriesProps)
open={isOpen}
project={project}
repositories={repositories}
showingAllRepositories={allShown}
showingAllRepositories={repositories.length < DEFAULT_BBS_PAGE_SIZE}
onImportRepository={props.onImportRepository}
/>
);

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketServerPersonalAccessTokenForm.tsx View File

@@ -30,11 +30,11 @@ import {
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { translate } from '../../../../helpers/l10n';
import { AlmSettingsInstance } from '../../../../types/alm-settings';
import { AlmInstanceBase } from '../../../../types/alm-settings';
import { usePersonalAccessToken } from '../usePersonalAccessToken';

interface Props {
almSetting: AlmSettingsInstance;
almSetting: AlmInstanceBase;
resetPat: boolean;
onPersonalAccessTokenCreated: () => void;
}

+ 5
- 10
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx View File

@@ -29,7 +29,7 @@ import withAvailableFeatures, {
WithAvailableFeaturesProps,
} from '../../../app/components/available-features/withAvailableFeatures';
import { translate } from '../../../helpers/l10n';
import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
import { AlmKeys } from '../../../types/alm-settings';
import { DopSetting } from '../../../types/dop-translation';
import { Feature } from '../../../types/features';
import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm';
@@ -50,7 +50,7 @@ export interface CreateProjectPageProps extends WithAvailableFeaturesProps {

interface State {
azureSettings: DopSetting[];
bitbucketSettings: AlmSettingsInstance[];
bitbucketSettings: DopSetting[];
bitbucketCloudSettings: DopSetting[];
githubSettings: DopSetting[];
gitlabSettings: DopSetting[];
@@ -192,9 +192,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
.then(({ dopSettings }) => {
this.setState({
azureSettings: dopSettings.filter(({ type }) => type === AlmKeys.Azure),
bitbucketSettings: dopSettings
.filter(({ type }) => type === AlmKeys.BitbucketServer)
.map(({ key, type, url }) => ({ alm: type, key, url })),
bitbucketSettings: dopSettings.filter(({ type }) => type === AlmKeys.BitbucketServer),
bitbucketCloudSettings: dopSettings.filter(({ type }) => type === AlmKeys.BitbucketCloud),
githubSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitHub),
gitlabSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitLab),
@@ -250,7 +248,6 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
};

renderProjectCreation(mode?: CreateProjectModes) {
const { location, router } = this.props;
const {
azureSettings,
bitbucketSettings,
@@ -275,10 +272,8 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
case CreateProjectModes.BitbucketServer: {
return (
<BitbucketProjectCreate
almInstances={bitbucketSettings}
loadingBindings={loading}
location={location}
router={router}
dopSettings={bitbucketSettings}
isLoadingBindings={loading}
onProjectSetupDone={this.handleProjectSetupDone}
/>
);

+ 11
- 20
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx View File

@@ -29,7 +29,7 @@ import { REPOSITORY_PAGE_SIZE } from '../constants';
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import { CreateProjectModes } from '../types';
import { useProjectCreate } from '../useProjectCreate';
import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
import { useRepositorySearch } from '../useRepositorySearch';
import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
import { redirectToGithub } from './utils';

@@ -43,6 +43,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
const { dopSettings, isLoadingBindings, onProjectSetupDone } = props;

const {
almInstances,
handleSelectRepository,
isInitialized,
isLoadingOrganizations,
@@ -54,6 +55,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
organizations,
repositories,
searchQuery,
selectedAlmInstance,
selectedDopSetting,
selectedRepository,
setIsInitialized,
@@ -65,11 +67,10 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
setSelectedOrganization,
selectedOrganization,
setIsLoadingOrganizations,
} = useProjectCreate<GithubRepository, GithubOrganization>(
} = useProjectCreate<GithubRepository, GithubRepository[], GithubOrganization>(
AlmKeys.GitHub,
dopSettings,
({ key }) => key,
REPOSITORY_PAGE_SIZE,
);

const [isInError, setIsInError] = useState(false);
@@ -79,10 +80,10 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
const router = useRouter();

const organizationOptions = useMemo(() => {
return organizations.map(transformToOption);
return organizations?.map(transformToOption);
}, [organizations]);
const repositoryOptions = useMemo(() => {
return repositories.map(transformToOption);
return repositories?.map(transformToOption);
}, [repositories]);

const fetchRepositories = useCallback(
@@ -104,7 +105,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
.then(({ paging, repositories }) => {
setProjectsPaging(paging);
setRepositories((prevRepositories) =>
pageIndex === 1 ? repositories : [...prevRepositories, ...repositories],
pageIndex === 1 ? repositories : [...(prevRepositories ?? []), ...repositories],
);
setIsInitialized(true);
})
@@ -164,7 +165,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
const handleSelectOrganization = useCallback(
(organizationKey: string) => {
setSearchQuery('');
setSelectedOrganization(organizations.find(({ key }) => key === organizationKey));
setSelectedOrganization(organizations?.find(({ key }) => key === organizationKey));
},
[organizations, setSearchQuery, setSelectedOrganization],
);
@@ -201,7 +202,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDopSetting]);

const { onSearch } = useProjectRepositorySearch(
const { onSearch } = useRepositorySearch(
AlmKeys.GitHub,
fetchRepositories,
isInitialized,
@@ -232,11 +233,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
/>
) : (
<GitHubProjectCreateRenderer
almInstances={dopSettings.map(({ key, type, url }) => ({
alm: type,
key,
url,
}))}
almInstances={almInstances}
error={isInError}
loadingBindings={isLoadingBindings}
loadingOrganizations={isLoadingOrganizations}
@@ -250,13 +247,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
repositories={repositories}
repositoryPaging={projectsPaging}
searchQuery={searchQuery}
selectedAlmInstance={
selectedDopSetting && {
alm: selectedDopSetting.type,
key: selectedDopSetting.key,
url: selectedDopSetting.url,
}
}
selectedAlmInstance={selectedAlmInstance}
selectedOrganization={selectedOrganization}
/>
);

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx View File

@@ -44,7 +44,7 @@ interface GitHubProjectCreateRendererProps {
onLoadMore: () => void;
onSearch: (q: string) => void;
onSelectOrganization: (key: string) => void;
organizations: GithubOrganization[];
organizations?: GithubOrganization[];
repositories?: GithubRepository[];
repositoryPaging: Paging;
searchQuery: string;
@@ -175,7 +175,7 @@ export default function GitHubProjectCreateRenderer(
<DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
{translate('onboarding.create_project.github.choose_organization')}
</DarkLabel>
{organizations.length > 0 ? (
{organizations && organizations.length > 0 ? (
<InputSelect
className="sw-w-7/12 sw-mb-9"
size="full"

+ 8
- 17
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx View File

@@ -29,7 +29,7 @@ import { REPOSITORY_PAGE_SIZE } from '../constants';
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import { CreateProjectModes } from '../types';
import { useProjectCreate } from '../useProjectCreate';
import { useProjectRepositorySearch } from '../useProjectRepositorySearch';
import { useRepositorySearch } from '../useRepositorySearch';
import GitlabPersonalAccessTokenForm from './GItlabPersonalAccessTokenForm';
import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer';

@@ -43,6 +43,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
const { dopSettings, isLoadingBindings, onProjectSetupDone } = props;

const {
almInstances,
handlePersonalAccessTokenCreated,
handleSelectRepository,
isInitialized,
@@ -54,6 +55,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
repositories,
resetPersonalAccessToken,
searchQuery,
selectedAlmInstance,
selectedDopSetting,
selectedRepository,
setIsInitialized,
@@ -64,17 +66,16 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
setSearchQuery,
setShowPersonalAccessTokenForm,
showPersonalAccessTokenForm,
} = useProjectCreate<GitlabProject, undefined>(
} = useProjectCreate<GitlabProject, GitlabProject[], undefined>(
AlmKeys.GitLab,
dopSettings,
({ id }) => id,
REPOSITORY_PAGE_SIZE,
);

const location = useLocation();

const repositoryOptions = useMemo(() => {
return repositories.map(transformToOption);
return repositories?.map(transformToOption);
}, [repositories]);

const fetchRepositories = useCallback(
@@ -143,7 +144,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
fetchRepositories(undefined, searchQuery, projectsPaging.pageIndex + 1, true);
}, [fetchRepositories, projectsPaging, searchQuery]);

const { onSearch } = useProjectRepositorySearch(
const { onSearch } = useRepositorySearch(
AlmKeys.GitLab,
fetchRepositories,
isInitialized,
@@ -182,11 +183,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
/>
) : (
<GitlabProjectCreateRenderer
almInstances={dopSettings.map((dopSetting) => ({
alm: dopSetting.type,
key: dopSetting.key,
url: dopSetting.url,
}))}
almInstances={almInstances}
loading={isLoadingRepositories || isLoadingBindings}
onImport={handleImportRepository}
onLoadMore={handleLoadMore}
@@ -197,13 +194,7 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
projectsPaging={projectsPaging}
resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
searchQuery={searchQuery}
selectedAlmInstance={
selectedDopSetting && {
alm: selectedDopSetting.type,
key: selectedDopSetting.key,
url: selectedDopSetting.url,
}
}
selectedAlmInstance={selectedAlmInstance}
showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
);

+ 11
- 7
server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx View File

@@ -90,7 +90,9 @@ it('should ask for PAT when it is not set yet and show the import project featur
expect(await screen.findByText('onboarding.create_project.azure.title')).toBeInTheDocument();
expect(screen.getByText('alm.configuration.selector.label.alm.azure.long')).toBeInTheDocument();

expect(screen.getByText('onboarding.create_project.enter_pat')).toBeInTheDocument();
await selectEvent.select(ui.instanceSelector.get(), [/conf-azure-1/]);

expect(await screen.findByText('onboarding.create_project.enter_pat')).toBeInTheDocument();
expect(screen.getByText('onboarding.create_project.pat_form.title')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();

@@ -135,11 +137,13 @@ it('should show import project feature when PAT is already set', async () => {
).toBeInTheDocument();

await user.type(ui.searchbox.get(), 'repo 2');
expect(
screen.queryByRole('listitem', {
name: 'Azure repo 1',
}),
).not.toBeInTheDocument();
await waitFor(() =>
expect(
screen.queryByRole('listitem', {
name: 'Azure repo 1',
}),
).not.toBeInTheDocument(),
);
expect(
screen.queryByRole('listitem', {
name: 'Azure repo 3',
@@ -199,7 +203,7 @@ it('should show search filter when PAT is already set', async () => {
await user.click(inputSearch);
await user.keyboard('s');

expect(searchAzureRepositories).toHaveBeenCalledWith('conf-azure-2', 's');
await waitFor(() => expect(searchAzureRepositories).toHaveBeenCalledWith('conf-azure-2', 's'));

// Should search with empty results
almIntegrationHandler.setSearchAzureRepositories([]);

+ 53
- 8
server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx View File

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { screen, within } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';

import userEvent from '@testing-library/user-event';
import * as React from 'react';
@@ -28,7 +28,9 @@ import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServi
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
import { Feature } from '../../../../types/features';
import CreateProjectPage from '../CreateProjectPage';
import { CreateProjectModes } from '../types';

jest.mock('../../../../api/alm-integrations');
jest.mock('../../../../api/alm-settings');
@@ -38,7 +40,17 @@ let dopTranslationHandler: DopTranslationServiceMock;
let newCodePeriodHandler: NewCodeDefinitionServiceMock;

const ui = {
bitbucketServerOnboardingTitle: byRole('heading', {
name: 'onboarding.create_project.bitbucket.title',
}),
bitbucketServerCreateProjectButton: byText('onboarding.create_project.select_method.bitbucket'),
cancelButton: byRole('button', { name: 'cancel' }),
monorepoSetupLink: byRole('link', {
name: 'onboarding.create_project.subtitle_monorepo_setup_link',
}),
monorepoTitle: byRole('heading', {
name: 'onboarding.create_project.monorepo.titlealm.bitbucket',
}),
personalAccessTokenInput: byRole('textbox', {
name: /onboarding.create_project.enter_pat/,
}),
@@ -73,8 +85,9 @@ it('should ask for PAT when it is not set yet and show the import project featur

expect(screen.getByText('onboarding.create_project.bitbucket.title')).toBeInTheDocument();
expect(await ui.instanceSelector.find()).toBeInTheDocument();
await selectEvent.select(ui.instanceSelector.get(), [/conf-bitbucketserver-1/]);

expect(screen.getByText('onboarding.create_project.pat_form.title')).toBeInTheDocument();
expect(await screen.findByText('onboarding.create_project.pat_form.title')).toBeInTheDocument();

expect(screen.getByRole('button', { name: 'save' })).toBeDisabled();

@@ -162,9 +175,11 @@ it('should show search filter when PAT is already set', async () => {
await user.click(inputSearch);
await user.keyboard('search');

expect(searchForBitbucketServerRepositories).toHaveBeenLastCalledWith(
'conf-bitbucketserver-2',
'search',
await waitFor(() =>
expect(searchForBitbucketServerRepositories).toHaveBeenLastCalledWith(
'conf-bitbucketserver-2',
'search',
),
);
});

@@ -179,8 +194,38 @@ it('should show no result message when there are no projects', async () => {
expect(await screen.findByText('onboarding.create_project.no_bbs_projects')).toBeInTheDocument();
});

function renderCreateProject() {
renderApp('project/create', <CreateProjectPage />, {
navigateTo: 'project/create?mode=bitbucket',
describe('Bitbucket Server monorepo project navigation', () => {
it('should be able to access monorepo setup page from Bitbucket Server project import page', async () => {
const user = userEvent.setup();
renderCreateProject();

await user.click(await ui.monorepoSetupLink.find());

expect(ui.monorepoTitle.get()).toBeInTheDocument();
});

it('should be able to go back to Bitbucket Server onboarding page from monorepo setup page', async () => {
const user = userEvent.setup();
renderCreateProject({ isMonorepo: true });

await user.click(await ui.cancelButton.find());

expect(ui.bitbucketServerOnboardingTitle.get()).toBeInTheDocument();
});
});

function renderCreateProject({
isMonorepo = false,
}: {
isMonorepo?: boolean;
} = {}) {
let queryString = `mode=${CreateProjectModes.BitbucketServer}`;
if (isMonorepo) {
queryString += '&mono=true';
}

renderApp('projects/create', <CreateProjectPage />, {
navigateTo: `projects/create?${queryString}`,
featureList: [Feature.MonoRepositoryPullRequestDecoration],
});
}

+ 5
- 5
server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx View File

@@ -41,29 +41,29 @@ let dopTranslationHandler: DopTranslationServiceMock;
let newCodePeriodHandler: NewCodeDefinitionServiceMock;

const ui = {
cancelButton: byRole('button', { name: 'cancel' }),
bitbucketCloudCreateProjectButton: byText(
'onboarding.create_project.select_method.bitbucketcloud',
),
bitbucketCloudOnboardingTitle: byRole('heading', {
name: 'onboarding.create_project.bitbucketcloud.title',
}),
cancelButton: byRole('button', { name: 'cancel' }),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
monorepoSetupLink: byRole('link', {
name: 'onboarding.create_project.subtitle_monorepo_setup_link',
}),
monorepoTitle: byRole('heading', {
name: 'onboarding.create_project.monorepo.titlealm.bitbucketcloud',
}),
password: byRole('textbox', {
name: /onboarding\.create_project\.bitbucket_cloud\.enter_password/,
}),
personalAccessTokenInput: byRole('textbox', {
name: /onboarding.create_project.enter_pat/,
}),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
userName: byRole('textbox', {
name: /onboarding\.create_project\.bitbucket_cloud\.enter_username/,
}),
password: byRole('textbox', {
name: /onboarding\.create_project\.bitbucket_cloud\.enter_password/,
}),
};

const original = window.location;

+ 59
- 13
server/sonar-web/src/main/js/apps/create/project/useProjectCreate.tsx View File

@@ -20,31 +20,46 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter';
import { isDefined } from '../../../helpers/types';
import {
AzureRepository,
BitbucketCloudRepository,
BitbucketRepository,
GithubRepository,
GitlabProject,
} from '../../../types/alm-integration';
import { AlmInstanceBase, AlmKeys } from '../../../types/alm-settings';
import { DopSetting } from '../../../types/dop-translation';
import { Paging } from '../../../types/types';

export function useProjectCreate<RepoType, GroupType>(
almKey: AlmKeys,
dopSettings: DopSetting[],
getKey: (repo: RepoType) => string,
pageSize: number,
) {
import { Dict, Paging } from '../../../types/types';
import { REPOSITORY_PAGE_SIZE } from './constants';

type RepoTypes =
| AzureRepository
| BitbucketRepository
| BitbucketCloudRepository
| GithubRepository
| GitlabProject;
type RepoCollectionTypes = Dict<RepoTypes[]> | RepoTypes[];

export function useProjectCreate<
RepoType extends RepoTypes,
RepoCollectionType extends RepoCollectionTypes,
GroupType,
>(almKey: AlmKeys, dopSettings: DopSetting[], getKey: (repo: RepoType) => string) {
const [isInitialized, setIsInitialized] = useState(false);
const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>();
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true);
const [organizations, setOrganizations] = useState<GroupType[]>([]);
const [organizations, setOrganizations] = useState<GroupType[]>();
const [selectedOrganization, setSelectedOrganization] = useState<GroupType>();
const [isLoadingRepositories, setIsLoadingRepositories] = useState<boolean>(false);
const [isLoadingMoreRepositories, setIsLoadingMoreRepositories] = useState<boolean>(false);
const [repositories, setRepositories] = useState<RepoType[]>([]);
const [repositories, setRepositories] = useState<RepoCollectionType>();
const [selectedRepository, setSelectedRepository] = useState<RepoType>();
const [showPersonalAccessTokenForm, setShowPersonalAccessTokenForm] = useState<boolean>(true);
const [resetPersonalAccessToken, setResetPersonalAccessToken] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>('');
const [projectsPaging, setProjectsPaging] = useState<Paging>({
pageIndex: 1,
pageSize,
pageSize: REPOSITORY_PAGE_SIZE,
total: 0,
});

@@ -54,6 +69,26 @@ export function useProjectCreate<RepoType, GroupType>(
const isMonorepoSetup = location.query?.mono === 'true';
const hasDopSettings = useMemo(() => Boolean(dopSettings?.length), [dopSettings]);

const almInstances = useMemo(
() =>
dopSettings?.map((dopSetting) => ({
alm: dopSetting.type,
key: dopSetting.key,
url: dopSetting.url,
})) ?? [],
[dopSettings],
);

const selectedAlmInstance = useMemo(
() =>
selectedDopSetting && {
alm: selectedDopSetting.type,
key: selectedDopSetting.key,
url: selectedDopSetting.url,
},
[selectedDopSetting],
);

const cleanUrl = useCallback(() => {
delete location.query.resetPat;
router.replace(location);
@@ -70,7 +105,7 @@ export function useProjectCreate<RepoType, GroupType>(
setSelectedDopSetting(setting);
setShowPersonalAccessTokenForm(true);
setOrganizations([]);
setRepositories([]);
setRepositories(undefined);
setSearchQuery('');
}, []);

@@ -93,7 +128,16 @@ export function useProjectCreate<RepoType, GroupType>(

const handleSelectRepository = useCallback(
(repositoryKey: string) => {
setSelectedRepository(repositories.find((repo) => getKey(repo) === repositoryKey));
if (Array.isArray(repositories)) {
const repos = repositories as RepoType[];
setSelectedRepository(repos.find((repo) => getKey(repo) === repositoryKey));
} else {
const repos = repositories as Dict<RepoType[]>;
const selected = Object.values(repos)
.flat()
.find((repo) => getKey(repo) === repositoryKey);
setSelectedRepository(selected);
}
},
[getKey, repositories, setSelectedRepository],
);
@@ -124,6 +168,7 @@ export function useProjectCreate<RepoType, GroupType>(
}, [almKey, dopSettings, hasDopSettings, location, selectedDopSetting, setSelectedDopSetting]);

return {
almInstances,
handlePersonalAccessTokenCreated,
handleSelectRepository,
hasDopSettings,
@@ -148,6 +193,7 @@ export function useProjectCreate<RepoType, GroupType>(
setIsLoadingOrganizations,
setProjectsPaging,
setOrganizations,
selectedAlmInstance,
selectedOrganization,
setRepositories,
setResetPersonalAccessToken,

+ 66
- 58
server/sonar-web/src/main/js/apps/create/project/useProjectRepositorySearch.tsx View File

@@ -17,86 +17,94 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlmKeys } from '../../../types/alm-settings';
import { isEmpty } from 'lodash';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AzureRepository, BitbucketRepository } from '../../../types/alm-integration';
import { DopSetting } from '../../../types/dop-translation';
import { REPOSITORY_SEARCH_DEBOUNCE_TIME } from './constants';

export function useProjectRepositorySearch(
almKey: AlmKeys,
fetchRepositories: (
organizationKey?: string,
query?: string,
pageIndex?: number,
more?: boolean,
) => Promise<void>,
isInitialized: boolean,
selectedDopSetting: DopSetting | undefined,
selectedOrganizationKey: string | undefined,
setSearchQuery: (query: string) => void,
showPersonalAccessTokenForm = false,
) {
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
const [isSearching, setIsSearching] = useState<boolean>(false);

const orgValid = useMemo(
() =>
almKey !== AlmKeys.GitHub ||
(almKey === AlmKeys.GitHub && selectedOrganizationKey !== undefined),
[almKey, selectedOrganizationKey],
);
type RepoTypes = AzureRepository | BitbucketRepository;

useEffect(() => {
if (selectedDopSetting && !showPersonalAccessTokenForm && orgValid) {
if (almKey === AlmKeys.GitHub) {
fetchRepositories(selectedOrganizationKey);
} else if (!isInitialized) {
fetchRepositories();
}
}
}, [
almKey,
fetchRepositories,
isInitialized,
orgValid,
selectedDopSetting,
selectedOrganizationKey,
showPersonalAccessTokenForm,
]);
export function useProjectRepositorySearch<RepoType extends RepoTypes>({
defaultRepositorySelect,
fetchData,
fetchSearchResults,
getRepositoryKey,
isMonorepoSetup,
selectedDopSetting,
setSearchQuery,
setSelectedRepository,
setShowPersonalAccessTokenForm,
}: {
defaultRepositorySelect: (repositoryKey: string) => void;
fetchData: () => void;
fetchSearchResults: (query: string, dopKey: string) => Promise<{ repositories: RepoType[] }>;
getRepositoryKey: (repo: RepoType) => string;
isMonorepoSetup: boolean;
selectedDopSetting: DopSetting | undefined;
setSearchQuery: (query: string) => void;
setSelectedRepository: (repo: RepoType) => void;
setShowPersonalAccessTokenForm: (show: boolean) => void;
}) {
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<RepoType[] | undefined>();

const onSearch = useCallback(
(query: string) => {
setSearchQuery(query);
if (!isInitialized || !orgValid) {
if (!selectedDopSetting) {
return;
}

if (isEmpty(query)) {
setSearchQuery('');
setSearchResults(undefined);
return;
}

clearTimeout(repositorySearchDebounceId.current);
repositorySearchDebounceId.current = setTimeout(() => {
setIsSearching(true);
fetchRepositories(
almKey === AlmKeys.GitHub ? selectedOrganizationKey : undefined,
query,
).then(
() => setIsSearching(false),
fetchSearchResults(query, selectedDopSetting.key).then(
({ repositories }) => {
setIsSearching(false);
setSearchResults(repositories);
},
() => setIsSearching(false),
);
}, REPOSITORY_SEARCH_DEBOUNCE_TIME);
},
[
almKey,
fetchRepositories,
isInitialized,
orgValid,
repositorySearchDebounceId,
selectedOrganizationKey,
setIsSearching,
setSearchQuery,
],
[fetchSearchResults, selectedDopSetting, setSearchQuery],
);

const onSelectRepository = useCallback(
(repositoryKey: string) => {
const repo = searchResults?.find((o) => getRepositoryKey(o) === repositoryKey);
if (searchResults && repo) {
setSelectedRepository(repo);
} else {
// If we dont have a set of search results we should look for the repository in the base set of repositories
defaultRepositorySelect(repositoryKey);
}
},
[defaultRepositorySelect, getRepositoryKey, searchResults, setSelectedRepository],
);

useEffect(() => {
setSearchResults(undefined);
setSearchQuery('');
setShowPersonalAccessTokenForm(true);
}, [isMonorepoSetup, selectedDopSetting, setSearchQuery, setShowPersonalAccessTokenForm]);

useEffect(() => {
fetchData();
}, [fetchData]);

return {
isSearching,
onSearch,
onSelectRepository,
searchResults,
};
}

+ 102
- 0
server/sonar-web/src/main/js/apps/create/project/useRepositorySearch.tsx View File

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

export function useRepositorySearch(
almKey: AlmKeys,
fetchRepositories: (
organizationKey?: string,
query?: string,
pageIndex?: number,
more?: boolean,
) => Promise<void>,
isInitialized: boolean,
selectedDopSetting: DopSetting | undefined,
selectedOrganizationKey: string | undefined,
setSearchQuery: (query: string) => void,
showPersonalAccessTokenForm = false,
) {
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();
const [isSearching, setIsSearching] = useState<boolean>(false);

const orgValid = useMemo(
() =>
almKey !== AlmKeys.GitHub ||
(almKey === AlmKeys.GitHub && selectedOrganizationKey !== undefined),
[almKey, selectedOrganizationKey],
);

useEffect(() => {
if (selectedDopSetting && !showPersonalAccessTokenForm && orgValid) {
if (almKey === AlmKeys.GitHub) {
fetchRepositories(selectedOrganizationKey);
} else if (!isInitialized) {
fetchRepositories();
}
}
}, [
almKey,
fetchRepositories,
isInitialized,
orgValid,
selectedDopSetting,
selectedOrganizationKey,
showPersonalAccessTokenForm,
]);

const onSearch = useCallback(
(query: string) => {
setSearchQuery(query);
if (!isInitialized || !orgValid) {
return;
}

clearTimeout(repositorySearchDebounceId.current);
repositorySearchDebounceId.current = setTimeout(() => {
setIsSearching(true);
fetchRepositories(
almKey === AlmKeys.GitHub ? selectedOrganizationKey : undefined,
query,
).then(
() => setIsSearching(false),
() => setIsSearching(false),
);
}, REPOSITORY_SEARCH_DEBOUNCE_TIME);
},
[
almKey,
fetchRepositories,
isInitialized,
orgValid,
repositorySearchDebounceId,
selectedOrganizationKey,
setIsSearching,
setSearchQuery,
],
);

return {
isSearching,
onSearch,
};
}

+ 1
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -4428,6 +4428,7 @@ onboarding.create_project.gitlab.link=See on GitLab
onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator.
onboarding.create_project.bitbucket.title=Bitbucket Server project onboarding
onboarding.create_project.bitbucket.subtitle=Import projects from one of your Bitbucket server workspaces
onboarding.create_project.bitbucket.subtitle.with_monorepo=Import projects from one of your Bitbucket server workspaces or {monorepoSetupLink}.
onboarding.create_project.x_repositories_selected={count} {count, plural, one {repository} other {repositories}} selected
onboarding.create_project.x_repository_created={count} {count, plural, one {repository} other {repositories}} will be created as {count, plural, one {a project} other {projects}} in SonarQube
onboarding.create_project.please_dont_leave=If you leave the page the import could fail. Are you sure you want to leave?

Loading…
Cancel
Save