Browse Source

SONAR-21823 Adding support for gitlab monorepo imports

tags/10.5.0.89998
Shane Findley 1 month ago
parent
commit
85564e301e
20 changed files with 930 additions and 476 deletions
  1. 4
    8
      server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
  2. 3
    2
      server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreate.tsx
  3. 2
    2
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GItlabPersonalAccessTokenForm.tsx
  4. 216
    188
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
  5. 39
    6
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx
  6. 5
    2
      server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectSelectionForm.tsx
  7. 46
    9
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
  8. 3
    3
      server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx
  9. 7
    2
      server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx
  10. 146
    0
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoConnectionSelector.tsx
  11. 59
    0
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoNoOrganisations.tsx
  12. 102
    0
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoOrganisationSelector.tsx
  13. 63
    232
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx
  14. 69
    0
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectsList.tsx
  15. 135
    0
      server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoRepositorySelector.tsx
  16. 2
    2
      server/sonar-web/src/main/js/apps/create/project/usePersonalAccessToken.ts
  17. 9
    9
      server/sonar-web/src/main/js/components/devops-platform/AlmSettingsInstanceSelector.tsx
  18. 4
    1
      server/sonar-web/src/main/js/types/alm-settings.ts
  19. 2
    4
      server/sonar-web/src/main/js/types/dop-translation.ts
  20. 14
    6
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

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

@@ -55,7 +55,7 @@ interface State {
bitbucketSettings: AlmSettingsInstance[];
bitbucketCloudSettings: AlmSettingsInstance[];
githubSettings: DopSetting[];
gitlabSettings: AlmSettingsInstance[];
gitlabSettings: DopSetting[];
loading: boolean;
creatingAlmDefinition?: AlmKeys;
importProjects?: ImportProjectParam;
@@ -202,9 +202,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
.filter(({ type }) => type === AlmKeys.BitbucketCloud)
.map(({ key, type, url }) => ({ alm: type, key, url })),
githubSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitHub),
gitlabSettings: dopSettings
.filter(({ type }) => type === AlmKeys.GitLab)
.map(({ key, type, url }) => ({ alm: type, key, url })),
gitlabSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitLab),
loading: false,
});
})
@@ -324,10 +322,8 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
return (
<GitlabProjectCreate
canAdmin={!!canAdmin}
loadingBindings={loading}
location={location}
router={router}
almInstances={gitlabSettings}
dopSettings={gitlabSettings}
isLoadingBindings={loading}
onProjectSetupDone={this.handleProjectSetupDone}
/>
);

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

@@ -20,7 +20,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getGithubOrganizations, getGithubRepositories } from '../../../../api/alm-integrations';
import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
import { AlmSettingsInstance } from '../../../../types/alm-settings';
import { DopSetting } from '../../../../types/dop-translation';
@@ -30,6 +29,7 @@ import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import { CreateProjectModes } from '../types';
import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
import { redirectToGithub } from './utils';
import { LabelValueSelectOption } from 'design-system';

interface Props {
canAdmin: boolean;
@@ -253,6 +253,7 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
selectedDopSetting={selectedDopSetting}
selectedOrganization={selectedOrganization && transformToOption(selectedOrganization)}
selectedRepository={selectedRepository && transformToOption(selectedRepository)}
showOrganizations
/>
) : (
<GitHubProjectCreateRenderer
@@ -290,6 +291,6 @@ export default function GitHubProjectCreate(props: Readonly<Props>) {
function transformToOption({
key,
name,
}: GithubOrganization | GithubRepository): LabelValueSelectOption {
}: GithubOrganization | GithubRepository): LabelValueSelectOption<string> {
return { value: key, label: name };
}

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/Gitlab/GItlabPersonalAccessTokenForm.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 { usePersonalAccessToken } from '../usePersonalAccessToken';
import { AlmInstanceBase } from '../../../../types/alm-settings';

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

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

@@ -17,227 +17,255 @@
* 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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getGitlabProjects } from '../../../../api/alm-integrations';
import { Location, Router } from '../../../../components/hoc/withRouter';
import { GitlabProject } from '../../../../types/alm-integration';
import { AlmSettingsInstance } from '../../../../types/alm-settings';
import { AlmInstanceBase } from '../../../../types/alm-settings';
import { Paging } from '../../../../types/types';
import { ImportProjectParam } from '../CreateProjectPage';
import { CreateProjectModes } from '../types';
import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer';
import { DopSetting } from '../../../../types/dop-translation';
import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import GitlabPersonalAccessTokenForm from './GItlabPersonalAccessTokenForm';
import { orderBy } from 'lodash';
import { LabelValueSelectOption } from 'design-system';

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

interface State {
loading: boolean;
loadingMore: boolean;
projects?: GitlabProject[];
projectsPaging: Paging;
resetPat: boolean;
searching: boolean;
searchQuery: string;
selectedAlmInstance: AlmSettingsInstance;
showPersonalAccessTokenForm: boolean;
}
const REPOSITORY_PAGE_SIZE = 50;
const REPOSITORY_SEARCH_DEBOUNCE_TIME = 250;

