@@ -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')}; | |||
`; |
@@ -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 && { |
@@ -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} | |||
/> | |||
))} | |||
</> |
@@ -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'); |
@@ -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', () => { |
@@ -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')}; | |||
`; |
@@ -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 |