From 454eb12ce700b10bfe06613c43eaeaf70147c047 Mon Sep 17 00:00:00 2001 From: Ambroise C Date: Mon, 22 Apr 2024 16:08:30 +0200 Subject: [PATCH] SONAR-21947 Add bulk import feature to Gitlab project onboarding --- .../Github/GitHubProjectCreateRenderer.tsx | 301 +++++------------- .../project/Gitlab/GitlabProjectCreate.tsx | 11 +- .../Gitlab/GitlabProjectCreateRenderer.tsx | 64 +++- .../create/project/__tests__/GitHub-it.tsx | 6 +- .../create/project/__tests__/GitLab-it.tsx | 170 +++++++--- .../project/components/RepositoryList.tsx | 192 +++++++++++ .../resources/org/sonar/l10n/core.properties | 2 +- 7 files changed, 438 insertions(+), 308 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/create/project/components/RepositoryList.tsx 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; - 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 ( -
-
-
- - - {translate('onboarding.create_project.select_all_repositories')} - - -
- -
- - {repositories.length === 0 ? ( -
- {translate('no_results')} -
- ) : ( -
    - {repositories.map(({ key, url, sqProjectKey, name }) => ( - props.onCheck(key)} - primaryTextNode={{name}} - /> - ))} -
- )} - - -
- ); -} - -export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) { +export default function GitHubProjectCreateRenderer( + props: Readonly, +) { const isMonorepoSupported = useContext(AvailableFeaturesContext).includes( Feature.MonoRepositoryPullRequestDecoration, ); @@ -193,24 +79,36 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe } = props; const [selected, setSelected] = useState>(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 ; } - 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 )} -
- - - {!error && ( -
- - {translate('onboarding.create_project.github.choose_organization')} - - {organizations.length > 0 ? ( - - props.onSelectOrganization(value) - } - value={selectedOrganization ? orgToOption(selectedOrganization) : null} - /> - ) : ( - !loadingOrganizations && ( - - - {canAdmin ? ( - - {translate( - 'onboarding.create_project.github.warning.message_admin.link', - )} - - ), - }} - /> - ) : ( - translate('onboarding.create_project.github.no_orgs') - )} - - - ) - )} -
+ + {!error && ( +
+ + {translate('onboarding.create_project.github.choose_organization')} + + {organizations.length > 0 ? ( + props.onSelectOrganization(value)} + value={selectedOrganization ? orgToOption(selectedOrganization) : null} + /> + ) : ( + !loadingOrganizations && ( + + + {canAdmin ? ( + + {translate( + 'onboarding.create_project.github.warning.message_admin.link', + )} + + ), + }} + /> + ) : ( + translate('onboarding.create_project.github.no_orgs') + )} + + + ) )} - +
+ )} + {selectedOrganization && ( -
- - {selected.size > 0 && ( - - - - -
- - - -
- - {translate('onboarding.create_project.import')} - -
-
-
- )} -
-
+ )} +
); } - -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) { const repositorySearchDebounceId = useRef(); const [isLoadingRepositories, setIsLoadingRepositories] = useState(false); - const [isLoadingMoreRepositories, setIsLoadingMoreRepositories] = useState(false); const [repositories, setRepositories] = useState([]); const [repositoryPaging, setRepositoryPaging] = useState({ pageSize: REPOSITORY_PAGE_SIZE, @@ -131,13 +130,13 @@ export default function GitlabProjectCreate(props: Readonly) { }, [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) { ); 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) { }))} canAdmin={canAdmin} loading={isLoadingRepositories || isLoadingBindings} - loadingMore={isLoadingMoreRepositories} onImport={handleImportRepository} onLoadMore={handleLoadMore} onPersonalAccessTokenCreated={handlePersonalAccessTokenCreated} @@ -252,7 +248,6 @@ export default function GitlabProjectCreate(props: Readonly) { 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, ) { - 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>(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 ( <>
@@ -126,15 +152,19 @@ export default function GitlabProjectCreateRenderer( onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated} /> ) : ( - ))} 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; + checkAll: () => void; + uncheckAll: () => void; + onCheck: (key: string) => void; + onImport: () => void; +} + +export default function RepositoryList(props: Readonly) { + 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 ( +
+ +
+
+ +
+ +
+ + {repositories.length === 0 ? ( +
+ + {formatMessage({ id: 'no_results' })} + +
+ ) : ( +
    + {repositories.map(({ id, name, sqProjectKey, url, ...repo }) => ( + onCheck(key)} + primaryTextNode={{name}} + /> + ))} +
+ )} + + +
+ + {selected.size > 0 && ( + + + + +
+ + + +
+ + {formatMessage({ id: 'onboarding.create_project.import' })} + +
+
+
+ )} +
+
+ ); +} + +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/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index a09e67795bc..b1c9940cd62 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 -- 2.39.5