aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/apps
diff options
context:
space:
mode:
authorAmbroise C <ambroise.christea@sonarsource.com>2024-04-22 16:08:30 +0200
committerMatteo Mara <matteo.mara@sonarsource.com>2024-04-30 10:59:02 +0200
commit454eb12ce700b10bfe06613c43eaeaf70147c047 (patch)
tree28acbbef18bee18c083fbc7aa35da3a3a117f750 /server/sonar-web/src/main/js/apps
parent5f4da2629817db94f36bc72cb72d9bd7fdf84f95 (diff)
downloadsonarqube-454eb12ce700b10bfe06613c43eaeaf70147c047.tar.gz
sonarqube-454eb12ce700b10bfe06613c43eaeaf70147c047.zip
SONAR-21947 Add bulk import feature to Gitlab project onboarding
Diffstat (limited to 'server/sonar-web/src/main/js/apps')
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx301
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx11
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx64
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx170
-rw-r--r--server/sonar-web/src/main/js/apps/create/project/components/RepositoryList.tsx192
6 files changed, 437 insertions, 307 deletions
diff --git a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
index 12ad79d1b16..66633884791 100644
--- a/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
@@ -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')};
-`;
diff --git a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
index 56af9258e91..c880cf2a5df 100644
--- a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreate.tsx
@@ -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 && {
diff --git a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx
index 4b9ee7fec90..a9419c4c131 100644
--- a/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx
@@ -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}
/>
))}
</>
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx
index e34500da034..b01f72702bf 100644
--- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx
@@ -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');
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
index 847f2656bc6..0752bbef94e 100644
--- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
+++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { 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', () => {
diff --git a/server/sonar-web/src/main/js/apps/create/project/components/RepositoryList.tsx b/server/sonar-web/src/main/js/apps/create/project/components/RepositoryList.tsx
new file mode 100644
index 00000000000..351b08ffa4b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/create/project/components/RepositoryList.tsx
@@ -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')};
+`;