const GITLAB_PROJECTS_PAGESIZE = 20;
export default function GitlabProjectCreate(props: Readonly<Props>) {
const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props;

export default class GitlabProjectCreate extends React.PureComponent<Props, State> {
mounted = false;
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();

constructor(props: Props) {
super(props);
const [isLoadingRepositories, setIsLoadingRepositories] = useState(false);
const [isLoadingMoreRepositories, setIsLoadingMoreRepositories] = 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);

this.state = {
loading: false,
loadingMore: false,
projectsPaging: { pageIndex: 1, total: 0, pageSize: GITLAB_PROJECTS_PAGESIZE },
resetPat: false,
showPersonalAccessTokenForm: true,
searching: false,
searchQuery: '',
selectedAlmInstance: props.almInstances[0],
};
}

componentDidMount() {
this.mounted = true;
}

componentDidUpdate(prevProps: Props) {
const { almInstances } = this.props;
if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
this.setState({ selectedAlmInstance: almInstances[0] }, () => {
this.fetchInitialData().catch(() => {
/* noop */
});
});
const location = useLocation();
const router = useRouter();

const isMonorepoSetup = location.query?.mono === 'true';
const hasDopSettings = useMemo(() => {
if (dopSettings === undefined) {
return false;
}
}

componentWillUnmount() {
this.mounted = false;
}
return dopSettings.length > 0;
}, [dopSettings]);
const repositoryOptions = useMemo(() => {
return repositories.map(transformToOption);
}, [repositories]);

fetchInitialData = async () => {
const { showPersonalAccessTokenForm } = this.state;
const fetchProjects = useCallback(
(pageIndex = 1, query?: string) => {
if (!selectedDopSetting) {
return Promise.resolve(undefined);
}

// 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) {
this.setState({ loading: true });
const result = await this.fetchProjects();
if (this.mounted && result) {
const { projects, projectsPaging } = result;

this.setState({
loading: false,
projects,
projectsPaging,
setIsLoadingRepositories(true);

fetchProjects()
.then((result) => {
if (result?.projects) {
setIsLoadingRepositories(false);
setRepositories(
isMonorepoSetup
? orderBy(result.projects, [(res) => res.name.toLowerCase()], ['asc'])
: result.projects,
);
setRepositoryPaging(result.projectsPaging);
} else {
setIsLoadingRepositories(false);
}
})
.catch(() => {
setResetPersonalAccessToken(true);
setShowPersonalAccessTokenForm(true);
});
} else {
this.setState({
loading: 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]);

const handleImportRepository = useCallback(
(gitlabProjectId: string) => {
if (selectedDopSetting) {
onProjectSetupDone({
almSetting: selectedDopSetting.key,
creationMode: CreateProjectModes.GitLab,
monorepo: false,
projects: [{ gitlabProjectId }],
});
}
}
};
},
[onProjectSetupDone, selectedDopSetting],
);

handleError = () => {
if (this.mounted) {
this.setState({ resetPat: true, showPersonalAccessTokenForm: true });
const handleLoadMore = useCallback(async () => {
setIsLoadingMoreRepositories(true);
const result = await fetchProjects(repositoryPaging.pageIndex + 1, searchQuery);
if (result?.projects) {
setRepositoryPaging(result ? result.projectsPaging : repositoryPaging);
setRepositories(result ? [...repositories, ...result.projects] : repositories);
}
setIsLoadingMoreRepositories(false);
}, [fetchProjects, repositories, repositoryPaging, searchQuery]);

return undefined;
};
const handleSelectRepository = useCallback(
(repositoryKey: string) => {
setSelectedRepository(repositories.find(({ id }) => id === repositoryKey));
},
[repositories],
);

fetchProjects = async (pageIndex = 1, query?: string) => {
const { selectedAlmInstance } = this.state;
if (!selectedAlmInstance) {
return Promise.resolve(undefined);
}
const onSelectDopSetting = useCallback((setting: DopSetting | undefined) => {
setSelectedDopSetting(setting);
setShowPersonalAccessTokenForm(true);
setRepositories([]);
setSearchQuery('');
}, []);

try {
// eslint-disable-next-line local-rules/no-api-imports
return await getGitlabProjects({
almSetting: selectedAlmInstance.key,
page: pageIndex,
pageSize: GITLAB_PROJECTS_PAGESIZE,
query,
});
} catch (_) {
return this.handleError();
const onSelectedAlmInstanceChange = useCallback(
(instance: AlmInstanceBase) => {
onSelectDopSetting(dopSettings.find((dopSetting) => dopSetting.key === instance.key));
},
[dopSettings, onSelectDopSetting],
);

useEffect(() => {
if (dopSettings.length > 0) {
setSelectedDopSetting(dopSettings[0]);
return;
}
};

handleImport = (gitlabProjectId: string) => {
const { selectedAlmInstance } = this.state;
setSelectedDopSetting(undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasDopSettings]);

if (selectedAlmInstance) {
this.props.onProjectSetupDone({
creationMode: CreateProjectModes.GitLab,
almSetting: selectedAlmInstance.key,
monorepo: false,
projects: [{ gitlabProjectId }],
});
}
};

handleLoadMore = async () => {
this.setState({ loadingMore: true });

const {
projectsPaging: { pageIndex },
searchQuery,
} = this.state;

const result = await this.fetchProjects(pageIndex + 1, searchQuery);
if (this.mounted) {
this.setState(({ projects = [], projectsPaging }) => ({
loadingMore: false,
projects: result ? [...projects, ...result.projects] : projects,
projectsPaging: result ? result.projectsPaging : projectsPaging,
}));
useEffect(() => {
if (selectedDopSetting) {
fetchInitialData();
}
};

handleSearch = async (searchQuery: string) => {
this.setState({ searching: true, searchQuery });

const result = await this.fetchProjects(1, searchQuery);
if (this.mounted) {
this.setState(({ projects, projectsPaging }) => ({
searching: false,
projects: result ? result.projects : projects,
projectsPaging: result ? result.projectsPaging : projectsPaging,
}));
}
};
}, [fetchInitialData, selectedDopSetting]);

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

handlePersonalAccessTokenCreated = () => {
this.cleanUrl();
this.setState({ showPersonalAccessTokenForm: false, resetPat: false }, () => {
this.fetchInitialData();
});
};

onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
this.setState({
selectedAlmInstance: instance,
showPersonalAccessTokenForm: true,
projects: undefined,
resetPat: false,
searchQuery: '',
});
};

render() {
const { loadingBindings, location, almInstances, canAdmin } = this.props;
const {
loading,
loadingMore,
projects,
projectsPaging,
resetPat,
searching,
searchQuery,
selectedAlmInstance,
showPersonalAccessTokenForm,
} = this.state;

return (
<GitlabProjectCreateRenderer
canAdmin={canAdmin}
almInstances={almInstances}
selectedAlmInstance={selectedAlmInstance}
loading={loading || loadingBindings}
loadingMore={loadingMore}
onImport={this.handleImport}
onLoadMore={this.handleLoadMore}
onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
onSearch={this.handleSearch}
projects={projects}
projectsPaging={projectsPaging}
resetPat={resetPat || Boolean(location.query.resetPat)}
searching={searching}
searchQuery={searchQuery}
showPersonalAccessTokenForm={
showPersonalAccessTokenForm || Boolean(location.query.resetPat)
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}
onSelectDopSetting={onSelectDopSetting}
onSelectRepository={handleSelectRepository}
personalAccessTokenComponent={
!isLoadingRepositories &&
selectedDopSetting && (
<GitlabPersonalAccessTokenForm
almSetting={selectedDopSetting}
resetPat={resetPersonalAccessToken}
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated}
/>
)
}
repositoryOptions={repositoryOptions}
repositorySearchQuery={searchQuery}
selectedDopSetting={selectedDopSetting}
selectedRepository={selectedRepository ? transformToOption(selectedRepository) : undefined}
showPersonalAccessToken={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
) : (
<GitlabProjectCreateRenderer
almInstances={dopSettings.map((dopSetting) => ({
alm: dopSetting.type,
key: dopSetting.key,
url: dopSetting.url,
}))}
canAdmin={canAdmin}
loading={isLoadingRepositories || isLoadingBindings}
loadingMore={isLoadingMoreRepositories}
onImport={handleImportRepository}
onLoadMore={handleLoadMore}
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated}
onSearch={setSearchQuery}
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
projects={repositories}
projectsPaging={repositoryPaging}
resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
searching={isLoadingRepositories}
searchQuery={searchQuery}
selectedAlmInstance={
selectedDopSetting && {
alm: selectedDopSetting.type,
key: selectedDopSetting.key,
url: selectedDopSetting.url,
}
onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
/>
);
}
}
showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
);
}

function transformToOption({ id, name }: GitlabProject): LabelValueSelectOption<string> {
return { value: id, label: name };
}

+ 39
- 6
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx View File

@@ -17,16 +17,22 @@
* 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 { LightPrimary, Title } from 'design-system';
import * as React from 'react';
import { translate } from '../../../../helpers/l10n';
import { GitlabProject } from '../../../../types/alm-integration';
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
import { AlmInstanceBase, AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
import { Paging } from '../../../../types/types';
import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
import GitlabPersonalAccessTokenForm from './GItlabPersonalAccessTokenForm';
import GitlabProjectSelectionForm from './GitlabProjectSelectionForm';
import { FormattedMessage } from 'react-intl';
import { Link, Spinner } from '@sonarsource/echoes-react';
import { queryToSearch } from '../../../../helpers/urls';
import { CreateProjectModes } from '../types';
import { Feature } from '../../../../types/features';
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';

export interface GitlabProjectCreateRendererProps {
canAdmin?: boolean;
@@ -44,10 +50,16 @@ export interface GitlabProjectCreateRendererProps {
almInstances?: AlmSettingsInstance[];
selectedAlmInstance?: AlmSettingsInstance;
showPersonalAccessTokenForm?: boolean;
onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
onSelectedAlmInstanceChange: (instance: AlmInstanceBase) => void;
}

export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRendererProps) {
export default function GitlabProjectCreateRenderer(
props: Readonly<GitlabProjectCreateRendererProps>,
) {
const isMonorepoSupported = React.useContext(AvailableFeaturesContext).includes(
Feature.MonoRepositoryPullRequestDecoration,
);

const {
canAdmin,
loading,
@@ -67,7 +79,28 @@ export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRe
<header className="sw-mb-10">
<Title className="sw-mb-4">{translate('onboarding.create_project.gitlab.title')}</Title>
<LightPrimary className="sw-body-sm">
{translate('onboarding.create_project.gitlab.subtitle')}
{isMonorepoSupported ? (
<FormattedMessage
id="onboarding.create_project.gitlab.subtitle.with_monorepo"
values={{
monorepoSetupLink: (
<Link
to={{
pathname: '/projects/create',
search: queryToSearch({
mode: CreateProjectModes.GitLab,
mono: true,
}),
}}
>
<FormattedMessage id="onboarding.create_project.gitlab.subtitle.link" />
</Link>
),
}}
/>
) : (
<FormattedMessage id="onboarding.create_project.gitlab.subtitle" />
)}
</LightPrimary>
</header>

@@ -78,7 +111,7 @@ export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRe
onChangeConfig={props.onSelectedAlmInstanceChange}
/>

<Spinner loading={loading} />
<Spinner isLoading={loading} />

{!loading && !selectedAlmInstance && (
<WrongBindingCountAlert alm={AlmKeys.GitLab} canAdmin={!!canAdmin} />

+ 5
- 2
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectSelectionForm.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 { FlagMessage, InputSearch, LightPrimary, Link } from 'design-system';
import { FlagMessage, InputSearch, LightPrimary } from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import ListFooter from '../../../../components/controls/ListFooter';
@@ -29,6 +29,7 @@ import { GitlabProject } from '../../../../types/alm-integration';
import { Paging } from '../../../../types/types';
import AlmRepoItem from '../components/AlmRepoItem';
import { CreateProjectModes } from '../types';
import { Link } from '@sonarsource/echoes-react';

export interface GitlabProjectSelectionFormProps {
loadingMore: boolean;
@@ -41,7 +42,9 @@ export interface GitlabProjectSelectionFormProps {
searchQuery: string;
}

export default function GitlabProjectSelectionForm(props: GitlabProjectSelectionFormProps) {
export default function GitlabProjectSelectionForm(
props: Readonly<GitlabProjectSelectionFormProps>,
) {
const { loadingMore, projects = [], projectsPaging, searching, searchQuery } = props;

if (projects.length === 0 && searchQuery.length === 0 && !searching) {

+ 46
- 9
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-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';
import selectEvent from 'react-select-event';
@@ -28,6 +28,8 @@ import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitio
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
import CreateProjectPage from '../CreateProjectPage';
import { Feature } from '../../../../types/features';
import { CreateProjectModes } from '../types';

jest.mock('../../../../api/alm-integrations');
jest.mock('../../../../api/alm-settings');
@@ -37,12 +39,16 @@ let dopTranslationHandler: DopTranslationServiceMock;
let newCodePeriodHandler: NewCodeDefinitionServiceMock;

const ui = {
cancelButton: byRole('button', { name: 'cancel' }),
gitlabCreateProjectButton: byText('onboarding.create_project.select_method.gitlab'),
gitLabOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.gitlab.title' }),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.gitlab.subtitle.link' }),
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.gitlab' }),

personalAccessTokenInput: byRole('textbox', {
name: /onboarding.create_project.enter_pat/,
}),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
};

const original = window.location;
@@ -145,17 +151,18 @@ it('should show search filter when PAT is already set', async () => {
await user.click(inputSearch);
await user.keyboard('sea');

await waitFor(() => expect(getGitlabProjects).toHaveBeenCalledTimes(2));
expect(getGitlabProjects).toHaveBeenLastCalledWith({
almSetting: 'conf-final-2',
page: 1,
pageSize: 20,
pageSize: 50,
query: 'sea',
});
});

it('should have load more', async () => {
const user = userEvent.setup();
almIntegrationHandler.createRandomGitlabProjectsWithLoadMore(10, 20);
almIntegrationHandler.createRandomGitlabProjectsWithLoadMore(50, 75);
renderCreateProject();

expect(await screen.findByText('onboarding.create_project.gitlab.title')).toBeInTheDocument();
@@ -167,12 +174,12 @@ it('should have load more', async () => {
* Next api call response will simulate reaching the last page so we can test the
* loadmore button disapperance.
*/
almIntegrationHandler.createRandomGitlabProjectsWithLoadMore(20, 20);
almIntegrationHandler.createRandomGitlabProjectsWithLoadMore(50, 50);
await user.click(loadMore);
expect(getGitlabProjects).toHaveBeenLastCalledWith({
almSetting: 'conf-final-2',
page: 2,
pageSize: 20,
pageSize: 50,
query: '',
});
expect(loadMore).not.toBeInTheDocument();
@@ -190,8 +197,38 @@ it('should show no result message when there are no projects', async () => {
).toBeInTheDocument();
});

function renderCreateProject() {
renderApp('project/create', <CreateProjectPage />, {
navigateTo: 'project/create?mode=gitlab',
describe('GitLab monorepo project navigation', () => {
it('should be able to access monorepo setup page from GitLab 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 GitLab onboarding page from monorepo setup page', async () => {
const user = userEvent.setup();
renderCreateProject({ isMonorepo: true });

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

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

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

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

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

@@ -51,7 +51,7 @@ const ui = {
addButton: byRole('button', { name: 'onboarding.create_project.monorepo.add_project' }),
cancelButton: byRole('button', { name: 'cancel' }),
dopSettingSelector: byRole('combobox', {
name: `onboarding.create_project.monorepo.choose_dop_setting.${AlmKeys.GitHub}`,
name: `onboarding.create_project.monorepo.choose_dop_settingalm.${AlmKeys.GitHub}`,
}),
gitHubOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.github.title' }),
monorepoProjectTitle: byRole('heading', {
@@ -60,11 +60,11 @@ const ui = {
monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.github.subtitle.link' }),
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.github' }),
organizationSelector: byRole('combobox', {
name: `onboarding.create_project.monorepo.choose_organization.${AlmKeys.GitHub}`,
name: `onboarding.create_project.monorepo.choose_organization`,
}),
removeButton: byRole('button', { name: 'onboarding.create_project.monorepo.remove_project' }),
repositorySelector: byRole('combobox', {
name: `onboarding.create_project.monorepo.choose_repository.${AlmKeys.GitHub}`,
name: `onboarding.create_project.monorepo.choose_repository`,
}),
notBoundRepositoryMessage: byText(
'onboarding.create_project.monorepo.choose_repository.no_already_bound_projects',

+ 7
- 2
server/sonar-web/src/main/js/apps/create/project/components/DopSettingDropdown.tsx View File

@@ -20,7 +20,7 @@
import classNames from 'classnames';
import { DarkLabel, InputSelect, LabelValueSelectOption, Note } from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { OptionProps, SingleValueProps, components } from 'react-select';
import { translate } from '../../../../helpers/l10n';
import { AlmKeys } from '../../../../types/alm-settings';
@@ -62,6 +62,8 @@ function orgToOption(alm: DopSetting) {
}

export default function DopSettingDropdown(props: Readonly<DopSettingDropdownProps>) {
const { formatMessage } = useIntl();

const { almKey, className, dopSettings, onChangeSetting, selectedDopSetting } = props;
if (!dopSettings || dopSettings.length < MIN_SIZE_INSTANCES) {
return null;
@@ -70,7 +72,10 @@ export default function DopSettingDropdown(props: Readonly<DopSettingDropdownPro
return (
<div className={classNames('sw-flex sw-flex-col', className)}>
<DarkLabel htmlFor="dop-setting-dropdown" className="sw-mb-2">
<FormattedMessage id={`onboarding.create_project.monorepo.choose_dop_setting.${almKey}`} />
<FormattedMessage
id="onboarding.create_project.monorepo.choose_dop_setting"
values={{ almKey: formatMessage({ id: `alm.${almKey}` }) }}
/>
</DarkLabel>

<InputSelect

+ 146
- 0
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoConnectionSelector.tsx View File

@@ -0,0 +1,146 @@
/*
* 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 { Title } from 'design-system/lib';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import DopSettingDropdown from '../components/DopSettingDropdown';
import { MonorepoOrganisationSelector } from './MonorepoOrganisationSelector';
import { MonorepoRepositorySelector } from './MonorepoRepositorySelector';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { DopSetting } from '../../../../types/dop-translation';
import { AlmKeys } from '../../../../types/alm-settings';
import MonorepoNoOrganisations from './MonorepoNoOrganisations';

interface Props {
almKey: AlmKeys;
alreadyBoundProjects: {
projectId: string;
projectName: string;
}[];
canAdmin: boolean;
dopSettings: DopSetting[];
error: boolean;
isFetchingAlreadyBoundProjects: boolean;
isLoadingAlreadyBoundProjects: boolean;
loadingBindings: boolean;
loadingOrganizations?: boolean;
loadingRepositories: boolean;
onSearchRepositories: (query: string) => void;
onSelectDopSetting: (instance: DopSetting) => void;
onSelectOrganization?: (organizationKey: string) => void;
onSelectRepository: (repositoryKey: string) => void;
organizationOptions?: LabelValueSelectOption[];
personalAccessTokenComponent?: React.ReactNode;
repositoryOptions?: LabelValueSelectOption[];
repositorySearchQuery: string;
selectedDopSetting?: DopSetting;
selectedOrganization?: LabelValueSelectOption;
selectedRepository?: LabelValueSelectOption;
showPersonalAccessToken?: boolean;
showOrganizations?: boolean;
}

export function MonorepoConnectionSelector({
almKey,
alreadyBoundProjects,
canAdmin,
dopSettings,
error,
isFetchingAlreadyBoundProjects,
isLoadingAlreadyBoundProjects,
loadingOrganizations,
loadingRepositories,
onSearchRepositories,
onSelectDopSetting,
onSelectOrganization,
onSelectRepository,
organizationOptions,
personalAccessTokenComponent,
repositoryOptions,
repositorySearchQuery,
selectedDopSetting,
selectedOrganization,
selectedRepository,
showPersonalAccessToken,
showOrganizations,
}: Readonly<Props>) {
return (
<div className="sw-flex sw-flex-col sw-gap-6">
<Title>
<FormattedMessage
id={
showOrganizations
? 'onboarding.create_project.monorepo.choose_organization_and_repository'
: 'onboarding.create_project.monorepo.choose_repository'
}
/>
</Title>

<DopSettingDropdown
almKey={almKey}
dopSettings={dopSettings}
selectedDopSetting={selectedDopSetting}
onChangeSetting={onSelectDopSetting}
/>

{showPersonalAccessToken ? (
personalAccessTokenComponent
) : (
<>
{showOrganizations && error && selectedDopSetting && !loadingOrganizations && (
<MonorepoNoOrganisations almKey={almKey} canAdmin={canAdmin} />
)}

{showOrganizations && organizationOptions && (
<div className="sw-flex sw-flex-col">
<MonorepoOrganisationSelector
almKey={almKey}
canAdmin={canAdmin}
error={error}
organizationOptions={organizationOptions}
loadingOrganizations={loadingOrganizations}
onSelectOrganization={onSelectOrganization}
selectedOrganization={selectedOrganization}
/>
</div>
)}

<div className="sw-flex sw-flex-col">
<MonorepoRepositorySelector
almKey={almKey}
alreadyBoundProjects={alreadyBoundProjects}
error={error}
isFetchingAlreadyBoundProjects={isFetchingAlreadyBoundProjects}
isLoadingAlreadyBoundProjects={isLoadingAlreadyBoundProjects}
loadingRepositories={loadingRepositories}
onSelectRepository={onSelectRepository}
onSearchRepositories={onSearchRepositories}
repositoryOptions={repositoryOptions}
repositorySearchQuery={repositorySearchQuery}
selectedOrganization={selectedOrganization}
selectedRepository={selectedRepository}
showOrganizations={showOrganizations}
/>
</div>
</>
)}
</div>
);
}

+ 59
- 0
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoNoOrganisations.tsx View File

@@ -0,0 +1,59 @@
/*
* 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 { Link } from '@sonarsource/echoes-react';
import { FlagMessage } from 'design-system/lib';
import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { AlmKeys } from '../../../../types/alm-settings';

export default function MonorepoNoOrganisations({
almKey,
canAdmin,
}: Readonly<{ almKey: AlmKeys; canAdmin: boolean }>) {
const { formatMessage } = useIntl();

return (
<FlagMessage variant="warning">
<span>
{canAdmin ? (
<FormattedMessage
id="onboarding.create_project.monorepo.warning.message_admin"
defaultMessage={formatMessage({
id: 'onboarding.create_project.monorepo.warning.message_admin',
})}
values={{
almKey: formatMessage({ id: `alm.${almKey}` }),
link: (
<Link to="/admin/settings?category=almintegration">
<FormattedMessage id="onboarding.create_project.monorepo.warning.message_admin.link" />
</Link>
),
}}
/>
) : (
<FormattedMessage
id="onboarding.create_project.monorepo.warning.message"
values={{ almKey: formatMessage({ id: `alm.${almKey}` }) }}
/>
)}
</span>
</FlagMessage>
);
}

+ 102
- 0
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoOrganisationSelector.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 { Link, Spinner } from '@sonarsource/echoes-react';
import { DarkLabel, FlagMessage, InputSelect } from 'design-system';
import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
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;
organizationOptions: LabelValueSelectOption[];
selectedOrganization?: LabelValueSelectOption;
}

export function MonorepoOrganisationSelector({
almKey,
canAdmin,
error,
loadingOrganizations,
onSelectOrganization,
organizationOptions,
selectedOrganization,
}: Readonly<Props>) {
const { formatMessage } = useIntl();

return (
!error && (
<>
<DarkLabel htmlFor={`${almKey}-monorepo-choose-organization`} className="sw-mb-2">
<FormattedMessage id="onboarding.create_project.monorepo.choose_organization" />
</DarkLabel>

<Spinner isLoading={loadingOrganizations && !error}>
{organizationOptions.length > 0 ? (
<InputSelect
size="large"
isSearchable
inputId={`${almKey}-monorepo-choose-organization`}
options={organizationOptions}
onChange={({ value }: LabelValueSelectOption) => {
if (onSelectOrganization) {
onSelectOrganization(value);
}
}}
placeholder={formatMessage({
id: 'onboarding.create_project.monorepo.choose_organization.placeholder',
})}
value={selectedOrganization}
/>
) : (
!loadingOrganizations && (
<FlagMessage variant="error" className="sw-mb-2">
<span>
{canAdmin ? (
<FormattedMessage
id="onboarding.create_project.monorepo.no_orgs_admin"
defaultMessage={formatMessage({
id: 'onboarding.create_project.monorepo.no_orgs_admin',
})}
values={{
almKey,
link: (
<Link to="/admin/settings?category=almintegration">
<FormattedMessage id="onboarding.create_project.monorepo.warning.message_admin.link" />
</Link>
),
}}
/>
) : (
<FormattedMessage id="onboarding.create_project.monorepo.no_orgs" />
)}
</span>
</FlagMessage>
)
)}
</Spinner>
</>
)
);
}

+ 63
- 232
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx View File

@@ -17,35 +17,24 @@
* 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, LinkHighlight, LinkStandalone, Spinner } from '@sonarsource/echoes-react';
import {
AddNewIcon,
BlueGreySeparator,
ButtonPrimary,
ButtonSecondary,
DarkLabel,
FlagMessage,
InputSelect,
SubTitle,
Title,
} from 'design-system';
import { Spinner } from '@sonarsource/echoes-react';
import { BlueGreySeparator, ButtonPrimary, ButtonSecondary } from 'design-system';
import React, { useEffect, useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { getComponents } from '../../../../api/project-management';
import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
import { throwGlobalError } from '../../../../helpers/error';
import { translate } from '../../../../helpers/l10n';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { getProjectUrl } from '../../../../helpers/urls';
import { useProjectBindingsQuery } from '../../../../queries/dop-translation';
import { AlmKeys } from '../../../../types/alm-settings';
import { DopSetting } from '../../../../types/dop-translation';
import { ImportProjectParam } from '../CreateProjectPage';
import DopSettingDropdown from '../components/DopSettingDropdown';
import { ProjectData, ProjectValidationCard } from '../components/ProjectValidation';
import { ProjectData } from '../components/ProjectValidation';
import { CreateProjectModes } from '../types';
import { getSanitizedProjectKey } from '../utils';
import { MonorepoProjectHeader } from './MonorepoProjectHeader';
import { MonorepoConnectionSelector } from './MonorepoConnectionSelector';
import { MonorepoProjectsList } from './MonorepoProjectsList';

interface MonorepoProjectCreateProps {
canAdmin: boolean;
@@ -57,37 +46,29 @@ interface MonorepoProjectCreateProps {
onProjectSetupDone: (importProjects: ImportProjectParam) => void;
onSearchRepositories: (query: string) => void;
onSelectDopSetting: (instance: DopSetting) => void;
onSelectOrganization: (organizationKey: string) => void;
onSelectRepository: (repositoryIdentifier: string) => void;
onSelectOrganization?: (organizationKey: string) => void;
onSelectRepository: (repositoryKey: string) => void;
organizationOptions?: LabelValueSelectOption[];
personalAccessTokenComponent?: React.ReactNode;
repositoryOptions?: LabelValueSelectOption[];
repositorySearchQuery: string;
selectedDopSetting?: DopSetting;
selectedOrganization?: LabelValueSelectOption;
selectedRepository?: LabelValueSelectOption;
showOrganizations?: boolean;
showPersonalAccessToken?: boolean;
}

type ProjectItem = Required<ProjectData<number>>;

export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCreateProps>) {
const {
dopSettings,
canAdmin,
error,
loadingBindings,
loadingOrganizations,
loadingRepositories,
onProjectSetupDone,
onSearchRepositories,
onSelectDopSetting,
onSelectOrganization,
onSelectRepository,
organizationOptions,
repositoryOptions,
repositorySearchQuery,
selectedDopSetting,
selectedOrganization,
selectedRepository,
showOrganizations = false,
} = props;

const projectCounter = useRef(0);
@@ -99,7 +80,6 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre

const location = useLocation();
const { push } = useRouter();
const { formatMessage } = useIntl();

const projectKeys = React.useMemo(() => projects.map(({ key }) => key), [projects]);
const {
@@ -116,24 +96,26 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre

const almKey = location.query.mode as AlmKeys;

const isOptionSelectionInvalid =
(showOrganizations && selectedOrganization === undefined) || selectedRepository === undefined;
const isSetupInvalid =
selectedDopSetting === undefined ||
selectedOrganization === undefined ||
selectedRepository === undefined ||
isOptionSelectionInvalid ||
projects.length === 0 ||
projects.some(({ hasError, key, name }) => hasError || key === '' || name === '');

const addProject = () => {
if (selectedOrganization === undefined || selectedRepository === undefined) {
const onAddProject = React.useCallback(() => {
if (isOptionSelectionInvalid) {
return;
}

const id = projectCounter.current;
projectCounter.current += 1;

const projectKeySuffix = id === 0 ? '' : `-${id}`;
const projectKey = getSanitizedProjectKey(
`${selectedOrganization.label}_${selectedRepository.label}_add-your-reference${projectKeySuffix}`,
showOrganizations && selectedOrganization
? `${selectedOrganization.label}_${selectedRepository.label}_add-your-reference${projectKeySuffix}`
: `${selectedRepository.label}_add-your-reference${projectKeySuffix}`,
);

const newProjects = [
@@ -148,28 +130,40 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre
];

setProjects(newProjects);
};

const onProjectChange = (project: ProjectItem) => {
const newProjects = projects.filter(({ id }) => id !== project.id);
newProjects.push({
...project,
});
newProjects.sort((a, b) => a.id - b.id);

setProjects(newProjects);
};
}, [
isOptionSelectionInvalid,
projects,
selectedOrganization,
selectedRepository,
showOrganizations,
]);

const onChangeProject = React.useCallback(
(project: ProjectItem) => {
const newProjects = projects.filter(({ id }) => id !== project.id);
newProjects.push({
...project,
});
newProjects.sort((a, b) => a.id - b.id);

setProjects(newProjects);
},
[projects],
);

const onProjectRemove = (id: number) => {
const newProjects = projects.filter(({ id: projectId }) => projectId !== id);
const onRemoveProject = React.useCallback(
(id: number) => {
const newProjects = projects.filter(({ id: projectId }) => projectId !== id);

setProjects(newProjects);
};
setProjects(newProjects);
},
[projects],
);

const cancelMonorepoSetup = () => {
push({
pathname: location.pathname,
query: { mode: AlmKeys.GitHub },
query: { mode: almKey },
});
};

@@ -194,7 +188,7 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre

useEffect(() => {
if (selectedRepository !== undefined && projects.length === 0) {
addProject();
onAddProject();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedRepository]);
@@ -233,188 +227,25 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre

<BlueGreySeparator className="sw-my-5" />

<div className="sw-flex sw-flex-col sw-gap-6">
<Title>
<FormattedMessage
id={`onboarding.create_project.monorepo.choose_organization_and_repository.${almKey}`}
/>
</Title>

<DopSettingDropdown
almKey={almKey}
dopSettings={dopSettings}
selectedDopSetting={selectedDopSetting}
onChangeSetting={onSelectDopSetting}
/>

{error && selectedDopSetting && !loadingOrganizations && (
<FlagMessage variant="warning">
<span>
{canAdmin ? (
<FormattedMessage
id="onboarding.create_project.github.warning.message_admin"
defaultMessage={translate(
'onboarding.create_project.github.warning.message_admin',
)}
values={{
link: (
<Link to="/admin/settings?category=almintegration">
{translate('onboarding.create_project.github.warning.message_admin.link')}
</Link>
),
}}
/>
) : (
translate('onboarding.create_project.github.warning.message')
)}
</span>
</FlagMessage>
)}

<div className="sw-flex sw-flex-col">
<Spinner isLoading={loadingOrganizations && !error}>
{!error && (
<>
<DarkLabel htmlFor="monorepo-choose-organization" className="sw-mb-2">
<FormattedMessage
id={`onboarding.create_project.monorepo.choose_organization.${almKey}`}
/>
</DarkLabel>
{(organizationOptions?.length ?? 0) > 0 ? (
<InputSelect
size="full"
isSearchable
inputId="monorepo-choose-organization"
options={organizationOptions}
onChange={({ value }: LabelValueSelectOption) => {
onSelectOrganization(value);
}}
placeholder={formatMessage({
id: `onboarding.create_project.monorepo.choose_organization.${almKey}.placeholder`,
})}
value={selectedOrganization}
/>
) : (
!loadingOrganizations && (
<FlagMessage variant="error" className="sw-mb-2">
<span>
{canAdmin ? (
<FormattedMessage
id="onboarding.create_project.github.no_orgs_admin"
defaultMessage={translate(
'onboarding.create_project.github.no_orgs_admin',
)}
values={{
link: (
<Link to="/admin/settings?category=almintegration">
{translate(
'onboarding.create_project.github.warning.message_admin.link',
)}
</Link>
),
}}
/>
) : (
translate('onboarding.create_project.github.no_orgs')
)}
</span>
</FlagMessage>
)
)}
</>
)}
</Spinner>
</div>

<div className="sw-flex sw-flex-col">
{selectedOrganization && (
<DarkLabel className="sw-mb-2" htmlFor="monorepo-choose-repository">
<FormattedMessage
id={`onboarding.create_project.monorepo.choose_repository.${almKey}`}
/>
</DarkLabel>
)}
{selectedOrganization && (
<>
<InputSelect
inputId="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.${almKey}.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>
)}
</>
)}
</div>
</div>
<MonorepoConnectionSelector
almKey={almKey}
alreadyBoundProjects={alreadyBoundProjects}
isFetchingAlreadyBoundProjects={isFetchingAlreadyBoundProjects}
isLoadingAlreadyBoundProjects={isLoadingAlreadyBoundProjects}
{...props}
/>

{selectedRepository !== undefined && (
<>
<BlueGreySeparator className="sw-my-5" />

<div>
<SubTitle>
<FormattedMessage id="onboarding.create_project.monorepo.project_title" />
</SubTitle>
<div>
{projects.map(({ id, key, name }) => (
<ProjectValidationCard
className="sw-mt-4"
initialKey={key}
initialName={name}
key={id}
monorepoSetupProjectKeys={projectKeys}
onChange={onProjectChange}
onRemove={() => {
onProjectRemove(id);
}}
projectId={id}
/>
))}
</div>

<div className="sw-flex sw-justify-end sw-mt-4">
<ButtonSecondary onClick={addProject}>
<AddNewIcon className="sw-mr-2" />
<FormattedMessage id="onboarding.create_project.monorepo.add_project" />
</ButtonSecondary>
</div>
</div>
<MonorepoProjectsList
projectKeys={projectKeys}
onAddProject={onAddProject}
onChangeProject={onChangeProject}
onRemoveProject={onRemoveProject}
projects={projects}
/>
</>
)}


+ 69
- 0
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectsList.tsx View File

@@ -0,0 +1,69 @@
/*
* 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 { ButtonSecondary, SubTitle } from 'design-system';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { ProjectData, ProjectValidationCard } from '../components/ProjectValidation';

interface Props {
projectKeys: string[];
onAddProject: () => void;
onChangeProject: (project: ProjectData<number>) => void;
onRemoveProject: (id?: number) => void;
projects: ProjectData<number>[];
}

export function MonorepoProjectsList({
projectKeys,
onAddProject,
onChangeProject,
onRemoveProject,
projects,
}: Readonly<Props>) {
return (
<div>
<SubTitle>
<FormattedMessage id="onboarding.create_project.monorepo.project_title" />
</SubTitle>
<div>
{projects.map(({ id, key, name }) => (
<ProjectValidationCard
className="sw-mt-4"
initialKey={key}
initialName={name}
key={id}
monorepoSetupProjectKeys={projectKeys}
onChange={onChangeProject}
onRemove={() => {
onRemoveProject(id);
}}
projectId={id}
/>
))}
</div>

<div className="sw-flex sw-justify-end sw-mt-4">
<ButtonSecondary onClick={onAddProject}>
<FormattedMessage id="onboarding.create_project.monorepo.add_project" />
</ButtonSecondary>
</div>
</div>
);
}

+ 135
- 0
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoRepositorySelector.tsx View File

@@ -0,0 +1,135 @@
/*
* 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 { LinkHighlight, LinkStandalone, Spinner } from '@sonarsource/echoes-react';
import { DarkLabel, FlagMessage, InputSelect } from 'design-system';
import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { getProjectUrl } from '../../../../helpers/urls';
import { AlmKeys } from '../../../../types/alm-settings';

interface Props {
almKey: AlmKeys;
alreadyBoundProjects: {
projectId: string;
projectName: string;
}[];
error: boolean;
isFetchingAlreadyBoundProjects: boolean;
isLoadingAlreadyBoundProjects: boolean;
loadingRepositories: boolean;
onSearchRepositories: (query: string) => void;
onSelectRepository: (repositoryKey: string) => void;
repositorySearchQuery: string;
repositoryOptions?: LabelValueSelectOption[];
selectedOrganization?: LabelValueSelectOption;
selectedRepository?: LabelValueSelectOption;
showOrganizations?: boolean;
}

export function MonorepoRepositorySelector({
almKey,
alreadyBoundProjects,
error,
isFetchingAlreadyBoundProjects,
isLoadingAlreadyBoundProjects,
loadingRepositories,
onSearchRepositories,
onSelectRepository,
repositorySearchQuery,
repositoryOptions,
selectedOrganization,
selectedRepository,
showOrganizations,
}: Readonly<Props>) {
const { formatMessage } = useIntl();

const repositorySelectorEnabled =
!error &&
!loadingRepositories &&
((showOrganizations && !!selectedOrganization) || !showOrganizations);
const showWarningMessage =
error || (repositorySelectorEnabled && repositoryOptions && repositoryOptions.length === 0);

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}` }),
}}
/>
) : (
<>
<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>
</>
);
}

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/usePersonalAccessToken.ts View File

@@ -23,8 +23,8 @@ import {
setAlmPersonalAccessToken,
} from '../../../api/alm-integrations';
import { translate } from '../../../helpers/l10n';
import { AlmSettingsInstance } from '../../../types/alm-settings';
import { tokenExistedBefore } from './utils';
import { AlmInstanceBase } from '../../../types/alm-settings';

export interface PATType {
validationFailed: boolean;
@@ -41,7 +41,7 @@ export interface PATType {
}

export const usePersonalAccessToken = (
almSetting: AlmSettingsInstance,
almSetting: AlmInstanceBase,
resetPat: boolean,
onPersonalAccessTokenCreated: () => void,
): PATType => {

+ 9
- 9
server/sonar-web/src/main/js/components/devops-platform/AlmSettingsInstanceSelector.tsx View File

@@ -21,21 +21,21 @@ import { InputSelect, LabelValueSelectOption, Note } from 'design-system';
import * as React from 'react';
import { OptionProps, SingleValueProps, components } from 'react-select';
import { translate } from '../../helpers/l10n';
import { AlmSettingsInstance } from '../../types/alm-settings';
import { AlmInstanceBase } from '../../types/alm-settings';

function optionRenderer(props: OptionProps<LabelValueSelectOption<AlmSettingsInstance>, false>) {
function optionRenderer(props: OptionProps<LabelValueSelectOption<AlmInstanceBase>, false>) {
return <components.Option {...props}>{customOptions(props.data.value)}</components.Option>;
}

function singleValueRenderer(
props: SingleValueProps<LabelValueSelectOption<AlmSettingsInstance>, false>,
props: SingleValueProps<LabelValueSelectOption<AlmInstanceBase>, false>,
) {
return (
<components.SingleValue {...props}>{customOptions(props.data.value)}</components.SingleValue>
);
}

function customOptions(instance: AlmSettingsInstance) {
function customOptions(instance: AlmInstanceBase) {
return instance.url ? (
<>
<span>{instance.key} — </span>
@@ -46,14 +46,14 @@ function customOptions(instance: AlmSettingsInstance) {
);
}

function orgToOption(alm: AlmSettingsInstance) {
function orgToOption(alm: AlmInstanceBase) {
return { value: alm, label: alm.key };
}

interface Props {
instances: AlmSettingsInstance[];
instances: AlmInstanceBase[];
initialValue?: string;
onChange: (instance: AlmSettingsInstance) => void;
onChange: (instance: AlmInstanceBase) => void;
className: string;
inputId: string;
}
@@ -68,7 +68,7 @@ export default function AlmSettingsInstanceSelector(props: Props) {
isClearable={false}
isSearchable={false}
options={instances.map(orgToOption)}
onChange={(data: LabelValueSelectOption<AlmSettingsInstance>) => {
onChange={(data: LabelValueSelectOption<AlmInstanceBase>) => {
props.onChange(data.value);
}}
components={{
@@ -76,7 +76,7 @@ export default function AlmSettingsInstanceSelector(props: Props) {
SingleValue: singleValueRenderer,
}}
placeholder={translate('alm.configuration.selector.placeholder')}
getOptionValue={(opt: LabelValueSelectOption<AlmSettingsInstance>) => opt.value.key}
getOptionValue={(opt: LabelValueSelectOption<AlmInstanceBase>) => opt.value.key}
value={instances.map(orgToOption).find((opt) => opt.value.key === initialValue) ?? null}
size="full"
/>

+ 4
- 1
server/sonar-web/src/main/js/types/alm-settings.ts View File

@@ -130,8 +130,11 @@ export interface GitlabProjectAlmBindingParams extends ProjectAlmBindingParams {
repository?: string;
}

export interface AlmSettingsInstance {
export interface AlmSettingsInstance extends AlmInstanceBase {
alm: AlmKeys;
}

export interface AlmInstanceBase {
key: string;
url?: string;
}

+ 2
- 4
server/sonar-web/src/main/js/types/dop-translation.ts View File

@@ -18,14 +18,12 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { AlmKeys } from './alm-settings';
import { AlmInstanceBase, AlmKeys } from './alm-settings';

export interface DopSetting {
export interface DopSetting extends AlmInstanceBase {
appId?: string;
id: string;
key: string;
type: AlmKeys;
url?: string;
}

export interface BoundProject {

+ 14
- 6
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -4420,6 +4420,8 @@ onboarding.create_project.github.no_orgs_admin=We couldn't load any organization
onboarding.create_project.github.no_projects=No projects could be fetched from GitHub. Contact your system administrator.
onboarding.create_project.gitlab.title=Gitlab project onboarding
onboarding.create_project.gitlab.subtitle=Import projects from one of your GitLab groups
onboarding.create_project.gitlab.subtitle.with_monorepo=Import projects from one of your GitLab groups or {monorepoSetupLink}.
onboarding.create_project.gitlab.subtitle.link=set up a monorepo
onboarding.create_project.gitlab.no_projects=No projects could be fetched from Gitlab. Contact your system administrator, or {link}.
onboarding.create_project.gitlab.link=See on GitLab
onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator.
@@ -4433,17 +4435,23 @@ onboarding.create_project.import_in_progress={count} of {total} projects importe
onboarding.create_project.monorepo.title={almName} monorepo project onboarding
onboarding.create_project.monorepo.subtitle=Create multiple SonarQube projects corresponding to the same monorepo and bound to the same repository.
onboarding.create_project.monorepo.doc_link=Learn more and get help setting up your monorepo
onboarding.create_project.monorepo.choose_organization_and_repository.github=Choose the organization and the repository
onboarding.create_project.monorepo.choose_dop_setting.github=Choose the GitHub configuration
onboarding.create_project.monorepo.choose_organization.github=Choose the organization
onboarding.create_project.monorepo.choose_organization.github.placeholder=List of organizations
onboarding.create_project.monorepo.choose_repository.github=Choose the repository
onboarding.create_project.monorepo.choose_repository.github.placeholder=List of repositories
onboarding.create_project.monorepo.choose_organization_and_repository=Choose the organization and the repository
onboarding.create_project.monorepo.choose_dop_setting=Choose the {almKey} configuration
onboarding.create_project.monorepo.choose_organization=Choose the organization
onboarding.create_project.monorepo.choose_organization.placeholder=List of organizations
onboarding.create_project.monorepo.choose_repository=Choose the repository
onboarding.create_project.monorepo.choose_repository.placeholder=List of repositories
onboarding.create_project.monorepo.choose_repository.no_already_bound_projects=This repository has no imported projects in SonarQube
onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects=This repository has already been imported, and it's linked to these projects in SonarQube:
onboarding.create_project.monorepo.no_orgs=We couldn't load any organizations with your key. Contact an administrator.
onboarding.create_project.monorepo.no_orgs_admin=We couldn't load any organizations. Make sure the {almKey} App is installed in at least one organization and check the {almKey} instance configuration in the {link}.
onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator.
onboarding.create_project.monorepo.project_title=Create new projects
onboarding.create_project.monorepo.add_project=Add new project
onboarding.create_project.monorepo.remove_project=Remove project
onboarding.create_project.monorepo.warning.message=Could not connect to {almKey}. Please contact an administrator to configure {almKey} integration.
onboarding.create_project.monorepo.warning.message_admin=Could not connect to {almKey}. Please make sure the {almKey} instance is correctly configured in the {link} to create a new project from a repository.
onboarding.create_project.monorepo.warning.message_admin.link=DevOps Platform integration settings

onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code
onboarding.create_x_project.new_code_definition.title=Set up {count, plural, one {project} other {# projects}} for Clean as You Code

Loading…
Cancel
Save