@@ -18,17 +18,29 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import axios from 'axios'; | |||
import { BoundProject, DopSetting } from '../types/dop-translation'; | |||
import { BoundProject, DopSetting, ProjectBinding } from '../types/dop-translation'; | |||
import { Paging } from '../types/types'; | |||
const DOP_TRANSLATION_PATH = '/api/v2/dop-translation'; | |||
const BOUND_PROJECTS_PATH = `${DOP_TRANSLATION_PATH}/bound-projects`; | |||
const DOP_SETTINGS_PATH = `${DOP_TRANSLATION_PATH}/dop-settings`; | |||
const PROJECT_BINDINGS_PATH = `${DOP_TRANSLATION_PATH}/project-bindings`; | |||
export function createBoundProject(data: BoundProject) { | |||
return axios.post(BOUND_PROJECTS_PATH, data); | |||
} | |||
export function getDopSettings() { | |||
return axios.get<{ paging: Paging; dopSettings: DopSetting[] }>(DOP_SETTINGS_PATH); | |||
return axios.get<{ dopSettings: DopSetting[]; page: Paging }>(DOP_SETTINGS_PATH); | |||
} | |||
export function getProjectBindings(data: { | |||
dopSettingId?: string; | |||
pageIndex?: number; | |||
pageSize?: number; | |||
repository?: string; | |||
}) { | |||
return axios.get<{ page: Paging; projectBindings: ProjectBinding[] }>(PROJECT_BINDINGS_PATH, { | |||
params: data, | |||
}); | |||
} |
@@ -20,9 +20,9 @@ | |||
import { cloneDeep } from 'lodash'; | |||
import { mockPaging } from '../../helpers/testMocks'; | |||
import { AlmKeys } from '../../types/alm-settings'; | |||
import { BoundProject, DopSetting } from '../../types/dop-translation'; | |||
import { createBoundProject, getDopSettings } from '../dop-translation'; | |||
import { mockDopSetting } from './data/dop-translation'; | |||
import { DopSetting, ProjectBinding } from '../../types/dop-translation'; | |||
import { createBoundProject, getDopSettings, getProjectBindings } from '../dop-translation'; | |||
import { mockDopSetting, mockProjectBinding } from './data/dop-translation'; | |||
jest.mock('../dop-translation'); | |||
@@ -57,48 +57,37 @@ const defaultDopSettings = [ | |||
mockDopSetting(), | |||
mockDopSetting({ id: 'dop-setting-test-id-2', key: 'Test/DopSetting2' }), | |||
]; | |||
const defaultProjectBindings = [ | |||
mockProjectBinding({ | |||
dopSetting: 'conf-github-1', | |||
id: 'project-binding-1', | |||
projectId: 'key123', | |||
projectKey: 'key123', | |||
repository: 'Github repo 1', | |||
slug: 'Slug/Repository-1', | |||
}), | |||
]; | |||
export default class DopTranslationServiceMock { | |||
boundProjects: BoundProject[] = []; | |||
dopSettings: DopSetting[] = [ | |||
mockDopSetting({ key: 'conf-final-1', type: AlmKeys.GitLab }), | |||
mockDopSetting({ key: 'conf-final-2', type: AlmKeys.GitLab }), | |||
mockDopSetting({ key: 'conf-github-1', type: AlmKeys.GitHub, url: 'http://url' }), | |||
mockDopSetting({ key: 'conf-github-2', type: AlmKeys.GitHub, url: 'http://url' }), | |||
mockDopSetting({ key: 'conf-github-3', type: AlmKeys.GitHub, url: 'javascript://url' }), | |||
mockDopSetting({ key: 'conf-azure-1', type: AlmKeys.Azure, url: 'url' }), | |||
mockDopSetting({ key: 'conf-azure-2', type: AlmKeys.Azure, url: 'url' }), | |||
mockDopSetting({ | |||
key: 'conf-bitbucketcloud-1', | |||
type: AlmKeys.BitbucketCloud, | |||
url: 'url', | |||
}), | |||
mockDopSetting({ | |||
key: 'conf-bitbucketcloud-2', | |||
type: AlmKeys.BitbucketCloud, | |||
url: 'url', | |||
}), | |||
mockDopSetting({ | |||
key: 'conf-bitbucketserver-1', | |||
type: AlmKeys.BitbucketServer, | |||
url: 'url', | |||
}), | |||
mockDopSetting({ | |||
key: 'conf-bitbucketserver-2', | |||
type: AlmKeys.BitbucketServer, | |||
url: 'url', | |||
}), | |||
mockDopSetting(), | |||
mockDopSetting({ id: 'dop-setting-test-id-2', key: 'Test/DopSetting2' }), | |||
]; | |||
projectBindings: ProjectBinding[] = []; | |||
dopSettings: DopSetting[] = []; | |||
constructor() { | |||
this.reset(); | |||
jest.mocked(createBoundProject).mockImplementation(this.createBoundProject); | |||
jest.mocked(getDopSettings).mockImplementation(this.getDopSettings); | |||
jest.mocked(getProjectBindings).mockImplementation(this.getProjectBindings); | |||
} | |||
createBoundProject: typeof createBoundProject = (data) => { | |||
this.boundProjects.push(data); | |||
this.projectBindings.push( | |||
mockProjectBinding({ | |||
dopSetting: data.devOpsPlatformSettingId, | |||
id: `${data.devOpsPlatformSettingId}-${data.repositoryIdentifier}-${data.projectKey}`, | |||
projectId: data.projectKey, | |||
repository: data.repositoryIdentifier, | |||
}), | |||
); | |||
return Promise.resolve({}); | |||
}; | |||
@@ -106,7 +95,21 @@ export default class DopTranslationServiceMock { | |||
const total = this.getDopSettings.length; | |||
return Promise.resolve({ | |||
dopSettings: this.dopSettings, | |||
paging: mockPaging({ pageSize: total, total }), | |||
page: mockPaging({ pageSize: total, total }), | |||
}); | |||
}; | |||
getProjectBindings: typeof getProjectBindings = (params) => { | |||
const pageIndex = params.pageIndex ?? 1; | |||
const pageSize = params.pageSize ?? 50; | |||
return this.reply({ | |||
page: { | |||
pageIndex, | |||
pageSize, | |||
total: this.projectBindings.length, | |||
}, | |||
projectBindings: this.projectBindings.slice((pageIndex - 1) * pageSize, pageIndex * pageSize), | |||
}); | |||
}; | |||
@@ -117,7 +120,11 @@ export default class DopTranslationServiceMock { | |||
}; | |||
reset() { | |||
this.boundProjects = []; | |||
this.projectBindings = cloneDeep(defaultProjectBindings); | |||
this.dopSettings = cloneDeep(defaultDopSettings); | |||
} | |||
reply<T>(response: T): Promise<T> { | |||
return Promise.resolve(cloneDeep(response)); | |||
} | |||
} |
@@ -119,6 +119,9 @@ export default class ProjectManagementServiceMock { | |||
) { | |||
return false; | |||
} | |||
if (params.projects !== undefined && !params.projects.split(',').includes(item.key)) { | |||
return false; | |||
} | |||
return true; | |||
}); | |||
@@ -21,7 +21,7 @@ | |||
/* eslint-disable local-rules/use-metrickey-enum */ | |||
import { AlmKeys } from '../../../types/alm-settings'; | |||
import { DopSetting } from '../../../types/dop-translation'; | |||
import { DopSetting, ProjectBinding } from '../../../types/dop-translation'; | |||
export function mockDopSetting(overrides?: Partial<DopSetting>): DopSetting { | |||
return { | |||
@@ -32,3 +32,15 @@ export function mockDopSetting(overrides?: Partial<DopSetting>): DopSetting { | |||
...overrides, | |||
}; | |||
} | |||
export function mockProjectBinding(overrides?: Partial<ProjectBinding>): ProjectBinding { | |||
return { | |||
dopSetting: 'dop-setting-test-id', | |||
id: 'project-binding-test-id', | |||
projectId: 'project-id', | |||
projectKey: 'project-key', | |||
repository: 'repository', | |||
slug: 'Slug/Project', | |||
...overrides, | |||
}; | |||
} |
@@ -26,8 +26,11 @@ import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock | |||
import ComponentsServiceMock from '../../../../api/mocks/ComponentsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import ProjectManagementServiceMock from '../../../../api/mocks/ProjectsManagementServiceMock'; | |||
import SettingsServiceMock from '../../../../api/mocks/SettingsServiceMock'; | |||
import { mockProject } from '../../../../helpers/mocks/projects'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
import { byRole } from '../../../../helpers/testSelector'; | |||
import { byRole, byText } from '../../../../helpers/testSelector'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { Feature } from '../../../../types/features'; | |||
import CreateProjectPage from '../CreateProjectPage'; | |||
@@ -41,6 +44,8 @@ let almSettingsHandler: AlmSettingsServiceMock; | |||
let componentsHandler: ComponentsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
let projectManagementHandler: ProjectManagementServiceMock; | |||
let settingsHandler: SettingsServiceMock; | |||
const ui = { | |||
addButton: byRole('button', { name: 'onboarding.create_project.monorepo.add_project' }), | |||
@@ -61,6 +66,12 @@ const ui = { | |||
repositorySelector: byRole('combobox', { | |||
name: `onboarding.create_project.monorepo.choose_repository.${AlmKeys.GitHub}`, | |||
}), | |||
notBoundRepositoryMessage: byText( | |||
'onboarding.create_project.monorepo.choose_repository.no_already_bound_projects', | |||
), | |||
alreadyBoundRepositoryMessage: byText( | |||
/onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects/, | |||
), | |||
submitButton: byRole('button', { name: 'next' }), | |||
}; | |||
@@ -74,6 +85,8 @@ beforeAll(() => { | |||
componentsHandler = new ComponentsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
settingsHandler = new SettingsServiceMock(); | |||
projectManagementHandler = new ProjectManagementServiceMock(settingsHandler); | |||
}); | |||
beforeEach(() => { | |||
@@ -83,6 +96,8 @@ beforeEach(() => { | |||
componentsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
projectManagementHandler.reset(); | |||
settingsHandler.reset(); | |||
}); | |||
describe('github monorepo project setup', () => { | |||
@@ -106,6 +121,49 @@ describe('github monorepo project setup', () => { | |||
expect(ui.gitHubOnboardingTitle.get()).toBeInTheDocument(); | |||
}); | |||
it('should display that selected repository is not bound to any existing project', async () => { | |||
renderCreateProject({ code: '123', dopSetting: 'dop-setting-test-id', isMonorepo: true }); | |||
expect(await ui.monorepoTitle.find()).toBeInTheDocument(); | |||
expect(await ui.dopSettingSelector.find()).toBeInTheDocument(); | |||
expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument(); | |||
await waitFor(async () => { | |||
await selectEvent.select(await ui.organizationSelector.find(), 'org-1'); | |||
}); | |||
expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument(); | |||
await selectEvent.select(await ui.repositorySelector.find(), 'Github repo 1'); | |||
expect(await ui.notBoundRepositoryMessage.find()).toBeInTheDocument(); | |||
}); | |||
it('should display that selected repository is already bound to an existing project', async () => { | |||
projectManagementHandler.setProjects([ | |||
mockProject({ | |||
key: 'key123', | |||
name: 'Project GitHub 1', | |||
}), | |||
]); | |||
renderCreateProject({ code: '123', dopSetting: 'dop-setting-test-id', isMonorepo: true }); | |||
expect(await ui.monorepoTitle.find()).toBeInTheDocument(); | |||
expect(await ui.dopSettingSelector.find()).toBeInTheDocument(); | |||
expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument(); | |||
await waitFor(async () => { | |||
await selectEvent.select(await ui.organizationSelector.find(), 'org-1'); | |||
}); | |||
expect(ui.monorepoProjectTitle.query()).not.toBeInTheDocument(); | |||
await selectEvent.select(await ui.repositorySelector.find(), 'Github repo 1'); | |||
expect(await ui.alreadyBoundRepositoryMessage.find()).toBeInTheDocument(); | |||
expect(byRole('link', { name: 'Project GitHub 1' }).get()).toBeInTheDocument(); | |||
}); | |||
it('should be able to set a monorepo project', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject({ code: '123', dopSetting: 'dop-setting-test-id', isMonorepo: true }); |
@@ -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 { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { Link, LinkHighlight, LinkStandalone, Spinner } from '@sonarsource/echoes-react'; | |||
import { | |||
AddNewIcon, | |||
BlueGreySeparator, | |||
@@ -31,9 +31,13 @@ import { | |||
} from 'design-system'; | |||
import React, { useEffect, useRef } from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { getComponents } from '../../../../api/project-management'; | |||
import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; | |||
import { throwGlobalError } from '../../../../helpers/error'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { LabelValueSelectOption } from '../../../../helpers/search'; | |||
import { getProjectUrl } from '../../../../helpers/urls'; | |||
import { useProjectBindingsQuery } from '../../../../queries/dop-translation'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
import { ImportProjectParam } from '../CreateProjectPage'; | |||
@@ -89,12 +93,26 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre | |||
const projectCounter = useRef(0); | |||
const [projects, setProjects] = React.useState<ProjectItem[]>([]); | |||
const [alreadyBoundProjects, setAlreadyBoundProjects] = React.useState< | |||
Array<{ projectId: string; projectName: string }> | |||
>([]); | |||
const location = useLocation(); | |||
const { push } = useRouter(); | |||
const { formatMessage } = useIntl(); | |||
const projectKeys = React.useMemo(() => projects.map(({ key }) => key), [projects]); | |||
const { | |||
data: alreadyBoundProjectBindings, | |||
isFetching: isFetchingAlreadyBoundProjects, | |||
isLoading: isLoadingAlreadyBoundProjects, | |||
} = useProjectBindingsQuery( | |||
{ | |||
dopSettingId: selectedDopSetting?.id, | |||
repository: selectedRepository?.value, | |||
}, | |||
selectedRepository !== undefined, | |||
); | |||
const almKey = location.query.mode as AlmKeys; | |||
@@ -181,6 +199,30 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [selectedRepository]); | |||
useEffect(() => { | |||
if (alreadyBoundProjectBindings === undefined) { | |||
return; | |||
} | |||
if (alreadyBoundProjectBindings.projectBindings.length === 0) { | |||
setAlreadyBoundProjects([]); | |||
return; | |||
} | |||
getComponents({ | |||
projects: alreadyBoundProjectBindings.projectBindings.reduce( | |||
(projectsSearchParam, { projectKey }) => `${projectsSearchParam},${projectKey}`, | |||
'', | |||
), | |||
}) | |||
.then(({ components }) => { | |||
setAlreadyBoundProjects( | |||
components.map(({ key, name }) => ({ projectId: key, projectName: name })), | |||
); | |||
}) | |||
.catch(throwGlobalError); | |||
}, [alreadyBoundProjectBindings]); | |||
if (loadingBindings) { | |||
return <Spinner />; | |||
} | |||
@@ -293,23 +335,50 @@ export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCre | |||
</DarkLabel> | |||
)} | |||
{selectedOrganization && ( | |||
<InputSelect | |||
inputId="monorepo-choose-repository" | |||
inputValue={repositorySearchQuery} | |||
isLoading={loadingRepositories} | |||
isSearchable | |||
noOptionsMessage={() => formatMessage({ id: 'no_results' })} | |||
onChange={({ value }: LabelValueSelectOption) => { | |||
onSelectRepository(value); | |||
}} | |||
onInputChange={onSearchRepositories} | |||
options={repositoryOptions} | |||
placeholder={formatMessage({ | |||
id: `onboarding.create_project.monorepo.choose_repository.${almKey}.placeholder`, | |||
})} | |||
size="full" | |||
value={selectedRepository} | |||
/> | |||
<> | |||
<InputSelect | |||
inputId="monorepo-choose-repository" | |||
inputValue={repositorySearchQuery} | |||
isLoading={loadingRepositories} | |||
isSearchable | |||
noOptionsMessage={() => formatMessage({ id: 'no_results' })} | |||
onChange={({ value }: LabelValueSelectOption) => { | |||
onSelectRepository(value); | |||
}} | |||
onInputChange={onSearchRepositories} | |||
options={repositoryOptions} | |||
placeholder={formatMessage({ | |||
id: `onboarding.create_project.monorepo.choose_repository.${almKey}.placeholder`, | |||
})} | |||
size="full" | |||
value={selectedRepository} | |||
/> | |||
{selectedRepository && | |||
!isLoadingAlreadyBoundProjects && | |||
!isFetchingAlreadyBoundProjects && ( | |||
<FlagMessage className="sw-mt-2" variant="info"> | |||
{alreadyBoundProjects.length === 0 ? ( | |||
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.no_already_bound_projects" /> | |||
) : ( | |||
<div> | |||
<FormattedMessage id="onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects" /> | |||
<ul className="sw-mt-4"> | |||
{alreadyBoundProjects.map(({ projectId, projectName }) => ( | |||
<li key={projectId}> | |||
<LinkStandalone | |||
to={getProjectUrl(projectId)} | |||
highlight={LinkHighlight.Subdued} | |||
> | |||
{projectName} | |||
</LinkStandalone> | |||
</li> | |||
))} | |||
</ul> | |||
</div> | |||
)} | |||
</FlagMessage> | |||
)} | |||
</> | |||
)} | |||
</div> | |||
</div> |
@@ -0,0 +1,38 @@ | |||
/* | |||
* 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 { useQuery } from '@tanstack/react-query'; | |||
import { getProjectBindings } from '../api/dop-translation'; | |||
export function useProjectBindingsQuery( | |||
data: { | |||
dopSettingId?: string; | |||
pageIndex?: number; | |||
pageSize?: number; | |||
repository?: string; | |||
}, | |||
enabled = true, | |||
) { | |||
return useQuery({ | |||
enabled, | |||
queryKey: ['dop-translation', 'project-bindings', data], | |||
queryFn: () => getProjectBindings(data), | |||
}); | |||
} |
@@ -37,3 +37,12 @@ export interface BoundProject { | |||
projectName: string; | |||
repositoryIdentifier: string; | |||
} | |||
export interface ProjectBinding { | |||
dopSetting: string; | |||
id: string; | |||
projectId: string; | |||
projectKey: string; | |||
repository: string; | |||
slug: string; | |||
} |
@@ -4439,6 +4439,8 @@ onboarding.create_project.monorepo.choose_organization.github=Choose the organiz | |||
onboarding.create_project.monorepo.choose_organization.github.placeholder=List of organizations | |||
onboarding.create_project.monorepo.choose_repository.github=Choose the repository | |||
onboarding.create_project.monorepo.choose_repository.github.placeholder=List of repositories | |||
onboarding.create_project.monorepo.choose_repository.no_already_bound_projects=This repository has no imported projects in SonarQube | |||
onboarding.create_project.monorepo.choose_repository.existing_already_bound_projects=This repository has already been imported, and it's linked to these projects in SonarQube: | |||
onboarding.create_project.monorepo.project_title=Create new projects | |||
onboarding.create_project.monorepo.add_project=Add new project | |||
onboarding.create_project.monorepo.remove_project=Remove project |