diff options
author | Ambroise C <ambroise.christea@sonarsource.com> | 2024-04-22 16:08:30 +0200 |
---|---|---|
committer | Matteo Mara <matteo.mara@sonarsource.com> | 2024-04-30 10:59:02 +0200 |
commit | 454eb12ce700b10bfe06613c43eaeaf70147c047 (patch) | |
tree | 28acbbef18bee18c083fbc7aa35da3a3a117f750 /server/sonar-web/src/main/js/apps | |
parent | 5f4da2629817db94f36bc72cb72d9bd7fdf84f95 (diff) | |
download | sonarqube-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')
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')}; +`; |