diff options
author | Ambroise C <ambroise.christea@sonarsource.com> | 2024-04-08 16:46:47 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-04-10 20:02:55 +0000 |
commit | b9a92dbaaaae23d7d94c8a201d23c932a7548aef (patch) | |
tree | 343dfc644c6321cd276afcabe8857cdaac445f1e /server | |
parent | 6b5fba4f0bdedee141b45bd9eb780a34b809b0b0 (diff) | |
download | sonarqube-b9a92dbaaaae23d7d94c8a201d23c932a7548aef.tar.gz sonarqube-b9a92dbaaaae23d7d94c8a201d23c932a7548aef.zip |
SONAR-21822 Check if selected repository is already bound to an existing project
Diffstat (limited to 'server')
-rw-r--r-- | server/sonar-web/src/main/js/api/dop-translation.ts | 16 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts | 83 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts | 3 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts | 14 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx (renamed from server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx) | 60 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx | 105 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/queries/dop-translation.ts | 38 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/types/dop-translation.ts | 9 |
8 files changed, 268 insertions, 60 deletions
diff --git a/server/sonar-web/src/main/js/api/dop-translation.ts b/server/sonar-web/src/main/js/api/dop-translation.ts index 9cc4e482739..fdceb34f90c 100644 --- a/server/sonar-web/src/main/js/api/dop-translation.ts +++ b/server/sonar-web/src/main/js/api/dop-translation.ts @@ -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, + }); } diff --git a/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts index 1393c846867..9852c49c535 100644 --- a/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/DopTranslationServiceMock.ts @@ -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)); + } } diff --git a/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts index e004e7a1a6e..57955a41fda 100644 --- a/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/ProjectsManagementServiceMock.ts @@ -119,6 +119,9 @@ export default class ProjectManagementServiceMock { ) { return false; } + if (params.projects !== undefined && !params.projects.split(',').includes(item.key)) { + return false; + } return true; }); diff --git a/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts b/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts index bc8a5d9a991..fbb5fa88df3 100644 --- a/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts +++ b/server/sonar-web/src/main/js/api/mocks/data/dop-translation.ts @@ -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, + }; +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx index 84cd593471a..bdd41870f99 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubMonorepoProjectCreate-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx @@ -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 }); diff --git a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx index 25a7399a0b7..4ec9b5e0470 100644 --- a/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.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 { 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> diff --git a/server/sonar-web/src/main/js/queries/dop-translation.ts b/server/sonar-web/src/main/js/queries/dop-translation.ts new file mode 100644 index 00000000000..c0de50fc031 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/dop-translation.ts @@ -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), + }); +} diff --git a/server/sonar-web/src/main/js/types/dop-translation.ts b/server/sonar-web/src/main/js/types/dop-translation.ts index 916752b4823..5d6a6baf982 100644 --- a/server/sonar-web/src/main/js/types/dop-translation.ts +++ b/server/sonar-web/src/main/js/types/dop-translation.ts @@ -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; +} |