Procházet zdrojové kódy

SONAR-21947 Add bulk import feature to Gitlab project onboarding

master
Ambroise C před 3 týdny
rodič
revize
454eb12ce7

+ 74
- 227
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx Zobrazit soubor

@@ -19,34 +19,20 @@
*/
/* eslint-disable react/no-unused-prop-types */

import styled from '@emotion/styled';
import { Link, Spinner } from '@sonarsource/echoes-react';
import {
ButtonPrimary,
Checkbox,
DarkLabel,
FlagMessage,
InputSearch,
InputSelect,
LightPrimary,
Title,
themeBorder,
themeColor,
} from 'design-system';
import React, { useContext, useState } from 'react';
import { DarkLabel, FlagMessage, InputSelect, LightPrimary, Title } from 'design-system';
import React, { useContext, useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
import ListFooter from '../../../../components/controls/ListFooter';
import { translate } from '../../../../helpers/l10n';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { getBaseUrl } from '../../../../helpers/system';
import { queryToSearch } from '../../../../helpers/urls';
import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
import { Feature } from '../../../../types/features';
import { Paging } from '../../../../types/types';
import AlmRepoItem from '../components/AlmRepoItem';
import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
import RepositoryList from '../components/RepositoryList';
import { CreateProjectModes } from '../types';

interface GitHubProjectCreateRendererProps {
@@ -69,113 +55,13 @@ interface GitHubProjectCreateRendererProps {
onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
}

type RepositoryListProps = Pick<
GitHubProjectCreateRendererProps,
| 'loadingRepositories'
| 'repositories'
| 'repositoryPaging'
| 'searchQuery'
| 'selectedOrganization'
| 'onLoadMore'
| 'onSearch'
> & {
selected: Set<string>;
checkAll: () => void;
uncheckAll: () => void;
onCheck: (key: string) => void;
};

function orgToOption({ key, name }: GithubOrganization) {
return { value: key, label: name };
}

function RepositoryList(props: RepositoryListProps) {
const {
loadingRepositories,
repositories,
repositoryPaging,
searchQuery,
selectedOrganization,
selected,
} = props;

const areAllRepositoriesChecked = () => {
const nonImportedRepos = repositories?.filter((r) => !r.sqProjectKey) ?? [];
return nonImportedRepos.length > 0 && selected.size === nonImportedRepos.length;
};

const onCheckAllRepositories = () => {
const allSelected = areAllRepositoriesChecked();
if (allSelected) {
props.uncheckAll();
} else {
props.checkAll();
}
};

if (!selectedOrganization || !repositories) {
return null;
}

return (
<div>
<div className="sw-mb-2 sw-py-2 sw-flex sw-items-center sw-justify-between sw-w-full">
<div>
<Checkbox
className="sw-ml-5"
checked={areAllRepositoriesChecked()}
disabled={repositories.length === 0}
onCheck={onCheckAllRepositories}
>
<span className="sw-ml-2">
{translate('onboarding.create_project.select_all_repositories')}
</span>
</Checkbox>
</div>
<InputSearch
size="medium"
loading={loadingRepositories}
onChange={props.onSearch}
placeholder={translate('onboarding.create_project.search_repositories')}
value={searchQuery}
/>
</div>

{repositories.length === 0 ? (
<div className="sw-py-6 sw-px-2">
<LightPrimary className="sw-body-sm">{translate('no_results')}</LightPrimary>
</div>
) : (
<ul className="sw-flex sw-flex-col sw-gap-3">
{repositories.map(({ key, url, sqProjectKey, name }) => (
<AlmRepoItem
key={key}
almKey={key}
almUrl={url}
almUrlText={translate('onboarding.create_project.see_on_github')}
almIconSrc={`${getBaseUrl()}/images/tutorials/github-actions.svg`}
sqProjectKey={sqProjectKey}
multiple
selected={selected.has(key)}
onCheck={(key: string) => props.onCheck(key)}
primaryTextNode={<span title={name}>{name}</span>}
/>
))}
</ul>
)}

<ListFooter
className="sw-mb-10"
count={repositories.length}
total={repositoryPaging.total}
loadMore={props.onLoadMore}
loading={loadingRepositories}
/>
</div>
);
}

export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) {
export default function GitHubProjectCreateRenderer(
props: Readonly<GitHubProjectCreateRendererProps>,
) {
const isMonorepoSupported = useContext(AvailableFeaturesContext).includes(
Feature.MonoRepositoryPullRequestDecoration,
);
@@ -193,24 +79,36 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
} = props;
const [selected, setSelected] = useState<Set<string>>(new Set());

useEffect(() => {
const selectedKeys = Array.from(selected).filter((key) =>
repositories?.find((r) => r.key === key),
);
setSelected(new Set(selectedKeys));
// We want to update only when `repositories` changes.
// If we subscribe to `selected` changes we will enter an infinite loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [repositories]);

if (loadingBindings) {
return <Spinner />;
}

const handleImport = () => {
props.onImportRepository(Array.from(selected));
const handleCheck = (key: string) => {
setSelected((prev) => new Set(prev.delete(key) ? prev : prev.add(key)));
};

const handleCheckAll = () => {
setSelected(new Set(repositories?.filter((r) => !r.sqProjectKey).map((r) => r.key) ?? []));
setSelected(
new Set(repositories?.filter((r) => r.sqProjectKey === undefined).map((r) => r.key) ?? []),
);
};

const handleUncheckAll = () => {
setSelected(new Set());
const handleImport = () => {
props.onImportRepository(Array.from(selected));
};

const handleCheck = (key: string) => {
setSelected((prev) => new Set(prev.delete(key) ? prev : prev.add(key)));
const handleUncheckAll = () => {
setSelected(new Set());
};

return (
@@ -272,112 +170,61 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
</FlagMessage>
)}

<div className="sw-flex sw-gap-12">
<LargeColumn>
<Spinner isLoading={loadingOrganizations && !error}>
{!error && (
<div className="sw-flex sw-flex-col">
<DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
{translate('onboarding.create_project.github.choose_organization')}
</DarkLabel>
{organizations.length > 0 ? (
<InputSelect
className="sw-w-full sw-mb-9"
size="full"
isSearchable
inputId="github-choose-organization"
options={organizations.map(orgToOption)}
onChange={({ value }: LabelValueSelectOption) =>
props.onSelectOrganization(value)
}
value={selectedOrganization ? orgToOption(selectedOrganization) : null}
/>
) : (
!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>
)
)}
</div>
<Spinner isLoading={loadingOrganizations && !error}>
{!error && (
<div className="sw-flex sw-flex-col">
<DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
{translate('onboarding.create_project.github.choose_organization')}
</DarkLabel>
{organizations.length > 0 ? (
<InputSelect
className="sw-w-7/12 sw-mb-9"
size="full"
isSearchable
inputId="github-choose-organization"
options={organizations.map(orgToOption)}
onChange={({ value }: LabelValueSelectOption) => props.onSelectOrganization(value)}
value={selectedOrganization ? orgToOption(selectedOrganization) : null}
/>
) : (
!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>
)}
{selectedOrganization && (
<RepositoryList
{...props}
selected={selected}
almKey={AlmKeys.GitHub}
checkAll={handleCheckAll}
uncheckAll={handleUncheckAll}
onCheck={handleCheck}
onImport={handleImport}
selected={selected}
uncheckAll={handleUncheckAll}
/>
</LargeColumn>
<SideColumn>
{selected.size > 0 && (
<SetupBox className="sw-rounded-2 sw-p-8 sw-mb-0">
<SetupBoxTitle className="sw-mb-2 sw-heading-md">
<FormattedMessage
id="onboarding.create_project.x_repositories_selected"
values={{ count: selected.size }}
/>
</SetupBoxTitle>
<div>
<SetupBoxContent className="sw-pb-4">
<FormattedMessage
id="onboarding.create_project.x_repository_created"
values={{ count: selected.size }}
/>
</SetupBoxContent>
<div className="sw-mt-4">
<ButtonPrimary onClick={handleImport} className="js-set-up-projects">
{translate('onboarding.create_project.import')}
</ButtonPrimary>
</div>
</div>
</SetupBox>
)}
</SideColumn>
</div>
)}
</Spinner>
</>
);
}

const LargeColumn = styled.div`
flex: 6;
`;

const SideColumn = styled.div`
flex: 4;
`;

const SetupBox = styled.form`
max-height: 280px;
background: ${themeColor('highlightedSection')};
border: ${themeBorder('default', 'highlightedSectionBorder')};
`;

const SetupBoxTitle = styled.h2`
color: ${themeColor('pageTitle')};
`;

const SetupBoxContent = styled.div`
border-bottom: ${themeBorder('default')};
`;

+ 3
- 8
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx Zobrazit soubor

@@ -48,7 +48,6 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>();

const [isLoadingRepositories, setIsLoadingRepositories] = useState(false);
const [isLoadingMoreRepositories, setIsLoadingMoreRepositories] = useState(false);
const [repositories, setRepositories] = useState<GitlabProject[]>([]);
const [repositoryPaging, setRepositoryPaging] = useState<Paging>({
pageSize: REPOSITORY_PAGE_SIZE,
@@ -131,13 +130,13 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
}, [cleanUrl, fetchInitialData]);

const handleImportRepository = useCallback(
(gitlabProjectId: string) => {
if (selectedDopSetting) {
(repoKeys: string[]) => {
if (selectedDopSetting && repoKeys.length > 0) {
onProjectSetupDone({
almSetting: selectedDopSetting.key,
creationMode: CreateProjectModes.GitLab,
monorepo: false,
projects: [{ gitlabProjectId }],
projects: repoKeys.map((repoKeys) => ({ gitlabProjectId: repoKeys })),
});
}
},
@@ -145,13 +144,11 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
);

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

const handleSelectRepository = useCallback(
@@ -243,7 +240,6 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
}))}
canAdmin={canAdmin}
loading={isLoadingRepositories || isLoadingBindings}
loadingMore={isLoadingMoreRepositories}
onImport={handleImportRepository}
onLoadMore={handleLoadMore}
onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated}
@@ -252,7 +248,6 @@ export default function GitlabProjectCreate(props: Readonly<Props>) {
projects={repositories}
projectsPaging={repositoryPaging}
resetPat={resetPersonalAccessToken || Boolean(location.query.resetPat)}
searching={isLoadingRepositories}
searchQuery={searchQuery}
selectedAlmInstance={
selectedDopSetting && {

+ 47
- 17
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx Zobrazit soubor

@@ -19,7 +19,7 @@
*/
import { Link, Spinner } from '@sonarsource/echoes-react';
import { LightPrimary, Title } from 'design-system';
import * as React from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
import { translate } from '../../../../helpers/l10n';
@@ -29,23 +29,21 @@ import { AlmInstanceBase, AlmKeys, AlmSettingsInstance } from '../../../../types
import { Feature } from '../../../../types/features';
import { Paging } from '../../../../types/types';
import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
import RepositoryList from '../components/RepositoryList';
import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
import { CreateProjectModes } from '../types';
import GitlabPersonalAccessTokenForm from './GItlabPersonalAccessTokenForm';
import GitlabProjectSelectionForm from './GitlabProjectSelectionForm';

export interface GitlabProjectCreateRendererProps {
canAdmin?: boolean;
loading: boolean;
loadingMore: boolean;
onImport: (gitlabProjectId: string) => void;
onImport: (id: string[]) => void;
onLoadMore: () => void;
onPersonalAccessTokenCreated: () => void;
onSearch: (searchQuery: string) => void;
projects?: GitlabProject[];
projectsPaging: Paging;
resetPat: boolean;
searching: boolean;
searchQuery: string;
almInstances?: AlmSettingsInstance[];
selectedAlmInstance?: AlmSettingsInstance;
@@ -56,24 +54,52 @@ export interface GitlabProjectCreateRendererProps {
export default function GitlabProjectCreateRenderer(
props: Readonly<GitlabProjectCreateRendererProps>,
) {
const isMonorepoSupported = React.useContext(AvailableFeaturesContext).includes(
const isMonorepoSupported = useContext(AvailableFeaturesContext).includes(
Feature.MonoRepositoryPullRequestDecoration,
);

const {
almInstances,
canAdmin,
loading,
loadingMore,
onLoadMore,
onSearch,
projects,
projectsPaging,
resetPat,
searching,
searchQuery,
selectedAlmInstance,
almInstances,
showPersonalAccessTokenForm,
} = props;

const [selected, setSelected] = useState<Set<string>>(new Set());

const handleCheck = (id: string) => {
setSelected((prev) => new Set(prev.delete(id) ? prev : prev.add(id)));
};

const handleCheckAll = () => {
setSelected(
new Set(projects?.filter((r) => r.sqProjectKey === undefined).map((r) => r.id) ?? []),
);
};

const handleImport = () => {
props.onImport(Array.from(selected));
};

const handleUncheckAll = () => {
setSelected(new Set());
};

useEffect(() => {
const selectedIds = Array.from(selected).filter((id) => projects?.find((r) => r.id === id));
setSelected(new Set(selectedIds));
// We want to update only when `projects` changes.
// If we subscribe to `selected` changes we will enter an infinite loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects]);

return (
<>
<header className="sw-mb-10">
@@ -126,15 +152,19 @@ export default function GitlabProjectCreateRenderer(
onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
/>
) : (
<GitlabProjectSelectionForm
loadingMore={loadingMore}
onImport={props.onImport}
onLoadMore={props.onLoadMore}
onSearch={props.onSearch}
projects={projects}
projectsPaging={projectsPaging}
searching={searching}
<RepositoryList
almKey={AlmKeys.GitLab}
checkAll={handleCheckAll}
loadingRepositories={loading}
onCheck={handleCheck}
onImport={handleImport}
onLoadMore={onLoadMore}
onSearch={onSearch}
repositories={projects}
repositoryPaging={projectsPaging}
searchQuery={searchQuery}
selected={selected}
uncheckAll={handleUncheckAll}
/>
))}
</>

+ 3
- 3
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx Zobrazit soubor

@@ -167,9 +167,9 @@ it('should import several projects', async () => {
const user = userEvent.setup();

almIntegrationHandler.setGithubRepositories([
mockGitHubRepository({ name: 'Github repo 1', key: 'key1' }),
mockGitHubRepository({ name: 'Github repo 2', key: 'key2' }),
mockGitHubRepository({ name: 'Github repo 3', key: 'key3' }),
mockGitHubRepository({ id: '1', name: 'Github repo 1', key: 'key1' }),
mockGitHubRepository({ id: '2', name: 'Github repo 2', key: 'key2' }),
mockGitHubRepository({ id: '3', name: 'Github repo 3', key: 'key3' }),
]);

renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213');

+ 118
- 52
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx Zobrazit soubor

@@ -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, waitFor, within } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import selectEvent from 'react-select-event';
@@ -25,6 +25,7 @@ import { getGitlabProjects } from '../../../../api/alm-integrations';
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock';
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
import { mockGitlabProject } from '../../../../helpers/mocks/alm-integrations';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
import { Feature } from '../../../../types/features';
@@ -43,14 +44,44 @@ const ui = {
gitlabCreateProjectButton: byText('onboarding.create_project.select_method.gitlab'),
gitLabOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.gitlab.title' }),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
importProjectsTitle: byText('onboarding.create_project.gitlab.title'),
monorepoSetupLink: byRole('link', {
name: 'onboarding.create_project.subtitle_monorepo_setup_link',
}),
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.gitlab' }),
patHelpInstructions: byText('onboarding.create_project.pat_help.instructions.gitlab'),
personalAccessTokenInput: byRole('textbox', {
name: /onboarding.create_project.enter_pat/,
}),

// Bulk import
checkAll: byRole('checkbox', { name: 'onboarding.create_project.select_all_repositories' }),
project1: byRole('listitem', { name: 'Gitlab project 1' }),
project1Checkbox: byRole('listitem', { name: 'Gitlab project 1' }).byRole('checkbox'),
project1Link: byRole('listitem', { name: 'Gitlab project 1' }).byRole('link', {
name: 'Gitlab project 1',
}),
project1GitlabLink: byRole('listitem', { name: 'Gitlab project 1' }).byRole('link', {
name: 'onboarding.create_project.see_on.alm.gitlab',
}),
project2: byRole('listitem', { name: 'Gitlab project 2' }),
project2Checkbox: byRole('listitem', { name: 'Gitlab project 2' }).byRole('checkbox'),
project3: byRole('listitem', { name: 'Gitlab project 3' }),
project3Checkbox: byRole('listitem', { name: 'Gitlab project 3' }).byRole('checkbox'),
importButton: byRole('button', { name: 'onboarding.create_project.import' }),
saveButton: byRole('button', { name: 'save' }),
backButton: byRole('button', { name: 'back' }),
newCodeMultipleProjectTitle: byRole('heading', {
name: 'onboarding.create_x_project.new_code_definition.title2',
}),
changePeriodLaterInfo: byText('onboarding.create_projects.new_code_definition.change_info'),
createProjectButton: byRole('button', {
name: 'onboarding.create_project.new_code_definition.create_x_projects1',
}),
createProjectsButton: byRole('button', {
name: 'onboarding.create_project.new_code_definition.create_x_projects2',
}),
globalSettingRadio: byRole('radio', { name: 'new_code_definition.global_setting' }),
};

const original = window.location;
@@ -80,72 +111,39 @@ it('should ask for PAT when it is not set yet and show the import project featur
const user = userEvent.setup();
renderCreateProject();

expect(await screen.findByText('onboarding.create_project.gitlab.title')).toBeInTheDocument();
expect(await ui.importProjectsTitle.find()).toBeInTheDocument();
expect(ui.instanceSelector.get()).toBeInTheDocument();

expect(screen.getByText('onboarding.create_project.enter_pat')).toBeInTheDocument();
expect(
screen.getByText('onboarding.create_project.pat_help.instructions.gitlab'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();
expect(ui.patHelpInstructions.get()).toBeInTheDocument();
expect(ui.saveButton.get()).toBeInTheDocument();

await user.click(ui.personalAccessTokenInput.get());
await user.keyboard('secret');
await user.click(screen.getByRole('button', { name: 'save' }));
await user.click(ui.saveButton.get());

expect(screen.getByText('Gitlab project 1')).toBeInTheDocument();
expect(screen.getByText('Gitlab project 2')).toBeInTheDocument();
expect(screen.getAllByText('onboarding.create_project.import')).toHaveLength(2);
expect(screen.getByText('onboarding.create_project.repository_imported')).toBeInTheDocument();
expect(await ui.project1.find()).toBeInTheDocument();
});

it('should show import project feature when PAT is already set', async () => {
const user = userEvent.setup();
let projectItem;
renderCreateProject();

expect(await screen.findByText('onboarding.create_project.gitlab.title')).toBeInTheDocument();
expect(await ui.importProjectsTitle.find()).toBeInTheDocument();
await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);

expect(await screen.findByText('Gitlab project 1')).toBeInTheDocument();
expect(screen.getByText('Gitlab project 2')).toBeInTheDocument();

projectItem = screen.getByRole('listitem', { name: /Gitlab project 1/ });
expect(
within(projectItem).getByText('onboarding.create_project.repository_imported'),
).toBeInTheDocument();
expect(within(projectItem).getByRole('link', { name: /Gitlab project 1/ })).toBeInTheDocument();
expect(within(projectItem).getByRole('link', { name: /Gitlab project 1/ })).toHaveAttribute(
expect(await ui.project1.find()).toBeInTheDocument();
expect(ui.project1Link.get()).toHaveAttribute('href', '/dashboard?id=key');
expect(ui.project1GitlabLink.get()).toHaveAttribute(
'href',
'/dashboard?id=key',
);

projectItem = screen.getByRole('listitem', { name: /Gitlab project 2/ });
const importButton = within(projectItem).getByRole('button', {
name: 'onboarding.create_project.import',
});

await user.click(importButton);

expect(
screen.getByRole('heading', { name: 'onboarding.create_x_project.new_code_definition.title1' }),
).toBeInTheDocument();

await user.click(screen.getByRole('radio', { name: 'new_code_definition.global_setting' }));
await user.click(
screen.getByRole('button', {
name: 'onboarding.create_project.new_code_definition.create_x_projects1',
}),
'https://gitlab.company.com/best-projects/awesome-project-exclamation',
);

expect(await screen.findByText('/dashboard?id=key')).toBeInTheDocument();
});

it('should show search filter when PAT is already set', async () => {
const user = userEvent.setup();
renderCreateProject();

expect(await screen.findByText('onboarding.create_project.gitlab.title')).toBeInTheDocument();
expect(await ui.importProjectsTitle.find()).toBeInTheDocument();

await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);

@@ -162,13 +160,83 @@ it('should show search filter when PAT is already set', async () => {
});
});

it('should import several projects', async () => {
const user = userEvent.setup();

almIntegrationHandler.setGitlabProjects([
mockGitlabProject({ id: '1', name: 'Gitlab project 1' }),
mockGitlabProject({ id: '2', name: 'Gitlab project 2' }),
mockGitlabProject({ id: '3', name: 'Gitlab project 3' }),
]);

renderCreateProject();

expect(await ui.importProjectsTitle.find()).toBeInTheDocument();
await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);

expect(await ui.project1.find()).toBeInTheDocument();
expect(ui.project1Checkbox.get()).not.toBeChecked();
expect(ui.project2Checkbox.get()).not.toBeChecked();
expect(ui.project3Checkbox.get()).not.toBeChecked();
expect(ui.checkAll.get()).not.toBeChecked();
expect(ui.importButton.query()).not.toBeInTheDocument();

await user.click(ui.project1Checkbox.get());

expect(ui.project1Checkbox.get()).toBeChecked();
expect(ui.project2Checkbox.get()).not.toBeChecked();
expect(ui.project3Checkbox.get()).not.toBeChecked();
expect(ui.checkAll.get()).not.toBeChecked();
expect(ui.importButton.get()).toBeInTheDocument();

await user.click(ui.checkAll.get());

expect(ui.project1Checkbox.get()).toBeChecked();
expect(ui.project2Checkbox.get()).toBeChecked();
expect(ui.project3Checkbox.get()).toBeChecked();
expect(ui.checkAll.get()).toBeChecked();
expect(ui.importButton.get()).toBeInTheDocument();

await user.click(ui.checkAll.get());

expect(ui.project1Checkbox.get()).not.toBeChecked();
expect(ui.project2Checkbox.get()).not.toBeChecked();
expect(ui.project3Checkbox.get()).not.toBeChecked();
expect(ui.checkAll.get()).not.toBeChecked();
expect(ui.importButton.query()).not.toBeInTheDocument();

await user.click(ui.project1Checkbox.get());
await user.click(ui.project2Checkbox.get());

expect(ui.importButton.get()).toBeInTheDocument();
await user.click(ui.importButton.get());

expect(await ui.newCodeMultipleProjectTitle.find()).toBeInTheDocument();
expect(ui.changePeriodLaterInfo.get()).toBeInTheDocument();
expect(ui.createProjectsButton.get()).toBeDisabled();

await user.click(ui.backButton.get());
expect(ui.project1Checkbox.get()).toBeChecked();
expect(ui.project2Checkbox.get()).toBeChecked();
expect(ui.project3Checkbox.get()).not.toBeChecked();
expect(ui.importButton.get()).toBeInTheDocument();
await user.click(ui.importButton.get());

expect(await ui.newCodeMultipleProjectTitle.find()).toBeInTheDocument();

await user.click(ui.globalSettingRadio.get());
expect(ui.createProjectsButton.get()).toBeEnabled();
await user.click(ui.createProjectsButton.get());

expect(await screen.findByText('/projects?sort=-creation_date')).toBeInTheDocument();
});

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

expect(await screen.findByText('onboarding.create_project.gitlab.title')).toBeInTheDocument();
await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);
await selectEvent.select(await ui.instanceSelector.find(), [/conf-final-2/]);
const loadMore = await screen.findByRole('button', { name: 'show_more' });
expect(loadMore).toBeInTheDocument();

@@ -191,12 +259,10 @@ it('should show no result message when there are no projects', async () => {
almIntegrationHandler.setGitlabProjects([]);
renderCreateProject();

expect(await screen.findByText('onboarding.create_project.gitlab.title')).toBeInTheDocument();
expect(await ui.importProjectsTitle.find()).toBeInTheDocument();
await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);

expect(
await screen.findByText('onboarding.create_project.gitlab.no_projects'),
).toBeInTheDocument();
expect(await screen.findByText('no_results')).toBeInTheDocument();
});

describe('GitLab monorepo project navigation', () => {

+ 192
- 0
server/sonar-web/src/main/js/apps/create/project/components/RepositoryList.tsx Zobrazit soubor

@@ -0,0 +1,192 @@
/*
* 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 styled from '@emotion/styled';
import { Checkbox } from '@sonarsource/echoes-react';
import { ButtonPrimary, InputSearch, LightPrimary, themeBorder, themeColor } from 'design-system';
import React, { useCallback, useMemo } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import ListFooter from '../../../../components/controls/ListFooter';
import { getBaseUrl } from '../../../../helpers/system';
import { GithubRepository, GitlabProject } from '../../../../types/alm-integration';
import { AlmKeys } from '../../../../types/alm-settings';
import { Paging } from '../../../../types/types';
import AlmRepoItem from '../components/AlmRepoItem';

interface RepositoryListProps {
loadingRepositories: boolean;
repositories?: GithubRepository[] | GitlabProject[];
repositoryPaging: Paging;
searchQuery: string;
onLoadMore: () => void;
onSearch: (query: string) => void;
almKey: AlmKeys.GitHub | AlmKeys.GitLab;
selected: Set<string>;
checkAll: () => void;
uncheckAll: () => void;
onCheck: (key: string) => void;
onImport: () => void;
}

export default function RepositoryList(props: Readonly<RepositoryListProps>) {
const {
almKey,
checkAll,
loadingRepositories,
onCheck,
onImport,
onLoadMore,
onSearch,
repositories,
repositoryPaging,
searchQuery,
selected,
uncheckAll,
} = props;

const { formatMessage } = useIntl();

const areAllRepositoriesChecked = useMemo(() => {
const nonImportedRepos = repositories?.filter((r) => r.sqProjectKey === undefined) ?? [];
return nonImportedRepos.length > 0 && selected.size === nonImportedRepos.length;
}, [repositories, selected.size]);

const onCheckAllRepositories = useCallback(() => {
if (areAllRepositoriesChecked) {
uncheckAll();
} else {
checkAll();
}
}, [areAllRepositoriesChecked, checkAll, uncheckAll]);

if (!repositories) {
return null;
}

return (
<div className="sw-flex sw-gap-12">
<LargeColumn>
<div className="sw-mb-2 sw-py-2 sw-flex sw-items-center sw-justify-between sw-w-full">
<div>
<Checkbox
checked={areAllRepositoriesChecked}
className="sw-ml-5"
isDisabled={repositories.length === 0}
label={formatMessage({ id: 'onboarding.create_project.select_all_repositories' })}
onCheck={onCheckAllRepositories}
/>
</div>
<InputSearch
size="medium"
loading={loadingRepositories}
onChange={onSearch}
placeholder={formatMessage({ id: 'onboarding.create_project.search_repositories' })}
value={searchQuery}
/>
</div>

{repositories.length === 0 ? (
<div className="sw-py-6 sw-px-2">
<LightPrimary className="sw-body-sm">
{formatMessage({ id: 'no_results' })}
</LightPrimary>
</div>
) : (
<ul className="sw-flex sw-flex-col sw-gap-3">
{repositories.map(({ id, name, sqProjectKey, url, ...repo }) => (
<AlmRepoItem
key={id}
almKey={almKey === AlmKeys.GitHub ? (repo as GithubRepository).key : id}
almUrl={url}
almUrlText={formatMessage(
{ id: 'onboarding.create_project.see_on' },
{ almName: formatMessage({ id: `alm.${almKey}` }) },
)}
almIconSrc={`${getBaseUrl()}/images/alm/${almKey}.svg`}
sqProjectKey={sqProjectKey}
multiple
selected={selected.has(
almKey === AlmKeys.GitHub ? (repo as GithubRepository).key : id,
)}
onCheck={(key: string) => onCheck(key)}
primaryTextNode={<span title={name}>{name}</span>}
/>
))}
</ul>
)}

<ListFooter
className="sw-mb-10"
count={repositories.length}
total={repositoryPaging.total}
loadMore={onLoadMore}
loading={loadingRepositories}
/>
</LargeColumn>
<SideColumn>
{selected.size > 0 && (
<SetupBox className="sw-rounded-2 sw-p-8 sw-mb-0">
<SetupBoxTitle className="sw-mb-2 sw-heading-md">
<FormattedMessage
id="onboarding.create_project.x_repositories_selected"
values={{ count: selected.size }}
/>
</SetupBoxTitle>
<div>
<SetupBoxContent className="sw-pb-4">
<FormattedMessage
id="onboarding.create_project.x_repository_created"
values={{ count: selected.size }}
/>
</SetupBoxContent>
<div className="sw-mt-4">
<ButtonPrimary onClick={onImport} className="js-set-up-projects">
{formatMessage({ id: 'onboarding.create_project.import' })}
</ButtonPrimary>
</div>
</div>
</SetupBox>
)}
</SideColumn>
</div>
);
}

const LargeColumn = styled.div`
flex: 6;
`;

const SideColumn = styled.div`
flex: 4;
`;

const SetupBox = styled.form`
max-height: 280px;
background: ${themeColor('highlightedSection')};
border: ${themeBorder('default', 'highlightedSectionBorder')};
`;

const SetupBoxTitle = styled.h2`
color: ${themeColor('pageTitle')};
`;

const SetupBoxContent = styled.div`
border-bottom: ${themeBorder('default')};
`;

+ 1
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties Zobrazit soubor

@@ -4392,7 +4392,7 @@ onboarding.create_project.no_bbs_repos=No repositories were found for this proje
onboarding.create_project.update_your_token=update your personal access token
onboarding.create_project.no_bbs_repos.filter=No repositories match your filter.
onboarding.create_project.only_showing_X_first_repos=We're only displaying the first {0} repositories. If you're looking for a repository that's not in this list, use the search above.
onboarding.create_project.see_on_github=See on GitHub
onboarding.create_project.see_on=See on {almName}

onboarding.create_project.search_prompt=Search for projects
onboarding.create_project.set_up=Set up

Načítá se…
Zrušit
Uložit