@@ -0,0 +1,37 @@ | |||
/* | |||
* 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 { useTheme } from '@emotion/react'; | |||
import { themeColor } from '../../helpers'; | |||
import { CustomIcon, IconProps } from './Icon'; | |||
export function AddNewIcon({ fill = 'currentColor', ...iconProps }: Readonly<IconProps>) { | |||
const theme = useTheme(); | |||
return ( | |||
<CustomIcon {...iconProps}> | |||
<path | |||
clipRule="evenodd" | |||
d="M8 0c-.55228 0-1 .44771-1 1v6H1c-.55229 0-1 .44771-1 1 0 .55228.44771 1 1 1h6v6c0 .5523.44772 1 1 1 .55229 0 1-.4477 1-1V9h6c.5523 0 1-.44771 1-1 0-.55228-.4477-1-1-1H9V1c0-.55229-.44771-1-1-1Z" | |||
fill={themeColor(fill)({ theme })} | |||
fillRule="evenodd" | |||
/> | |||
</CustomIcon> | |||
); | |||
} |
@@ -17,6 +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. | |||
*/ | |||
export { AddNewIcon } from './AddNewIcon'; | |||
export { BranchIcon } from './BranchIcon'; | |||
export { BugIcon } from './BugIcon'; | |||
export { CalendarIcon } from './CalendarIcon'; |
@@ -18,21 +18,17 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import axios from 'axios'; | |||
import { BoundProject, DopSetting } 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`; | |||
// Imported projects | |||
const IMPORTED_PROJECTS_PATH = `${DOP_TRANSLATION_PATH}/bound-projects`; | |||
export function createBoundProject(data: BoundProject) { | |||
return axios.post(BOUND_PROJECTS_PATH, data); | |||
} | |||
export function createImportedProjects(data: { | |||
devOpsPlatformSettingId: string; | |||
monorepo: boolean; | |||
newCodeDefinitionType?: string; | |||
newCodeDefinitionValue?: string; | |||
projectKey: string; | |||
projectName: string; | |||
repositoryIdentifier: string; | |||
}) { | |||
return axios.post(IMPORTED_PROJECTS_PATH, data); | |||
export function getDopSettings() { | |||
return axios.get<{ paging: Paging; dopSettings: DopSetting[] }>(DOP_SETTINGS_PATH); | |||
} |
@@ -37,6 +37,7 @@ import { | |||
ComponentRaw, | |||
GetTreeParams, | |||
changeKey, | |||
doesComponentExists, | |||
getBreadcrumbs, | |||
getChildren, | |||
getComponentData, | |||
@@ -106,6 +107,7 @@ export default class ComponentsServiceMock { | |||
jest.mocked(setProjectTags).mockImplementation(this.handleSetProjectTags); | |||
jest.mocked(setApplicationTags).mockImplementation(this.handleSetApplicationTags); | |||
jest.mocked(searchProjects).mockImplementation(this.handleSearchProjects); | |||
jest.mocked(doesComponentExists).mockImplementation(this.handleDoesComponentExists); | |||
} | |||
handleSearchProjects: typeof searchProjects = (data) => { | |||
@@ -420,6 +422,11 @@ export default class ComponentsServiceMock { | |||
return this.reply(); | |||
}; | |||
handleDoesComponentExists: typeof doesComponentExists = ({ component }) => { | |||
const exists = this.components.some(({ component: { key } }) => key === component); | |||
return this.reply(exists); | |||
}; | |||
reply<T>(): Promise<void>; | |||
reply<T>(response: T): Promise<T>; | |||
reply<T>(response?: T): Promise<T | void> { |
@@ -0,0 +1,123 @@ | |||
/* | |||
* 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 { 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'; | |||
jest.mock('../dop-translation'); | |||
const defaultDopSettings = [ | |||
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' }), | |||
]; | |||
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' }), | |||
]; | |||
constructor() { | |||
jest.mocked(createBoundProject).mockImplementation(this.createBoundProject); | |||
jest.mocked(getDopSettings).mockImplementation(this.getDopSettings); | |||
} | |||
createBoundProject: typeof createBoundProject = (data) => { | |||
this.boundProjects.push(data); | |||
return Promise.resolve({}); | |||
}; | |||
getDopSettings = () => { | |||
const total = this.getDopSettings.length; | |||
return Promise.resolve({ | |||
dopSettings: this.dopSettings, | |||
paging: mockPaging({ pageSize: total, total }), | |||
}); | |||
}; | |||
removeDopTypeFromSettings = (type: AlmKeys) => { | |||
this.dopSettings = cloneDeep(defaultDopSettings).filter( | |||
(dopSetting) => dopSetting.type !== type, | |||
); | |||
}; | |||
reset() { | |||
this.boundProjects = []; | |||
this.dopSettings = cloneDeep(defaultDopSettings); | |||
} | |||
} |
@@ -0,0 +1,34 @@ | |||
/* | |||
* 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. | |||
*/ | |||
/* eslint-disable local-rules/use-metrickey-enum */ | |||
import { AlmKeys } from '../../../types/alm-settings'; | |||
import { DopSetting } from '../../../types/dop-translation'; | |||
export function mockDopSetting(overrides?: Partial<DopSetting>): DopSetting { | |||
return { | |||
id: overrides?.id ?? overrides?.key ?? 'dop-setting-test-id', | |||
key: 'Test/DopSetting', | |||
type: AlmKeys.GitHub, | |||
url: 'https://github.com', | |||
...overrides, | |||
}; | |||
} |
@@ -213,6 +213,7 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State | |||
this.props.onProjectSetupDone({ | |||
creationMode: CreateProjectModes.AzureDevOps, | |||
almSetting: selectedAlmInstance.key, | |||
monorepo: false, | |||
projects: [ | |||
{ | |||
projectName: selectedRepository.projectName, |
@@ -198,6 +198,7 @@ export default class BitbucketCloudProjectCreate extends React.PureComponent<Pro | |||
this.props.onProjectSetupDone({ | |||
creationMode: CreateProjectModes.BitbucketCloud, | |||
almSetting: selectedAlmInstance.key, | |||
monorepo: false, | |||
projects: [ | |||
{ | |||
repositorySlug, |
@@ -189,6 +189,7 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S | |||
this.props.onProjectSetupDone({ | |||
creationMode: CreateProjectModes.BitbucketServer, | |||
almSetting: selectedAlmInstance.key, | |||
monorepo: false, | |||
projects: [ | |||
{ | |||
projectKey: selectedRepository.projectKey, |
@@ -21,7 +21,7 @@ import classNames from 'classnames'; | |||
import { LargeCenteredLayout } from 'design-system'; | |||
import * as React from 'react'; | |||
import { Helmet } from 'react-helmet-async'; | |||
import { getAlmSettings } from '../../../api/alm-settings'; | |||
import { getDopSettings } from '../../../api/dop-translation'; | |||
import withAppStateContext from '../../../app/components/app-state/withAppStateContext'; | |||
import withAvailableFeatures, { | |||
WithAvailableFeaturesProps, | |||
@@ -31,6 +31,7 @@ import { Location, Router, withRouter } from '../../../components/hoc/withRouter | |||
import { translate } from '../../../helpers/l10n'; | |||
import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; | |||
import { AppState } from '../../../types/appstate'; | |||
import { DopSetting } from '../../../types/dop-translation'; | |||
import { Feature } from '../../../types/features'; | |||
import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm'; | |||
import AzureProjectCreate from './Azure/AzureProjectCreate'; | |||
@@ -53,7 +54,7 @@ interface State { | |||
azureSettings: AlmSettingsInstance[]; | |||
bitbucketSettings: AlmSettingsInstance[]; | |||
bitbucketCloudSettings: AlmSettingsInstance[]; | |||
githubSettings: AlmSettingsInstance[]; | |||
githubSettings: DopSetting[]; | |||
gitlabSettings: AlmSettingsInstance[]; | |||
loading: boolean; | |||
creatingAlmDefinition?: AlmKeys; | |||
@@ -73,6 +74,7 @@ export type ImportProjectParam = | |||
| { | |||
creationMode: CreateProjectModes.AzureDevOps; | |||
almSetting: string; | |||
monorepo: false; | |||
projects: { | |||
projectName: string; | |||
repositoryName: string; | |||
@@ -81,6 +83,7 @@ export type ImportProjectParam = | |||
| { | |||
creationMode: CreateProjectModes.BitbucketCloud; | |||
almSetting: string; | |||
monorepo: false; | |||
projects: { | |||
repositorySlug: string; | |||
}[]; | |||
@@ -88,6 +91,7 @@ export type ImportProjectParam = | |||
| { | |||
creationMode: CreateProjectModes.BitbucketServer; | |||
almSetting: string; | |||
monorepo: false; | |||
projects: { | |||
repositorySlug: string; | |||
projectKey: string; | |||
@@ -96,6 +100,7 @@ export type ImportProjectParam = | |||
| { | |||
creationMode: CreateProjectModes.GitHub; | |||
almSetting: string; | |||
monorepo: false; | |||
projects: { | |||
repositoryKey: string; | |||
}[]; | |||
@@ -103,12 +108,14 @@ export type ImportProjectParam = | |||
| { | |||
creationMode: CreateProjectModes.GitLab; | |||
almSetting: string; | |||
monorepo: false; | |||
projects: { | |||
gitlabProjectId: string; | |||
}[]; | |||
} | |||
| { | |||
creationMode: CreateProjectModes.Manual; | |||
monorepo: false; | |||
projects: { | |||
project: string; | |||
name: string; | |||
@@ -116,9 +123,9 @@ export type ImportProjectParam = | |||
}[]; | |||
} | |||
| { | |||
creationMode: CreateProjectModes.Monorepo; | |||
creationMode: CreateProjectModes; | |||
devOpsPlatformSettingId: string; | |||
monorepo: boolean; | |||
monorepo: true; | |||
projects: { | |||
projectKey: string; | |||
projectName: string; | |||
@@ -146,6 +153,14 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
this.fetchAlmBindings(); | |||
} | |||
componentDidUpdate(prevProps: CreateProjectPageProps) { | |||
const { location } = this.props; | |||
if (location.query.mono !== prevProps.location.query.mono) { | |||
this.fetchAlmBindings(); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
@@ -153,6 +168,15 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
cleanQueryParameters() { | |||
const { location, router } = this.props; | |||
const isMonorepoSupported = this.props.hasFeature(Feature.MonoRepositoryPullRequestDecoration); | |||
if (location.query?.mono === 'true' && !isMonorepoSupported) { | |||
// Timeout is required to force the refresh of the URL | |||
setTimeout(() => { | |||
location.query.mono = undefined; | |||
router.replace(location); | |||
}, 0); | |||
} | |||
if (location.query?.setncd === 'true') { | |||
// Timeout is required to force the refresh of the URL | |||
setTimeout(() => { | |||
@@ -164,23 +188,28 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
fetchAlmBindings = () => { | |||
this.setState({ loading: true }); | |||
return getAlmSettings() | |||
.then((almSettings) => { | |||
if (this.mounted) { | |||
this.setState({ | |||
azureSettings: almSettings.filter((s) => s.alm === AlmKeys.Azure), | |||
bitbucketSettings: almSettings.filter((s) => s.alm === AlmKeys.BitbucketServer), | |||
bitbucketCloudSettings: almSettings.filter((s) => s.alm === AlmKeys.BitbucketCloud), | |||
githubSettings: almSettings.filter((s) => s.alm === AlmKeys.GitHub), | |||
gitlabSettings: almSettings.filter((s) => s.alm === AlmKeys.GitLab), | |||
loading: false, | |||
}); | |||
} | |||
return getDopSettings() | |||
.then(({ dopSettings }) => { | |||
this.setState({ | |||
azureSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.Azure) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
bitbucketSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.BitbucketServer) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
bitbucketCloudSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.BitbucketCloud) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
githubSettings: dopSettings.filter(({ type }) => type === AlmKeys.GitHub), | |||
gitlabSettings: dopSettings | |||
.filter(({ type }) => type === AlmKeys.GitLab) | |||
.map(({ key, type, url }) => ({ alm: type, key, url })), | |||
loading: false, | |||
}); | |||
}) | |||
.catch(() => { | |||
if (this.mounted) { | |||
this.setState({ loading: false }); | |||
} | |||
this.setState({ loading: false }); | |||
}); | |||
}; | |||
@@ -285,11 +314,9 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp | |||
return ( | |||
<GitHubProjectCreate | |||
canAdmin={!!canAdmin} | |||
loadingBindings={loading} | |||
location={location} | |||
isLoadingBindings={loading} | |||
onProjectSetupDone={this.handleProjectSetupDone} | |||
router={router} | |||
almInstances={githubSettings} | |||
dopSettings={githubSettings} | |||
/> | |||
); | |||
} |
@@ -17,305 +17,279 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { debounce } from 'lodash'; | |||
import * as React from 'react'; | |||
import { isWebUri } from 'valid-url'; | |||
import { | |||
getGithubClientId, | |||
getGithubOrganizations, | |||
getGithubRepositories, | |||
} from '../../../../api/alm-integrations'; | |||
import { Location, Router } from '../../../../components/hoc/withRouter'; | |||
import { getHostUrl } from '../../../../helpers/urls'; | |||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |||
import { getGithubOrganizations, getGithubRepositories } from '../../../../api/alm-integrations'; | |||
import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; | |||
import { LabelValueSelectOption } from '../../../../helpers/search'; | |||
import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration'; | |||
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; | |||
import { AlmSettingsInstance } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
import { Paging } from '../../../../types/types'; | |||
import { ImportProjectParam } from '../CreateProjectPage'; | |||
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate'; | |||
import { CreateProjectModes } from '../types'; | |||
import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer'; | |||
import { redirectToGithub } from './utils'; | |||
interface Props { | |||
canAdmin: boolean; | |||
loadingBindings: boolean; | |||
isLoadingBindings: boolean; | |||
onProjectSetupDone: (importProjects: ImportProjectParam) => void; | |||
almInstances: AlmSettingsInstance[]; | |||
location: Location; | |||
router: Router; | |||
} | |||
interface State { | |||
error: boolean; | |||
loadingOrganizations: boolean; | |||
loadingRepositories: boolean; | |||
organizations: GithubOrganization[]; | |||
repositoryPaging: Paging; | |||
repositories: GithubRepository[]; | |||
searchQuery: string; | |||
selectedOrganization?: GithubOrganization; | |||
selectedAlmInstance?: AlmSettingsInstance; | |||
dopSettings: DopSetting[]; | |||
} | |||
const REPOSITORY_PAGE_SIZE = 50; | |||
const REPOSITORY_SEARCH_DEBOUNCE_TIME = 250; | |||
export default function GitHubProjectCreate(props: Readonly<Props>) { | |||
const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props; | |||
const repositorySearchDebounceId = useRef<NodeJS.Timeout | undefined>(); | |||
const [isInError, setIsInError] = useState(false); | |||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true); | |||
const [isLoadingRepositories, setIsLoadingRepositories] = useState(false); | |||
const [organizations, setOrganizations] = useState<GithubOrganization[]>([]); | |||
const [repositories, setRepositories] = useState<GithubRepository[]>([]); | |||
const [repositoryPaging, setRepositoryPaging] = useState<Paging>({ | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
total: 0, | |||
pageIndex: 1, | |||
}); | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting>(); | |||
const [selectedOrganization, setSelectedOrganization] = useState<GithubOrganization>(); | |||
const [selectedRepository, setSelectedRepository] = useState<GithubRepository>(); | |||
const location = useLocation(); | |||
const router = useRouter(); | |||
const isMonorepoSetup = location.query?.mono === 'true'; | |||
const hasDopSettings = Boolean(dopSettings?.length); | |||
const organizationOptions = useMemo(() => { | |||
return organizations.map(transformToOption); | |||
}, [organizations]); | |||
const repositoryOptions = useMemo(() => { | |||
return repositories.map(transformToOption); | |||
}, [repositories]); | |||
const fetchRepositories = useCallback( | |||
async (params: { organizationKey: string; page?: number; query?: string }) => { | |||
const { organizationKey, page = 1, query } = params; | |||
if (selectedDopSetting === undefined) { | |||
setIsInError(true); | |||
return; | |||
} | |||
export default class GitHubProjectCreate extends React.Component<Props, State> { | |||
mounted = false; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { | |||
error: false, | |||
loadingOrganizations: true, | |||
loadingRepositories: false, | |||
organizations: [], | |||
repositories: [], | |||
repositoryPaging: { pageSize: REPOSITORY_PAGE_SIZE, total: 0, pageIndex: 1 }, | |||
searchQuery: '', | |||
selectedAlmInstance: this.getInitialSelectedAlmInstance(), | |||
}; | |||
this.triggerSearch = debounce(this.triggerSearch, 250); | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.initialize(); | |||
} | |||
setIsLoadingRepositories(true); | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) { | |||
this.setState({ selectedAlmInstance: this.getInitialSelectedAlmInstance() }, () => { | |||
this.initialize().catch(() => { | |||
/* noop */ | |||
try { | |||
const { paging, repositories } = await getGithubRepositories({ | |||
almSetting: selectedDopSetting.key, | |||
organization: organizationKey, | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
page, | |||
query, | |||
}); | |||
}); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
getInitialSelectedAlmInstance() { | |||
const { | |||
location: { | |||
query: { almInstance: selectedAlmInstanceKey }, | |||
}, | |||
almInstances, | |||
} = this.props; | |||
const selectedAlmInstance = almInstances.find( | |||
(instance) => instance.key === selectedAlmInstanceKey, | |||
); | |||
if (selectedAlmInstance) { | |||
return selectedAlmInstance; | |||
} | |||
return this.props.almInstances.length > 1 ? undefined : this.props.almInstances[0]; | |||
} | |||
setRepositoryPaging(paging); | |||
setRepositories((prevRepositories) => | |||
page === 1 ? repositories : [...prevRepositories, ...repositories], | |||
); | |||
} catch (_) { | |||
setRepositoryPaging({ pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 }); | |||
setRepositories([]); | |||
} finally { | |||
setIsLoadingRepositories(false); | |||
} | |||
}, | |||
[selectedDopSetting], | |||
); | |||
const handleImportRepository = useCallback( | |||
(repoKeys: string[]) => { | |||
if (selectedDopSetting && selectedOrganization && repoKeys.length > 0) { | |||
onProjectSetupDone({ | |||
almSetting: selectedDopSetting.key, | |||
creationMode: CreateProjectModes.GitHub, | |||
monorepo: false, | |||
projects: repoKeys.map((repositoryKey) => ({ repositoryKey })), | |||
}); | |||
} | |||
}, | |||
[onProjectSetupDone, selectedDopSetting, selectedOrganization], | |||
); | |||
async initialize() { | |||
const { location, router } = this.props; | |||
const { selectedAlmInstance } = this.state; | |||
if (!selectedAlmInstance || !selectedAlmInstance.url) { | |||
this.setState({ error: true }); | |||
return; | |||
const handleLoadMore = useCallback(() => { | |||
if (selectedOrganization) { | |||
fetchRepositories({ | |||
organizationKey: selectedOrganization.key, | |||
page: repositoryPaging.pageIndex + 1, | |||
query: searchQuery, | |||
}); | |||
} | |||
this.setState({ error: false }); | |||
const code = location.query?.code; | |||
}, [fetchRepositories, repositoryPaging.pageIndex, searchQuery, selectedOrganization]); | |||
const handleSelectOrganization = useCallback( | |||
(organizationKey: string) => { | |||
setSearchQuery(''); | |||
setSelectedOrganization(organizations.find(({ key }) => key === organizationKey)); | |||
fetchRepositories({ organizationKey }); | |||
}, | |||
[fetchRepositories, organizations], | |||
); | |||
const handleSelectRepository = useCallback( | |||
(repositoryIdentifier: string) => { | |||
setSelectedRepository(repositories.find(({ key }) => key === repositoryIdentifier)); | |||
}, | |||
[repositories], | |||
); | |||
const authenticateToGithub = useCallback(async () => { | |||
try { | |||
if (!code) { | |||
await this.redirectToGithub(selectedAlmInstance); | |||
} else { | |||
delete location.query.code; | |||
router.replace(location); | |||
await this.fetchOrganizations(selectedAlmInstance, code); | |||
} | |||
} catch (e) { | |||
if (this.mounted) { | |||
this.setState({ error: true }); | |||
} | |||
await redirectToGithub({ isMonorepoSetup, selectedDopSetting }); | |||
} catch { | |||
setIsInError(true); | |||
} | |||
} | |||
}, [isMonorepoSetup, selectedDopSetting]); | |||
const onSelectDopSetting = useCallback((setting: DopSetting | undefined) => { | |||
setSelectedDopSetting(setting); | |||
setOrganizations([]); | |||
setRepositories([]); | |||
setSearchQuery(''); | |||
}, []); | |||
const onSelectedAlmInstanceChange = useCallback( | |||
(instance: AlmSettingsInstance) => { | |||
onSelectDopSetting(dopSettings.find((dopSetting) => dopSetting.key === instance.key)); | |||
}, | |||
[dopSettings, onSelectDopSetting], | |||
); | |||
useEffect(() => { | |||
const selectedDopSettingId = location.query?.dopSetting; | |||
if (selectedDopSettingId !== undefined) { | |||
const selectedDopSetting = dopSettings.find(({ id }) => id === selectedDopSettingId); | |||
if (selectedDopSetting) { | |||
setSelectedDopSetting(selectedDopSetting); | |||
} | |||
async redirectToGithub(selectedAlmInstance: AlmSettingsInstance) { | |||
if (!selectedAlmInstance.url) { | |||
return; | |||
} | |||
const { clientId } = await getGithubClientId(selectedAlmInstance.key); | |||
if (!clientId) { | |||
this.setState({ error: true }); | |||
if (dopSettings.length > 1) { | |||
setSelectedDopSetting(undefined); | |||
return; | |||
} | |||
const queryParams = [ | |||
{ param: 'client_id', value: clientId }, | |||
{ | |||
param: 'redirect_uri', | |||
value: encodeURIComponent( | |||
`${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&almInstance=${ | |||
selectedAlmInstance.key | |||
}`, | |||
), | |||
}, | |||
] | |||
.map(({ param, value }) => `${param}=${value}`) | |||
.join('&'); | |||
let instanceRootUrl; | |||
// Strip the api section from the url, since we're not hitting the api here. | |||
if (selectedAlmInstance.url.includes('/api/v3')) { | |||
// GitHub Enterprise | |||
instanceRootUrl = selectedAlmInstance.url.replace('/api/v3', ''); | |||
} else { | |||
// github.com | |||
instanceRootUrl = selectedAlmInstance.url.replace('api.', ''); | |||
} | |||
// strip the trailing / | |||
instanceRootUrl = instanceRootUrl.replace(/\/$/, ''); | |||
if (!isWebUri(instanceRootUrl)) { | |||
this.setState({ error: true }); | |||
} else { | |||
window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`); | |||
} | |||
} | |||
async fetchOrganizations(selectedAlmInstance: AlmSettingsInstance, token: string) { | |||
const { organizations } = await getGithubOrganizations(selectedAlmInstance.key, token); | |||
setSelectedDopSetting(dopSettings[0]); | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [hasDopSettings]); | |||
if (this.mounted) { | |||
this.setState({ loadingOrganizations: false, organizations }); | |||
} | |||
} | |||
async fetchRepositories(params: { organizationKey: string; page?: number; query?: string }) { | |||
const { organizationKey, page = 1, query } = params; | |||
const { selectedAlmInstance } = this.state; | |||
if (!selectedAlmInstance) { | |||
this.setState({ error: true }); | |||
useEffect(() => { | |||
if (selectedDopSetting?.url === undefined) { | |||
setIsInError(true); | |||
return; | |||
} | |||
setIsInError(false); | |||
this.setState({ loadingRepositories: true }); | |||
try { | |||
const data = await getGithubRepositories({ | |||
almSetting: selectedAlmInstance.key, | |||
organization: organizationKey, | |||
pageSize: REPOSITORY_PAGE_SIZE, | |||
page, | |||
query, | |||
const code = location.query?.code; | |||
if (code === undefined) { | |||
authenticateToGithub().catch(() => { | |||
setIsInError(true); | |||
}); | |||
if (this.mounted) { | |||
this.setState(({ repositories }) => ({ | |||
loadingRepositories: false, | |||
repositoryPaging: data.paging, | |||
repositories: page === 1 ? data.repositories : [...repositories, ...data.repositories], | |||
})); | |||
} | |||
} catch (_) { | |||
if (this.mounted) { | |||
this.setState({ | |||
loadingRepositories: false, | |||
repositoryPaging: { pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 }, | |||
repositories: [], | |||
} else { | |||
delete location.query.code; | |||
router.replace(location); | |||
getGithubOrganizations(selectedDopSetting.key, code) | |||
.then(({ organizations }) => { | |||
setOrganizations(organizations); | |||
setIsLoadingOrganizations(false); | |||
}) | |||
.catch(() => { | |||
setIsInError(true); | |||
}); | |||
} | |||
} | |||
} | |||
triggerSearch = (query: string) => { | |||
const { selectedOrganization } = this.state; | |||
if (selectedOrganization) { | |||
this.fetchRepositories({ organizationKey: selectedOrganization.key, query }); | |||
} | |||
}; | |||
handleSelectOrganization = (key: string) => { | |||
this.setState(({ organizations }) => ({ | |||
searchQuery: '', | |||
selectedOrganization: organizations.find((o) => o.key === key), | |||
})); | |||
this.fetchRepositories({ organizationKey: key }); | |||
}; | |||
handleSearch = (searchQuery: string) => { | |||
this.setState({ searchQuery }); | |||
this.triggerSearch(searchQuery); | |||
}; | |||
handleLoadMore = () => { | |||
const { repositoryPaging, searchQuery, selectedOrganization } = this.state; | |||
if (selectedOrganization) { | |||
this.fetchRepositories({ | |||
organizationKey: selectedOrganization.key, | |||
page: repositoryPaging.pageIndex + 1, | |||
query: searchQuery, | |||
}); | |||
} | |||
}; | |||
handleImportRepository = (repoKeys: string[]) => { | |||
const { selectedOrganization, selectedAlmInstance } = this.state; | |||
if (selectedAlmInstance && selectedOrganization && repoKeys.length > 0) { | |||
this.props.onProjectSetupDone({ | |||
almSetting: selectedAlmInstance.key, | |||
creationMode: CreateProjectModes.GitHub, | |||
projects: repoKeys.map((repositoryKey) => ({ repositoryKey })), | |||
}); | |||
} | |||
}; | |||
onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => { | |||
this.setState( | |||
{ selectedAlmInstance: instance, searchQuery: '', organizations: [], repositories: [] }, | |||
() => { | |||
this.initialize().catch(() => { | |||
/* noop */ | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [selectedDopSetting]); | |||
useEffect(() => { | |||
repositorySearchDebounceId.current = setTimeout(() => { | |||
if (selectedOrganization) { | |||
fetchRepositories({ | |||
organizationKey: selectedOrganization.key, | |||
query: searchQuery, | |||
}); | |||
}, | |||
); | |||
}; | |||
} | |||
}, REPOSITORY_SEARCH_DEBOUNCE_TIME); | |||
render() { | |||
const { canAdmin, loadingBindings, almInstances } = this.props; | |||
const { | |||
error, | |||
loadingOrganizations, | |||
loadingRepositories, | |||
organizations, | |||
repositoryPaging, | |||
repositories, | |||
searchQuery, | |||
selectedOrganization, | |||
selectedAlmInstance, | |||
} = this.state; | |||
return () => { | |||
clearTimeout(repositorySearchDebounceId.current); | |||
}; | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [searchQuery]); | |||
return isMonorepoSetup ? ( | |||
<MonorepoProjectCreate | |||
dopSettings={dopSettings} | |||
canAdmin={canAdmin} | |||
error={isInError} | |||
loadingBindings={isLoadingBindings} | |||
loadingOrganizations={isLoadingOrganizations} | |||
loadingRepositories={isLoadingRepositories} | |||
onProjectSetupDone={onProjectSetupDone} | |||
onSearchRepositories={setSearchQuery} | |||
onSelectDopSetting={onSelectDopSetting} | |||
onSelectOrganization={handleSelectOrganization} | |||
onSelectRepository={handleSelectRepository} | |||
organizationOptions={organizationOptions} | |||
repositoryOptions={repositoryOptions} | |||
repositorySearchQuery={searchQuery} | |||
selectedDopSetting={selectedDopSetting} | |||
selectedOrganization={selectedOrganization && transformToOption(selectedOrganization)} | |||
selectedRepository={selectedRepository && transformToOption(selectedRepository)} | |||
/> | |||
) : ( | |||
<GitHubProjectCreateRenderer | |||
almInstances={dopSettings.map(({ key, type, url }) => ({ | |||
alm: type, | |||
key, | |||
url, | |||
}))} | |||
canAdmin={canAdmin} | |||
error={isInError} | |||
loadingBindings={isLoadingBindings} | |||
loadingOrganizations={isLoadingOrganizations} | |||
loadingRepositories={isLoadingRepositories} | |||
onImportRepository={handleImportRepository} | |||
onLoadMore={handleLoadMore} | |||
onSearch={setSearchQuery} | |||
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange} | |||
onSelectOrganization={handleSelectOrganization} | |||
organizations={organizations} | |||
repositories={repositories} | |||
repositoryPaging={repositoryPaging} | |||
searchQuery={searchQuery} | |||
selectedAlmInstance={ | |||
selectedDopSetting && { | |||
alm: selectedDopSetting.type, | |||
key: selectedDopSetting.key, | |||
url: selectedDopSetting.url, | |||
} | |||
} | |||
selectedOrganization={selectedOrganization} | |||
/> | |||
); | |||
} | |||
return ( | |||
<GitHubProjectCreateRenderer | |||
canAdmin={canAdmin} | |||
error={error} | |||
loadingBindings={loadingBindings} | |||
loadingOrganizations={loadingOrganizations} | |||
loadingRepositories={loadingRepositories} | |||
onImportRepository={this.handleImportRepository} | |||
onLoadMore={this.handleLoadMore} | |||
onSearch={this.handleSearch} | |||
onSelectOrganization={this.handleSelectOrganization} | |||
organizations={organizations} | |||
repositoryPaging={repositoryPaging} | |||
searchQuery={searchQuery} | |||
repositories={repositories} | |||
selectedOrganization={selectedOrganization} | |||
almInstances={almInstances} | |||
selectedAlmInstance={selectedAlmInstance} | |||
onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange} | |||
/> | |||
); | |||
} | |||
function transformToOption({ | |||
key, | |||
name, | |||
}: GithubOrganization | GithubRepository): LabelValueSelectOption { | |||
return { value: key, label: name }; | |||
} |
@@ -20,6 +20,7 @@ | |||
/* eslint-disable react/no-unused-prop-types */ | |||
import styled from '@emotion/styled'; | |||
import { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { | |||
ButtonPrimary, | |||
Checkbox, | |||
@@ -28,23 +29,25 @@ import { | |||
InputSearch, | |||
InputSelect, | |||
LightPrimary, | |||
Link, | |||
Spinner, | |||
Title, | |||
themeBorder, | |||
themeColor, | |||
} from 'design-system'; | |||
import React, { useState } from 'react'; | |||
import React, { useContext, 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 { CreateProjectModes } from '../types'; | |||
interface GitHubProjectCreateRendererProps { | |||
canAdmin: boolean; | |||
@@ -173,6 +176,10 @@ function RepositoryList(props: RepositoryListProps) { | |||
} | |||
export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) { | |||
const isMonorepoSupported = useContext(AvailableFeaturesContext).includes( | |||
Feature.MonoRepositoryPullRequestDecoration, | |||
); | |||
const { | |||
canAdmin, | |||
error, | |||
@@ -211,7 +218,28 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe | |||
<header className="sw-mb-10"> | |||
<Title className="sw-mb-4">{translate('onboarding.create_project.github.title')}</Title> | |||
<LightPrimary className="sw-body-sm"> | |||
{translate('onboarding.create_project.github.subtitle')} | |||
{isMonorepoSupported ? ( | |||
<FormattedMessage | |||
id="onboarding.create_project.github.subtitle.with_monorepo" | |||
values={{ | |||
monorepoSetupLink: ( | |||
<Link | |||
to={{ | |||
pathname: '/projects/create', | |||
search: queryToSearch({ | |||
mode: CreateProjectModes.GitHub, | |||
mono: true, | |||
}), | |||
}} | |||
> | |||
<FormattedMessage id="onboarding.create_project.github.subtitle.link" /> | |||
</Link> | |||
), | |||
}} | |||
/> | |||
) : ( | |||
<FormattedMessage id="onboarding.create_project.github.subtitle" /> | |||
)} | |||
</LightPrimary> | |||
</header> | |||
@@ -246,7 +274,7 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe | |||
<div className="sw-flex sw-gap-12"> | |||
<LargeColumn> | |||
<Spinner loading={loadingOrganizations && !error}> | |||
<Spinner isLoading={loadingOrganizations && !error}> | |||
{!error && ( | |||
<div className="sw-flex sw-flex-col"> | |||
<DarkLabel htmlFor="github-choose-organization" className="sw-mb-2"> |
@@ -0,0 +1,73 @@ | |||
/* | |||
* 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 { isWebUri } from 'valid-url'; | |||
import { getGithubClientId } from '../../../../api/alm-integrations'; | |||
import { getHostUrl } from '../../../../helpers/urls'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
export async function redirectToGithub(params: { | |||
isMonorepoSetup: boolean; | |||
selectedDopSetting?: DopSetting; | |||
}) { | |||
const { isMonorepoSetup, selectedDopSetting } = params; | |||
if (selectedDopSetting?.url === undefined) { | |||
return; | |||
} | |||
const { clientId } = await getGithubClientId(selectedDopSetting.key); | |||
if (clientId === undefined) { | |||
throw new Error('Received no GitHub client id'); | |||
} | |||
const queryParams = [ | |||
{ param: 'client_id', value: clientId }, | |||
{ | |||
param: 'redirect_uri', | |||
value: encodeURIComponent( | |||
`${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&dopSetting=${ | |||
selectedDopSetting.id | |||
}${isMonorepoSetup ? '&mono=true' : ''}`, | |||
), | |||
}, | |||
] | |||
.map(({ param, value }) => `${param}=${value}`) | |||
.join('&'); | |||
let instanceRootUrl; | |||
// Strip the api section from the url, since we're not hitting the api here. | |||
if (selectedDopSetting.url.includes('/api/v3')) { | |||
// GitHub Enterprise | |||
instanceRootUrl = selectedDopSetting.url.replace('/api/v3', ''); | |||
} else { | |||
// github.com | |||
instanceRootUrl = selectedDopSetting.url.replace('api.', ''); | |||
} | |||
// strip the trailing / | |||
instanceRootUrl = instanceRootUrl.replace(/\/$/, ''); | |||
if (isWebUri(instanceRootUrl) === undefined) { | |||
throw new Error('Invalid GitHub URL'); | |||
} else { | |||
window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`); | |||
} | |||
} |
@@ -143,6 +143,7 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat | |||
this.props.onProjectSetupDone({ | |||
creationMode: CreateProjectModes.GitLab, | |||
almSetting: selectedAlmInstance.key, | |||
monorepo: false, | |||
projects: [{ gitlabProjectId }], | |||
}); | |||
} |
@@ -24,7 +24,7 @@ import * as React from 'react'; | |||
import selectEvent from 'react-select-event'; | |||
import { searchAzureRepositories } from '../../../../api/alm-integrations'; | |||
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; | |||
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; | |||
@@ -34,7 +34,7 @@ jest.mock('../../../../api/alm-integrations'); | |||
jest.mock('../../../../api/alm-settings'); | |||
let almIntegrationHandler: AlmIntegrationsServiceMock; | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const ui = { | |||
@@ -53,14 +53,14 @@ beforeAll(() => { | |||
value: { replace: jest.fn() }, | |||
}); | |||
almIntegrationHandler = new AlmIntegrationsServiceMock(); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
}); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almIntegrationHandler.reset(); | |||
almSettingsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
}); | |||
afterAll(() => { |
@@ -24,7 +24,7 @@ import * as React from 'react'; | |||
import selectEvent from 'react-select-event'; | |||
import { searchForBitbucketServerRepositories } from '../../../../api/alm-integrations'; | |||
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; | |||
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; | |||
@@ -34,7 +34,7 @@ jest.mock('../../../../api/alm-integrations'); | |||
jest.mock('../../../../api/alm-settings'); | |||
let almIntegrationHandler: AlmIntegrationsServiceMock; | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const ui = { | |||
@@ -52,14 +52,14 @@ beforeAll(() => { | |||
value: { replace: jest.fn() }, | |||
}); | |||
almIntegrationHandler = new AlmIntegrationsServiceMock(); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
}); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almIntegrationHandler.reset(); | |||
almSettingsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
}); | |||
@@ -24,7 +24,7 @@ import * as React from 'react'; | |||
import selectEvent from 'react-select-event'; | |||
import { searchForBitbucketCloudRepositories } from '../../../../api/alm-integrations'; | |||
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; | |||
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; | |||
@@ -35,7 +35,7 @@ jest.mock('../../../../api/alm-integrations'); | |||
jest.mock('../../../../api/alm-settings'); | |||
let almIntegrationHandler: AlmIntegrationsServiceMock; | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const ui = { | |||
@@ -56,14 +56,14 @@ beforeAll(() => { | |||
value: { replace: jest.fn() }, | |||
}); | |||
almIntegrationHandler = new AlmIntegrationsServiceMock(); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
}); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almIntegrationHandler.reset(); | |||
almSettingsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
}); | |||
@@ -21,7 +21,7 @@ import { screen } from '@testing-library/react'; | |||
import * as React from 'react'; | |||
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; | |||
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import { mockAppState } from '../../../../helpers/testMocks'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
@@ -32,7 +32,7 @@ jest.mock('../../../../api/alm-integrations'); | |||
jest.mock('../../../../api/alm-settings'); | |||
let almIntegrationHandler: AlmIntegrationsServiceMock; | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const original = window.location; | |||
@@ -43,14 +43,14 @@ beforeAll(() => { | |||
value: { replace: jest.fn() }, | |||
}); | |||
almIntegrationHandler = new AlmIntegrationsServiceMock(); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
}); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almIntegrationHandler.reset(); | |||
almSettingsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
}); | |||
afterAll(() => { | |||
@@ -58,14 +58,14 @@ afterAll(() => { | |||
}); | |||
it('should be able to setup if no config and admin', async () => { | |||
almSettingsHandler.removeFromAlmSettings(AlmKeys.Azure); | |||
dopTranslationHandler.removeDopTypeFromSettings(AlmKeys.Azure); | |||
renderCreateProject(true); | |||
expect(await screen.findByText('onboarding.create_project.select_method')).toBeInTheDocument(); | |||
expect(screen.getByRole('button', { name: 'setup' })).toBeInTheDocument(); | |||
}); | |||
it('should not be able to setup if no config and no admin rights', async () => { | |||
almSettingsHandler.removeFromAlmSettings(AlmKeys.Azure); | |||
dopTranslationHandler.removeDopTypeFromSettings(AlmKeys.Azure); | |||
renderCreateProject(); | |||
expect(await screen.findByText('onboarding.create_project.select_method')).toBeInTheDocument(); | |||
expect(screen.queryByRole('button', { name: 'setup' })).not.toBeInTheDocument(); |
@@ -24,7 +24,7 @@ import * as React from 'react'; | |||
import selectEvent from 'react-select-event'; | |||
import { getGithubRepositories } from '../../../../api/alm-integrations'; | |||
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; | |||
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
@@ -37,7 +37,7 @@ jest.mock('../../../../api/alm-settings'); | |||
const original = window.location; | |||
let almIntegrationHandler: AlmIntegrationsServiceMock; | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const ui = { | |||
@@ -76,14 +76,14 @@ beforeAll(() => { | |||
value: { replace: jest.fn() }, | |||
}); | |||
almIntegrationHandler = new AlmIntegrationsServiceMock(); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
}); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almIntegrationHandler.reset(); | |||
almSettingsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
}); | |||
@@ -120,7 +120,7 @@ it('should not redirect to github when url is malformated', async () => { | |||
it('should show import project feature when the authentication is successfull', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); | |||
renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); | |||
expect(await ui.instanceSelector.find()).toBeInTheDocument(); | |||
@@ -172,7 +172,7 @@ it('should import several projects', async () => { | |||
mockGitHubRepository({ name: 'Github repo 3', key: 'key3' }), | |||
]); | |||
renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); | |||
renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); | |||
expect(await ui.instanceSelector.find()).toBeInTheDocument(); | |||
@@ -237,7 +237,7 @@ it('should import several projects', async () => { | |||
it('should show search filter when the authentication is successful', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); | |||
renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); | |||
expect(await ui.instanceSelector.find()).toBeInTheDocument(); | |||
@@ -247,12 +247,14 @@ it('should show search filter when the authentication is successful', async () = | |||
await user.click(inputSearch); | |||
await user.keyboard('search'); | |||
expect(getGithubRepositories).toHaveBeenLastCalledWith({ | |||
almSetting: 'conf-github-2', | |||
organization: 'org-1', | |||
page: 1, | |||
pageSize: 50, | |||
query: 'search', | |||
await waitFor(() => { | |||
expect(getGithubRepositories).toHaveBeenLastCalledWith({ | |||
almSetting: 'conf-github-2', | |||
organization: 'org-1', | |||
page: 1, | |||
pageSize: 50, | |||
query: 'search', | |||
}); | |||
}); | |||
}); | |||
@@ -260,7 +262,7 @@ it('should have load more', async () => { | |||
const user = userEvent.setup(); | |||
almIntegrationHandler.createRandomGithubRepositoriessWithLoadMore(10, 20); | |||
renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); | |||
renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); | |||
expect(await ui.instanceSelector.find()).toBeInTheDocument(); | |||
@@ -288,7 +290,7 @@ it('should have load more', async () => { | |||
it('should show no result message when there are no projects', async () => { | |||
almIntegrationHandler.setGithubRepositories([]); | |||
renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213'); | |||
renderCreateProject('project/create?mode=github&dopSetting=conf-github-2&code=213321213'); | |||
expect(await ui.instanceSelector.find()).toBeInTheDocument(); | |||
@@ -0,0 +1,187 @@ | |||
/* | |||
* 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 { waitFor } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import React from 'react'; | |||
import selectEvent from 'react-select-event'; | |||
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; | |||
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 { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
import { byRole } from '../../../../helpers/testSelector'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { Feature } from '../../../../types/features'; | |||
import CreateProjectPage from '../CreateProjectPage'; | |||
import { CreateProjectModes } from '../types'; | |||
jest.mock('../../../../api/alm-integrations'); | |||
jest.mock('../../../../api/alm-settings'); | |||
let almIntegrationHandler: AlmIntegrationsServiceMock; | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let componentsHandler: ComponentsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const ui = { | |||
addButton: byRole('button', { name: 'onboarding.create_project.monorepo.add_project' }), | |||
cancelButton: byRole('button', { name: 'cancel' }), | |||
dopSettingSelector: byRole('combobox', { | |||
name: `onboarding.create_project.monorepo.choose_dop_setting.${AlmKeys.GitHub}`, | |||
}), | |||
gitHubOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.github.title' }), | |||
monorepoProjectTitle: byRole('heading', { | |||
name: 'onboarding.create_project.monorepo.project_title', | |||
}), | |||
monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.github.subtitle.link' }), | |||
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.github' }), | |||
organizationSelector: byRole('combobox', { | |||
name: `onboarding.create_project.monorepo.choose_organization.${AlmKeys.GitHub}`, | |||
}), | |||
removeButton: byRole('button', { name: 'onboarding.create_project.monorepo.remove_project' }), | |||
repositorySelector: byRole('combobox', { | |||
name: `onboarding.create_project.monorepo.choose_repository.${AlmKeys.GitHub}`, | |||
}), | |||
submitButton: byRole('button', { name: 'next' }), | |||
}; | |||
beforeAll(() => { | |||
Object.defineProperty(window, 'location', { | |||
configurable: true, | |||
value: { replace: jest.fn() }, | |||
}); | |||
almIntegrationHandler = new AlmIntegrationsServiceMock(); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
componentsHandler = new ComponentsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
}); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almIntegrationHandler.reset(); | |||
almSettingsHandler.reset(); | |||
componentsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
}); | |||
describe('github monorepo project setup', () => { | |||
it('should be able to access monorepo setup page from GitHub project import page', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject({ isMonorepo: false }); | |||
await ui.monorepoSetupLink.find(); | |||
await user.click(await ui.monorepoSetupLink.find()); | |||
expect(ui.monorepoTitle.get()).toBeInTheDocument(); | |||
}); | |||
it('should be able to go back to GitHub onboarding page from monorepo setup page', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject(); | |||
await user.click(await ui.cancelButton.find()); | |||
expect(ui.gitHubOnboardingTitle.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 }); | |||
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.monorepoProjectTitle.find()).toBeInTheDocument(); | |||
let projects = byRole('textbox', { | |||
name: /onboarding.create_project.project_key/, | |||
}).getAll(); | |||
expect(projects).toHaveLength(1); | |||
expect(projects[0]).toHaveValue('org-1_Github-repo-1_add-your-reference'); | |||
expect(ui.submitButton.get()).toBeEnabled(); | |||
await user.click(ui.addButton.get()); | |||
await waitFor(() => { | |||
projects = byRole('textbox', { | |||
name: /onboarding.create_project.project_key/, | |||
}).getAll(); | |||
expect(projects).toHaveLength(2); | |||
}); | |||
expect(projects[0]).toHaveValue('org-1_Github-repo-1_add-your-reference'); | |||
expect(projects[1]).toHaveValue('org-1_Github-repo-1_add-your-reference-1'); | |||
expect(ui.submitButton.get()).toBeEnabled(); | |||
await user.type(projects[0], '-1'); | |||
expect(ui.submitButton.get()).toBeDisabled(); | |||
await user.clear(projects[1]); | |||
expect(ui.submitButton.get()).toBeDisabled(); | |||
await user.click(ui.removeButton.getAll()[0]); | |||
await waitFor(() => { | |||
projects = byRole('textbox', { | |||
name: /onboarding.create_project.project_key/, | |||
}).getAll(); | |||
expect(projects).toHaveLength(1); | |||
}); | |||
expect(projects[0]).toHaveValue(''); | |||
expect(ui.submitButton.get()).toBeDisabled(); | |||
await user.type(projects[0], 'project-key'); | |||
expect(ui.submitButton.get()).toBeEnabled(); | |||
}); | |||
}); | |||
function renderCreateProject({ | |||
code, | |||
dopSetting, | |||
isMonorepo = true, | |||
}: { | |||
code?: string; | |||
dopSetting?: string; | |||
isMonorepo?: boolean; | |||
} = {}) { | |||
let queryString = `mode=${CreateProjectModes.GitHub}`; | |||
if (isMonorepo) { | |||
queryString += '&mono=true'; | |||
} | |||
if (dopSetting !== undefined) { | |||
queryString += `&dopSetting=${dopSetting}`; | |||
} | |||
if (code !== undefined) { | |||
queryString += `&code=${code}`; | |||
} | |||
renderApp('projects/create', <CreateProjectPage />, { | |||
navigateTo: `projects/create?${queryString}`, | |||
featureList: [Feature.MonoRepositoryPullRequestDecoration], | |||
}); | |||
} |
@@ -23,7 +23,7 @@ import * as React from 'react'; | |||
import selectEvent from 'react-select-event'; | |||
import { getGitlabProjects } from '../../../../api/alm-integrations'; | |||
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock'; | |||
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import { renderApp } from '../../../../helpers/testReactTestingUtils'; | |||
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector'; | |||
@@ -33,7 +33,7 @@ jest.mock('../../../../api/alm-integrations'); | |||
jest.mock('../../../../api/alm-settings'); | |||
let almIntegrationHandler: AlmIntegrationsServiceMock; | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
const ui = { | |||
@@ -53,14 +53,14 @@ beforeAll(() => { | |||
value: { replace: jest.fn() }, | |||
}); | |||
almIntegrationHandler = new AlmIntegrationsServiceMock(); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
}); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almIntegrationHandler.reset(); | |||
almSettingsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
}); | |||
@@ -17,9 +17,11 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { waitFor } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; | |||
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; | |||
import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServiceMock'; | |||
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock'; | |||
import { ProjectsServiceMock } from '../../../../api/mocks/ProjectsServiceMock'; | |||
import { getNewCodeDefinition } from '../../../../api/newCodeDefinition'; | |||
@@ -34,6 +36,7 @@ import routes from '../../../projects/routes'; | |||
jest.mock('../../../../api/measures'); | |||
jest.mock('../../../../api/favorites'); | |||
jest.mock('../../../../api/alm-settings'); | |||
jest.mock('../../../../api/dop-translation'); | |||
jest.mock('../../../../api/newCodeDefinition'); | |||
jest.mock('../../../../api/project-management', () => ({ | |||
createProject: jest.fn().mockReturnValue(Promise.resolve({ project: mockProject() })), | |||
@@ -98,6 +101,7 @@ async function fillFormAndNext(displayName: string, user: UserEvent) { | |||
} | |||
let almSettingsHandler: AlmSettingsServiceMock; | |||
let dopTranslationHandler: DopTranslationServiceMock; | |||
let newCodePeriodHandler: NewCodeDefinitionServiceMock; | |||
let projectHandler: ProjectsServiceMock; | |||
@@ -109,6 +113,7 @@ beforeAll(() => { | |||
value: { replace: jest.fn() }, | |||
}); | |||
almSettingsHandler = new AlmSettingsServiceMock(); | |||
dopTranslationHandler = new DopTranslationServiceMock(); | |||
newCodePeriodHandler = new NewCodeDefinitionServiceMock(); | |||
projectHandler = new ProjectsServiceMock(); | |||
}); | |||
@@ -116,6 +121,7 @@ beforeAll(() => { | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
almSettingsHandler.reset(); | |||
dopTranslationHandler.reset(); | |||
newCodePeriodHandler.reset(); | |||
projectHandler.reset(); | |||
}); | |||
@@ -192,7 +198,7 @@ it('the project onboarding page should be displayed when the project is created' | |||
expect(await ui.projectDashboardText.find()).toBeInTheDocument(); | |||
}); | |||
it('validate the provate key field', async () => { | |||
it('validate the private key field', async () => { | |||
const user = userEvent.setup(); | |||
renderCreateProject(); | |||
expect(ui.manualProjectHeader.get()).toBeInTheDocument(); | |||
@@ -200,7 +206,9 @@ it('validate the provate key field', async () => { | |||
await user.click(ui.displayNameField.get()); | |||
await user.keyboard('exists'); | |||
expect(ui.projectNextButton.get()).toBeDisabled(); | |||
await waitFor(() => { | |||
expect(ui.projectNextButton.get()).toBeDisabled(); | |||
}); | |||
await user.click(ui.projectNextButton.get()); | |||
}); | |||
@@ -23,7 +23,7 @@ import AlmSettingsInstanceSelector from '../../../../components/devops-platform/ | |||
import { hasMessage, translate, translateWithParameters } from '../../../../helpers/l10n'; | |||
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings'; | |||
export interface AlmSettingsInstanceDropdownProps { | |||
interface Props { | |||
almKey: AlmKeys; | |||
almInstances?: AlmSettingsInstance[]; | |||
selectedAlmInstance?: AlmSettingsInstance; | |||
@@ -32,7 +32,7 @@ export interface AlmSettingsInstanceDropdownProps { | |||
const MIN_SIZE_INSTANCES = 2; | |||
export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDropdownProps) { | |||
export default function AlmSettingsInstanceDropdown(props: Readonly<Props>) { | |||
const { almKey, almInstances, selectedAlmInstance } = props; | |||
if (!almInstances || almInstances.length < MIN_SIZE_INSTANCES) { | |||
return null; | |||
@@ -43,7 +43,7 @@ export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDr | |||
: `alm.${almKey}`; | |||
return ( | |||
<div className="sw-flex sw-flex-col"> | |||
<div className="sw-flex sw-flex-col sw-mb-9"> | |||
<DarkLabel htmlFor="alm-config-selector" className="sw-mb-2"> | |||
{translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))} | |||
</DarkLabel> | |||
@@ -51,7 +51,7 @@ export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDr | |||
instances={almInstances} | |||
onChange={props.onChangeConfig} | |||
initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined} | |||
className="sw-w-abs-400 sw-mb-9" | |||
className="sw-w-abs-400" | |||
inputId="alm-config-selector" | |||
/> | |||
</div> |
@@ -0,0 +1,99 @@ | |||
/* | |||
* 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 classNames from 'classnames'; | |||
import { DarkLabel, InputSelect, LabelValueSelectOption, Note } from 'design-system'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { OptionProps, SingleValueProps, components } from 'react-select'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
export interface DopSettingDropdownProps { | |||
almKey: AlmKeys; | |||
className?: string; | |||
dopSettings?: DopSetting[]; | |||
onChangeSetting: (setting: DopSetting) => void; | |||
selectedDopSetting?: DopSetting; | |||
} | |||
const MIN_SIZE_INSTANCES = 2; | |||
function optionRenderer(props: OptionProps<LabelValueSelectOption<DopSetting>, false>) { | |||
return <components.Option {...props}>{customOptions(props.data.value)}</components.Option>; | |||
} | |||
function singleValueRenderer(props: SingleValueProps<LabelValueSelectOption<DopSetting>, false>) { | |||
return ( | |||
<components.SingleValue {...props}>{customOptions(props.data.value)}</components.SingleValue> | |||
); | |||
} | |||
function customOptions(setting: DopSetting) { | |||
return setting.url ? ( | |||
<> | |||
<span>{setting.key} — </span> | |||
<Note>{setting.url}</Note> | |||
</> | |||
) : ( | |||
<span>{setting.key}</span> | |||
); | |||
} | |||
function orgToOption(alm: DopSetting) { | |||
return { value: alm, label: alm.key }; | |||
} | |||
export default function DopSettingDropdown(props: Readonly<DopSettingDropdownProps>) { | |||
const { almKey, className, dopSettings, onChangeSetting, selectedDopSetting } = props; | |||
if (!dopSettings || dopSettings.length < MIN_SIZE_INSTANCES) { | |||
return null; | |||
} | |||
return ( | |||
<div className={classNames('sw-flex sw-flex-col', className)}> | |||
<DarkLabel htmlFor="dop-setting-dropdown" className="sw-mb-2"> | |||
<FormattedMessage id={`onboarding.create_project.monorepo.choose_dop_setting.${almKey}`} /> | |||
</DarkLabel> | |||
<InputSelect | |||
inputId="dop-setting-dropdown" | |||
className={className} | |||
isClearable={false} | |||
isSearchable={false} | |||
options={dopSettings.map(orgToOption)} | |||
onChange={(data: LabelValueSelectOption<DopSetting>) => { | |||
onChangeSetting(data.value); | |||
}} | |||
components={{ | |||
Option: optionRenderer, | |||
SingleValue: singleValueRenderer, | |||
}} | |||
placeholder={translate('alm.configuration.selector.placeholder')} | |||
getOptionValue={(opt: LabelValueSelectOption<DopSetting>) => opt.value.key} | |||
value={ | |||
dopSettings.map(orgToOption).find((opt) => opt.value.key === selectedDopSetting?.key) ?? | |||
null | |||
} | |||
size="full" | |||
/> | |||
</div> | |||
); | |||
} |
@@ -34,6 +34,7 @@ import * as React from 'react'; | |||
import { useEffect } from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { useNavigate, unstable_usePrompt as usePrompt } from 'react-router-dom'; | |||
import { useLocation } from '../../../../components/hoc/withRouter'; | |||
import NewCodeDefinitionSelector from '../../../../components/new-code-definition/NewCodeDefinitionSelector'; | |||
import { useDocUrl } from '../../../../helpers/docs'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
@@ -65,6 +66,7 @@ export default function NewCodeDefinitionSelection(props: Props) { | |||
const mutateCount = useImportProjectProgress(); | |||
const isImporting = mutateCount > 0; | |||
const intl = useIntl(); | |||
const location = useLocation(); | |||
const navigate = useNavigate(); | |||
const getDocUrl = useDocUrl(); | |||
usePrompt({ | |||
@@ -74,10 +76,11 @@ export default function NewCodeDefinitionSelection(props: Props) { | |||
const projectCount = importProjects.projects.length; | |||
const isMultipleProjects = projectCount > 1; | |||
const isMonorepo = location.query?.mono === 'true'; | |||
useEffect(() => { | |||
const redirect = (projectCount: number) => { | |||
if (projectCount === 1 && data) { | |||
if (!isMonorepo && projectCount === 1 && data) { | |||
if (redirectTo === '/projects') { | |||
navigate(getProjectUrl(data.project.key)); | |||
} else { | |||
@@ -110,7 +113,11 @@ export default function NewCodeDefinitionSelection(props: Props) { | |||
if (redirectTo === '/projects') { | |||
addGlobalSuccessMessage( | |||
intl.formatMessage( | |||
{ id: 'onboarding.create_project.success' }, | |||
{ | |||
id: isMonorepo | |||
? 'onboarding.create_project.monorepo.success' | |||
: 'onboarding.create_project.success', | |||
}, | |||
{ | |||
count: projectCount - failedImports, | |||
}, |
@@ -0,0 +1,336 @@ | |||
/* | |||
* 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 classNames from 'classnames'; | |||
import { | |||
ButtonSecondary, | |||
Card, | |||
FlagErrorIcon, | |||
FlagSuccessIcon, | |||
FormField, | |||
InputField, | |||
Note, | |||
TextError, | |||
TrashIcon, | |||
} from 'design-system'; | |||
import { isEmpty } from 'lodash'; | |||
import * as React from 'react'; | |||
import { doesComponentExists } from '../../../../api/components'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { validateProjectKey } from '../../../../helpers/projects'; | |||
import { ProjectKeyValidationResult } from '../../../../types/component'; | |||
import { PROJECT_NAME_MAX_LEN } from '../constants'; | |||
import { getSanitizedProjectKey } from '../utils'; | |||
interface Props<I> { | |||
initialKey?: string; | |||
initialName?: string; | |||
monorepoSetupProjectKeys?: string[]; | |||
onChange: (project: ProjectData<I>) => void; | |||
onRemove?: () => void; | |||
projectId?: I; | |||
} | |||
interface State { | |||
name: string; | |||
nameError?: boolean; | |||
nameTouched: boolean; | |||
key: string; | |||
keyError?: ProjectKeyErrors; | |||
keyTouched: boolean; | |||
validatingKey: boolean; | |||
} | |||
export interface ProjectData<I = string> { | |||
hasError: boolean; | |||
id?: I; | |||
name: string; | |||
key: string; | |||
touched: boolean; | |||
} | |||
enum ProjectKeyErrors { | |||
DuplicateKey = 'DUPLICATE_KEY', | |||
MonorepoDuplicateKey = 'MONOREPO_DUPLICATE_KEY', | |||
WrongFormat = 'WRONG_FORMAT', | |||
} | |||
const DEBOUNCE_DELAY = 250; | |||
export default function ProjectValidation<I>(props: Readonly<Props<I>>) { | |||
const { | |||
initialKey = '', | |||
initialName = '', | |||
monorepoSetupProjectKeys, | |||
onChange, | |||
projectId, | |||
} = props; | |||
const checkFreeKeyTimeout = React.useRef<NodeJS.Timeout | undefined>(); | |||
const [project, setProject] = React.useState<State>({ | |||
key: initialKey, | |||
name: initialName, | |||
keyTouched: false, | |||
nameTouched: false, | |||
validatingKey: false, | |||
}); | |||
const { key, keyError, keyTouched, name, nameError, nameTouched, validatingKey } = project; | |||
React.useEffect(() => { | |||
onChange({ | |||
hasError: keyError !== undefined || nameError !== undefined, | |||
id: projectId, | |||
key, | |||
name, | |||
touched: keyTouched || nameTouched, | |||
}); | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [key, name, keyError, keyTouched, nameError, nameTouched]); | |||
const checkFreeKey = (keyVal: string) => { | |||
setProject((prevProject) => ({ ...prevProject, validatingKey: true })); | |||
doesComponentExists({ component: keyVal }) | |||
.then((alreadyExist) => { | |||
setProject((prevProject) => { | |||
if (keyVal === prevProject.key) { | |||
return { | |||
...prevProject, | |||
keyError: alreadyExist ? ProjectKeyErrors.DuplicateKey : undefined, | |||
validatingKey: false, | |||
}; | |||
} | |||
return prevProject; | |||
}); | |||
}) | |||
.catch(() => { | |||
setProject((prevProject) => { | |||
if (keyVal === prevProject.key) { | |||
return { | |||
...prevProject, | |||
keyError: undefined, | |||
validatingKey: false, | |||
}; | |||
} | |||
return prevProject; | |||
}); | |||
}); | |||
}; | |||
const handleProjectKeyChange = (projectKey: string, fromUI = false) => { | |||
const keyError = validateKey(projectKey); | |||
setProject((prevProject) => ({ | |||
...prevProject, | |||
key: projectKey, | |||
keyError, | |||
keyTouched: fromUI, | |||
})); | |||
}; | |||
React.useEffect(() => { | |||
if (nameTouched && !keyTouched) { | |||
const sanitizedProjectKey = getSanitizedProjectKey(name); | |||
handleProjectKeyChange(sanitizedProjectKey); | |||
} | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [name, keyTouched]); | |||
React.useEffect(() => { | |||
if (!keyError && key !== '') { | |||
checkFreeKeyTimeout.current = setTimeout(() => { | |||
checkFreeKey(key); | |||
checkFreeKeyTimeout.current = undefined; | |||
}, DEBOUNCE_DELAY); | |||
} | |||
return () => { | |||
if (checkFreeKeyTimeout.current !== undefined) { | |||
clearTimeout(checkFreeKeyTimeout.current); | |||
} | |||
}; | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [key]); | |||
React.useEffect(() => { | |||
if ( | |||
(keyError === undefined || keyError === ProjectKeyErrors.MonorepoDuplicateKey) && | |||
key !== '' | |||
) { | |||
if (monorepoSetupProjectKeys?.indexOf(key) !== monorepoSetupProjectKeys?.lastIndexOf(key)) { | |||
setProject((prevProject) => ({ | |||
...prevProject, | |||
keyError: ProjectKeyErrors.MonorepoDuplicateKey, | |||
})); | |||
} else { | |||
setProject((prevProject) => { | |||
if (prevProject.keyError === ProjectKeyErrors.MonorepoDuplicateKey) { | |||
return { | |||
...prevProject, | |||
keyError: undefined, | |||
}; | |||
} | |||
return prevProject; | |||
}); | |||
} | |||
} | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [monorepoSetupProjectKeys]); | |||
const handleProjectNameChange = (projectName: string, fromUI = false) => { | |||
setProject({ | |||
...project, | |||
name: projectName, | |||
nameError: validateName(projectName), | |||
nameTouched: fromUI, | |||
}); | |||
}; | |||
const validateKey = (projectKey: string) => { | |||
const result = validateProjectKey(projectKey); | |||
if (result !== ProjectKeyValidationResult.Valid) { | |||
return ProjectKeyErrors.WrongFormat; | |||
} | |||
return undefined; | |||
}; | |||
const validateName = (projectName: string) => { | |||
if (isEmpty(projectName)) { | |||
return true; | |||
} | |||
return undefined; | |||
}; | |||
const touched = Boolean(keyTouched || nameTouched); | |||
const projectNameIsInvalid = nameTouched && nameError !== undefined; | |||
const projectNameIsValid = nameTouched && nameError === undefined; | |||
const projectKeyIsInvalid = touched && keyError !== undefined; | |||
const projectKeyIsValid = touched && !validatingKey && keyError === undefined; | |||
const projectKeyInputId = projectId !== undefined ? `project-key-${projectId}` : 'project-key'; | |||
const projectNameInputId = projectId !== undefined ? `project-name-${projectId}` : 'project-name'; | |||
return ( | |||
<> | |||
<FormField | |||
htmlFor={projectNameInputId} | |||
label={translate('onboarding.create_project.display_name')} | |||
required | |||
> | |||
<div> | |||
<InputField | |||
className={classNames({ | |||
'js__is-invalid': projectNameIsInvalid, | |||
})} | |||
size="large" | |||
id={projectNameInputId} | |||
maxLength={PROJECT_NAME_MAX_LEN} | |||
minLength={1} | |||
onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)} | |||
type="text" | |||
value={name} | |||
autoFocus | |||
isInvalid={projectNameIsInvalid} | |||
isValid={projectNameIsValid} | |||
required | |||
/> | |||
{projectNameIsInvalid && <FlagErrorIcon className="sw-ml-2" />} | |||
{projectNameIsValid && <FlagSuccessIcon className="sw-ml-2" />} | |||
</div> | |||
{nameError !== undefined && ( | |||
<Note className="sw-mt-2"> | |||
{translate('onboarding.create_project.display_name.description')} | |||
</Note> | |||
)} | |||
</FormField> | |||
<FormField | |||
htmlFor={projectKeyInputId} | |||
label={translate('onboarding.create_project.project_key')} | |||
required | |||
> | |||
<div> | |||
<InputField | |||
className={classNames({ | |||
'js__is-invalid': projectKeyIsInvalid, | |||
})} | |||
size="large" | |||
id={projectKeyInputId} | |||
minLength={1} | |||
onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)} | |||
type="text" | |||
value={key} | |||
isInvalid={projectKeyIsInvalid} | |||
isValid={projectKeyIsValid} | |||
required | |||
/> | |||
{projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />} | |||
{projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />} | |||
</div> | |||
{keyError !== undefined && ( | |||
<Note className="sw-flex-col sw-mt-2"> | |||
{keyError === ProjectKeyErrors.DuplicateKey || | |||
(keyError === ProjectKeyErrors.MonorepoDuplicateKey && ( | |||
<TextError | |||
text={translate('onboarding.create_project.project_key.duplicate_key')} | |||
/> | |||
))} | |||
{!isEmpty(key) && keyError === ProjectKeyErrors.WrongFormat && ( | |||
<TextError text={translate('onboarding.create_project.project_key.wrong_format')} /> | |||
)} | |||
<p>{translate('onboarding.create_project.project_key.description')}</p> | |||
</Note> | |||
)} | |||
</FormField> | |||
</> | |||
); | |||
} | |||
export function ProjectValidationCard<I>({ | |||
initialKey, | |||
initialName, | |||
monorepoSetupProjectKeys, | |||
onChange, | |||
onRemove, | |||
projectId, | |||
...cardProps | |||
}: Readonly< | |||
Props<I> & Omit<React.ComponentPropsWithoutRef<typeof Card>, 'onChange' | 'children'> | |||
>) { | |||
return ( | |||
<Card {...cardProps}> | |||
<ProjectValidation | |||
initialKey={initialKey} | |||
initialName={initialName} | |||
monorepoSetupProjectKeys={monorepoSetupProjectKeys} | |||
onChange={onChange} | |||
projectId={projectId} | |||
/> | |||
<ButtonSecondary | |||
className="sw-mt-4 sw-mr-4" | |||
icon={<TrashIcon />} | |||
onClick={onRemove} | |||
type="button" | |||
> | |||
{translate('onboarding.create_project.monorepo.remove_project')} | |||
</ButtonSecondary> | |||
</Card> | |||
); | |||
} |
@@ -17,6 +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. | |||
*/ | |||
export const PROJECT_NAME_MAX_LEN = 255; | |||
export const DEFAULT_BBS_PAGE_SIZE = 25; |
@@ -30,21 +30,17 @@ import { | |||
InteractiveIcon, | |||
Link, | |||
Note, | |||
TextError, | |||
Title, | |||
} from 'design-system'; | |||
import { debounce, isEmpty } from 'lodash'; | |||
import { isEmpty } from 'lodash'; | |||
import * as React from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { doesComponentExists } from '../../../../api/components'; | |||
import { getValue } from '../../../../api/settings'; | |||
import { useDocUrl } from '../../../../helpers/docs'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { PROJECT_KEY_INVALID_CHARACTERS, validateProjectKey } from '../../../../helpers/projects'; | |||
import { ProjectKeyValidationResult } from '../../../../types/component'; | |||
import { GlobalSettingKeys } from '../../../../types/settings'; | |||
import { ImportProjectParam } from '../CreateProjectPage'; | |||
import { PROJECT_NAME_MAX_LEN } from '../constants'; | |||
import ProjectValidation, { ProjectData } from '../components/ProjectValidation'; | |||
import { CreateProjectModes } from '../types'; | |||
interface Props { | |||
@@ -53,94 +49,36 @@ interface Props { | |||
onClose: () => void; | |||
} | |||
interface State { | |||
projectName: string; | |||
projectNameError?: boolean; | |||
projectNameTouched: boolean; | |||
projectKey: string; | |||
projectKeyError?: 'DUPLICATE_KEY' | 'WRONG_FORMAT'; | |||
projectKeyTouched: boolean; | |||
validatingProjectKey: boolean; | |||
interface MainBranchState { | |||
mainBranchName: string; | |||
mainBranchNameError?: boolean; | |||
mainBranchNameTouched: boolean; | |||
} | |||
const DEBOUNCE_DELAY = 250; | |||
type ValidState = State & Required<Pick<State, 'projectKey' | 'projectName'>>; | |||
type ValidState = ProjectData & Required<Pick<ProjectData, 'key' | 'name'>>; | |||
export default function ManualProjectCreate(props: Readonly<Props>) { | |||
const [project, setProject] = React.useState<State>({ | |||
projectKey: '', | |||
projectName: '', | |||
projectKeyTouched: false, | |||
projectNameTouched: false, | |||
const [mainBranch, setMainBranch] = React.useState<MainBranchState>({ | |||
mainBranchName: 'main', | |||
mainBranchNameTouched: false, | |||
validatingProjectKey: false, | |||
}); | |||
const [project, setProject] = React.useState<ProjectData>({ | |||
hasError: false, | |||
key: '', | |||
name: '', | |||
touched: false, | |||
}); | |||
const intl = useIntl(); | |||
const docUrl = useDocUrl(); | |||
const checkFreeKey = React.useCallback( | |||
debounce((key: string) => { | |||
setProject((prevProject) => ({ ...prevProject, validatingProjectKey: true })); | |||
doesComponentExists({ component: key }) | |||
.then((alreadyExist) => { | |||
setProject((prevProject) => { | |||
if (key === prevProject.projectKey) { | |||
return { | |||
...prevProject, | |||
projectKeyError: alreadyExist ? 'DUPLICATE_KEY' : undefined, | |||
validatingProjectKey: false, | |||
}; | |||
} | |||
return prevProject; | |||
}); | |||
}) | |||
.catch(() => { | |||
setProject((prevProject) => { | |||
if (key === prevProject.projectKey) { | |||
return { | |||
...prevProject, | |||
projectKeyError: undefined, | |||
validatingProjectKey: false, | |||
}; | |||
} | |||
return prevProject; | |||
}); | |||
}); | |||
}, DEBOUNCE_DELAY), | |||
[], | |||
); | |||
const handleProjectKeyChange = React.useCallback( | |||
(projectKey: string, fromUI = false) => { | |||
const projectKeyError = validateKey(projectKey); | |||
setProject((prevProject) => ({ | |||
...prevProject, | |||
projectKey, | |||
projectKeyError, | |||
projectKeyTouched: fromUI, | |||
})); | |||
if (projectKeyError === undefined) { | |||
checkFreeKey(projectKey); | |||
} | |||
}, | |||
[checkFreeKey], | |||
); | |||
React.useEffect(() => { | |||
async function fetchMainBranchName() { | |||
const { value: mainBranchName } = await getValue({ key: GlobalSettingKeys.MainBranchName }); | |||
if (mainBranchName !== undefined) { | |||
setProject((prevProject) => ({ | |||
...prevProject, | |||
setMainBranch((prevBranchName) => ({ | |||
...prevBranchName, | |||
mainBranchName, | |||
})); | |||
} | |||
@@ -149,37 +87,25 @@ export default function ManualProjectCreate(props: Readonly<Props>) { | |||
fetchMainBranchName(); | |||
}, []); | |||
React.useEffect(() => { | |||
if (!project.projectKeyTouched) { | |||
const sanitizedProjectKey = project.projectName | |||
.trim() | |||
.replace(PROJECT_KEY_INVALID_CHARACTERS, '-'); | |||
handleProjectKeyChange(sanitizedProjectKey); | |||
} | |||
}, [project.projectName, project.projectKeyTouched, handleProjectKeyChange]); | |||
const canSubmit = (state: State): state is ValidState => { | |||
const { projectKey, projectKeyError, projectName, projectNameError, mainBranchName } = state; | |||
return Boolean( | |||
projectKeyError === undefined && | |||
projectNameError === undefined && | |||
!isEmpty(projectKey) && | |||
!isEmpty(projectName) && | |||
!isEmpty(mainBranchName), | |||
); | |||
const canSubmit = ( | |||
mainBranch: MainBranchState, | |||
projectData: ProjectData, | |||
): projectData is ValidState => { | |||
const { mainBranchName } = mainBranch; | |||
const { key, name, hasError } = projectData; | |||
return Boolean(!hasError && !isEmpty(key) && !isEmpty(name) && !isEmpty(mainBranchName)); | |||
}; | |||
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
const { projectKey, projectName, mainBranchName } = project; | |||
if (canSubmit(project)) { | |||
if (canSubmit(mainBranch, project)) { | |||
props.onProjectSetupDone({ | |||
creationMode: CreateProjectModes.Manual, | |||
monorepo: false, | |||
projects: [ | |||
{ | |||
project: projectKey, | |||
name: (projectName || projectKey).trim(), | |||
project: project.key, | |||
name: (project.name ?? project.key).trim(), | |||
mainBranch: mainBranchName, | |||
}, | |||
], | |||
@@ -187,39 +113,14 @@ export default function ManualProjectCreate(props: Readonly<Props>) { | |||
} | |||
}; | |||
const handleProjectNameChange = (projectName: string, fromUI = false) => { | |||
setProject({ | |||
...project, | |||
projectName, | |||
projectNameError: validateName(projectName), | |||
projectNameTouched: fromUI, | |||
}); | |||
}; | |||
const handleBranchNameChange = (mainBranchName: string, fromUI = false) => { | |||
setProject({ | |||
...project, | |||
setMainBranch({ | |||
mainBranchName, | |||
mainBranchNameError: validateMainBranchName(mainBranchName), | |||
mainBranchNameTouched: fromUI, | |||
}); | |||
}; | |||
const validateKey = (projectKey: string) => { | |||
const result = validateProjectKey(projectKey); | |||
if (result !== ProjectKeyValidationResult.Valid) { | |||
return 'WRONG_FORMAT'; | |||
} | |||
return undefined; | |||
}; | |||
const validateName = (projectName: string) => { | |||
if (isEmpty(projectName)) { | |||
return true; | |||
} | |||
return undefined; | |||
}; | |||
const validateMainBranchName = (mainBranchName: string) => { | |||
if (isEmpty(mainBranchName)) { | |||
return true; | |||
@@ -227,25 +128,9 @@ export default function ManualProjectCreate(props: Readonly<Props>) { | |||
return undefined; | |||
}; | |||
const { | |||
projectKey, | |||
projectKeyError, | |||
projectKeyTouched, | |||
projectName, | |||
projectNameError, | |||
projectNameTouched, | |||
validatingProjectKey, | |||
mainBranchName, | |||
mainBranchNameError, | |||
mainBranchNameTouched, | |||
} = project; | |||
const { mainBranchName, mainBranchNameError, mainBranchNameTouched } = mainBranch; | |||
const { branchesEnabled } = props; | |||
const touched = Boolean(projectKeyTouched || projectNameTouched); | |||
const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined; | |||
const projectNameIsValid = projectNameTouched && projectNameError === undefined; | |||
const projectKeyIsInvalid = touched && projectKeyError !== undefined; | |||
const projectKeyIsValid = touched && !validatingProjectKey && projectKeyError === undefined; | |||
const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined; | |||
const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined; | |||
@@ -279,71 +164,7 @@ export default function ManualProjectCreate(props: Readonly<Props>) { | |||
className="sw-flex-col sw-body-sm" | |||
onSubmit={handleFormSubmit} | |||
> | |||
<FormField | |||
htmlFor="project-name" | |||
label={translate('onboarding.create_project.display_name')} | |||
required | |||
> | |||
<div> | |||
<InputField | |||
className={classNames({ | |||
'js__is-invalid': projectNameIsInvalid, | |||
})} | |||
size="large" | |||
id="project-name" | |||
maxLength={PROJECT_NAME_MAX_LEN} | |||
minLength={1} | |||
onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)} | |||
type="text" | |||
value={projectName} | |||
autoFocus | |||
isInvalid={projectNameIsInvalid} | |||
isValid={projectNameIsValid} | |||
required | |||
/> | |||
{projectNameIsInvalid && <FlagErrorIcon className="sw-ml-2" />} | |||
{projectNameIsValid && <FlagSuccessIcon className="sw-ml-2" />} | |||
</div> | |||
<Note className="sw-mt-2"> | |||
{translate('onboarding.create_project.display_name.description')} | |||
</Note> | |||
</FormField> | |||
<FormField | |||
htmlFor="project-key" | |||
label={translate('onboarding.create_project.project_key')} | |||
required | |||
> | |||
<div> | |||
<InputField | |||
className={classNames({ | |||
'js__is-invalid': projectKeyIsInvalid, | |||
})} | |||
size="large" | |||
id="project-key" | |||
minLength={1} | |||
onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)} | |||
type="text" | |||
value={projectKey} | |||
isInvalid={projectKeyIsInvalid} | |||
isValid={projectKeyIsValid} | |||
required | |||
/> | |||
{projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />} | |||
{projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />} | |||
</div> | |||
<Note className="sw-flex-col sw-mt-2"> | |||
{projectKeyError === 'DUPLICATE_KEY' && ( | |||
<TextError | |||
text={translate('onboarding.create_project.project_key.duplicate_key')} | |||
/> | |||
)} | |||
{!isEmpty(projectKey) && projectKeyError === 'WRONG_FORMAT' && ( | |||
<TextError text={translate('onboarding.create_project.project_key.wrong_format')} /> | |||
)} | |||
<p>{translate('onboarding.create_project.project_key.description')}</p> | |||
</Note> | |||
</FormField> | |||
<ProjectValidation onChange={setProject} /> | |||
<FormField | |||
htmlFor="main-branch-name" | |||
@@ -386,7 +207,11 @@ export default function ManualProjectCreate(props: Readonly<Props>) { | |||
<ButtonSecondary className="sw-mt-4 sw-mr-4" onClick={props.onClose} type="button"> | |||
{intl.formatMessage({ id: 'cancel' })} | |||
</ButtonSecondary> | |||
<ButtonPrimary className="sw-mt-4" type="submit" disabled={!canSubmit(project)}> | |||
<ButtonPrimary | |||
className="sw-mt-4" | |||
type="submit" | |||
disabled={!canSubmit(mainBranch, project)} | |||
> | |||
{translate('next')} | |||
</ButtonPrimary> | |||
</form> |
@@ -0,0 +1,362 @@ | |||
/* | |||
* 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 { Link, Spinner } from '@sonarsource/echoes-react'; | |||
import { | |||
AddNewIcon, | |||
BlueGreySeparator, | |||
ButtonPrimary, | |||
ButtonSecondary, | |||
DarkLabel, | |||
FlagMessage, | |||
InputSelect, | |||
SubTitle, | |||
Title, | |||
} from 'design-system'; | |||
import React, { useEffect, useRef } from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { useLocation, useRouter } from '../../../../components/hoc/withRouter'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { LabelValueSelectOption } from '../../../../helpers/search'; | |||
import { AlmKeys } from '../../../../types/alm-settings'; | |||
import { DopSetting } from '../../../../types/dop-translation'; | |||
import { ImportProjectParam } from '../CreateProjectPage'; | |||
import DopSettingDropdown from '../components/DopSettingDropdown'; | |||
import { ProjectData, ProjectValidationCard } from '../components/ProjectValidation'; | |||
import { CreateProjectModes } from '../types'; | |||
import { getSanitizedProjectKey } from '../utils'; | |||
import { MonorepoProjectHeader } from './MonorepoProjectHeader'; | |||
interface MonorepoProjectCreateProps { | |||
canAdmin: boolean; | |||
dopSettings: DopSetting[]; | |||
error: boolean; | |||
loadingBindings: boolean; | |||
loadingOrganizations: boolean; | |||
loadingRepositories: boolean; | |||
onProjectSetupDone: (importProjects: ImportProjectParam) => void; | |||
onSearchRepositories: (query: string) => void; | |||
onSelectDopSetting: (instance: DopSetting) => void; | |||
onSelectOrganization: (organizationKey: string) => void; | |||
onSelectRepository: (repositoryIdentifier: string) => void; | |||
organizationOptions?: LabelValueSelectOption[]; | |||
repositoryOptions?: LabelValueSelectOption[]; | |||
repositorySearchQuery: string; | |||
selectedDopSetting?: DopSetting; | |||
selectedOrganization?: LabelValueSelectOption; | |||
selectedRepository?: LabelValueSelectOption; | |||
} | |||
type ProjectItem = Required<ProjectData<number>>; | |||
export default function MonorepoProjectCreate(props: Readonly<MonorepoProjectCreateProps>) { | |||
const { | |||
dopSettings, | |||
canAdmin, | |||
error, | |||
loadingBindings, | |||
loadingOrganizations, | |||
loadingRepositories, | |||
onProjectSetupDone, | |||
onSearchRepositories, | |||
onSelectDopSetting, | |||
onSelectOrganization, | |||
onSelectRepository, | |||
organizationOptions, | |||
repositoryOptions, | |||
repositorySearchQuery, | |||
selectedDopSetting, | |||
selectedOrganization, | |||
selectedRepository, | |||
} = props; | |||
const projectCounter = useRef(0); | |||
const [projects, setProjects] = React.useState<ProjectItem[]>([]); | |||
const location = useLocation(); | |||
const { push } = useRouter(); | |||
const { formatMessage } = useIntl(); | |||
const projectKeys = React.useMemo(() => projects.map(({ key }) => key), [projects]); | |||
const almKey = location.query.mode as AlmKeys; | |||
const isSetupInvalid = | |||
selectedDopSetting === undefined || | |||
selectedOrganization === undefined || | |||
selectedRepository === undefined || | |||
projects.length === 0 || | |||
projects.some(({ hasError, key, name }) => hasError || key === '' || name === ''); | |||
const addProject = () => { | |||
if (selectedOrganization === undefined || selectedRepository === undefined) { | |||
return; | |||
} | |||
const id = projectCounter.current; | |||
projectCounter.current += 1; | |||
const projectKeySuffix = id === 0 ? '' : `-${id}`; | |||
const projectKey = getSanitizedProjectKey( | |||
`${selectedOrganization.label}_${selectedRepository.label}_add-your-reference${projectKeySuffix}`, | |||
); | |||
const newProjects = [ | |||
...projects, | |||
{ | |||
hasError: false, | |||
id, | |||
key: projectKey, | |||
name: projectKey, | |||
touched: false, | |||
}, | |||
]; | |||
setProjects(newProjects); | |||
}; | |||
const onProjectChange = (project: ProjectItem) => { | |||
const newProjects = projects.filter(({ id }) => id !== project.id); | |||
newProjects.push({ | |||
...project, | |||
}); | |||
newProjects.sort((a, b) => a.id - b.id); | |||
setProjects(newProjects); | |||
}; | |||
const onProjectRemove = (id: number) => { | |||
const newProjects = projects.filter(({ id: projectId }) => projectId !== id); | |||
setProjects(newProjects); | |||
}; | |||
const cancelMonorepoSetup = () => { | |||
push({ | |||
pathname: location.pathname, | |||
query: { mode: AlmKeys.GitHub }, | |||
}); | |||
}; | |||
const submitProjects = () => { | |||
if (isSetupInvalid) { | |||
return; | |||
} | |||
const monorepoSetup: ImportProjectParam = { | |||
creationMode: almKey as unknown as CreateProjectModes, | |||
devOpsPlatformSettingId: selectedDopSetting.id, | |||
monorepo: true, | |||
projects: projects.map(({ key: projectKey, name: projectName }) => ({ | |||
projectKey, | |||
projectName, | |||
})), | |||
repositoryIdentifier: selectedRepository.value, | |||
}; | |||
onProjectSetupDone(monorepoSetup); | |||
}; | |||
useEffect(() => { | |||
if (selectedRepository !== undefined && projects.length === 0) { | |||
addProject(); | |||
} | |||
// eslint-disable-next-line react-hooks/exhaustive-deps | |||
}, [selectedRepository]); | |||
if (loadingBindings) { | |||
return <Spinner />; | |||
} | |||
return ( | |||
<div> | |||
<MonorepoProjectHeader /> | |||
<BlueGreySeparator className="sw-my-5" /> | |||
<div className="sw-flex sw-flex-col sw-gap-6"> | |||
<Title> | |||
<FormattedMessage | |||
id={`onboarding.create_project.monorepo.choose_organization_and_repository.${almKey}`} | |||
/> | |||
</Title> | |||
<DopSettingDropdown | |||
almKey={almKey} | |||
dopSettings={dopSettings} | |||
selectedDopSetting={selectedDopSetting} | |||
onChangeSetting={onSelectDopSetting} | |||
/> | |||
{error && selectedDopSetting && !loadingOrganizations && ( | |||
<FlagMessage variant="warning"> | |||
<span> | |||
{canAdmin ? ( | |||
<FormattedMessage | |||
id="onboarding.create_project.github.warning.message_admin" | |||
defaultMessage={translate( | |||
'onboarding.create_project.github.warning.message_admin', | |||
)} | |||
values={{ | |||
link: ( | |||
<Link to="/admin/settings?category=almintegration"> | |||
{translate('onboarding.create_project.github.warning.message_admin.link')} | |||
</Link> | |||
), | |||
}} | |||
/> | |||
) : ( | |||
translate('onboarding.create_project.github.warning.message') | |||
)} | |||
</span> | |||
</FlagMessage> | |||
)} | |||
<div className="sw-flex sw-flex-col"> | |||
<Spinner isLoading={loadingOrganizations && !error}> | |||
{!error && ( | |||
<> | |||
<DarkLabel htmlFor="monorepo-choose-organization" className="sw-mb-2"> | |||
<FormattedMessage | |||
id={`onboarding.create_project.monorepo.choose_organization.${almKey}`} | |||
/> | |||
</DarkLabel> | |||
{(organizationOptions?.length ?? 0) > 0 ? ( | |||
<InputSelect | |||
size="full" | |||
isSearchable | |||
inputId="monorepo-choose-organization" | |||
options={organizationOptions} | |||
onChange={({ value }: LabelValueSelectOption) => { | |||
onSelectOrganization(value); | |||
}} | |||
placeholder={formatMessage({ | |||
id: `onboarding.create_project.monorepo.choose_organization.${almKey}.placeholder`, | |||
})} | |||
value={selectedOrganization} | |||
/> | |||
) : ( | |||
!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> | |||
<div className="sw-flex sw-flex-col"> | |||
{selectedOrganization && ( | |||
<DarkLabel className="sw-mb-2" htmlFor="monorepo-choose-repository"> | |||
<FormattedMessage | |||
id={`onboarding.create_project.monorepo.choose_repository.${almKey}`} | |||
/> | |||
</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} | |||
/> | |||
)} | |||
</div> | |||
</div> | |||
{selectedRepository !== undefined && ( | |||
<> | |||
<BlueGreySeparator className="sw-my-5" /> | |||
<div> | |||
<SubTitle> | |||
<FormattedMessage id="onboarding.create_project.monorepo.project_title" /> | |||
</SubTitle> | |||
<div> | |||
{projects.map(({ id, key, name }) => ( | |||
<ProjectValidationCard | |||
className="sw-mt-4" | |||
initialKey={key} | |||
initialName={name} | |||
key={id} | |||
monorepoSetupProjectKeys={projectKeys} | |||
onChange={onProjectChange} | |||
onRemove={() => { | |||
onProjectRemove(id); | |||
}} | |||
projectId={id} | |||
/> | |||
))} | |||
</div> | |||
<div className="sw-flex sw-justify-end sw-mt-4"> | |||
<ButtonSecondary onClick={addProject}> | |||
<AddNewIcon className="sw-mr-2" /> | |||
<FormattedMessage id="onboarding.create_project.monorepo.add_project" /> | |||
</ButtonSecondary> | |||
</div> | |||
</div> | |||
</> | |||
)} | |||
<div className="sw-my-5"> | |||
<ButtonSecondary onClick={cancelMonorepoSetup}> | |||
<FormattedMessage id="cancel" /> | |||
</ButtonSecondary> | |||
<ButtonPrimary className="sw-ml-3" disabled={isSetupInvalid} onClick={submitProjects}> | |||
<FormattedMessage id="next" /> | |||
</ButtonPrimary> | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,54 @@ | |||
/* | |||
* 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 { LinkStandalone } from '@sonarsource/echoes-react'; | |||
import { LightPrimary, Title } from 'design-system/lib'; | |||
import React from 'react'; | |||
import { FormattedMessage, useIntl } from 'react-intl'; | |||
import { useLocation } from '../../../../components/hoc/withRouter'; | |||
import { useDocUrl } from '../../../../helpers/docs'; | |||
export function MonorepoProjectHeader() { | |||
const { formatMessage } = useIntl(); | |||
const { query } = useLocation(); | |||
const almKey = query.mode as string; | |||
return ( | |||
<> | |||
<Title> | |||
<FormattedMessage | |||
id="onboarding.create_project.monorepo.title" | |||
values={{ | |||
almName: formatMessage({ id: `alm.${almKey}` }), | |||
}} | |||
/> | |||
</Title> | |||
<div> | |||
<LightPrimary> | |||
<FormattedMessage id="onboarding.create_project.monorepo.subtitle" /> | |||
</LightPrimary> | |||
</div> | |||
<div className="sw-mt-3"> | |||
<LinkStandalone isExternal to={useDocUrl('/project-administration/monorepos/')}> | |||
<FormattedMessage id="onboarding.create_project.monorepo.doc_link" /> | |||
</LinkStandalone> | |||
</div> | |||
</> | |||
); | |||
} |
@@ -24,5 +24,4 @@ export enum CreateProjectModes { | |||
BitbucketCloud = 'bitbucketcloud', | |||
GitHub = 'github', | |||
GitLab = 'gitlab', | |||
Monorepo = 'monorepo', | |||
} |
@@ -17,6 +17,13 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { PROJECT_KEY_INVALID_CHARACTERS } from '../../../helpers/projects'; | |||
export function tokenExistedBefore(error?: string) { | |||
return error?.includes('is missing'); | |||
} | |||
export function getSanitizedProjectKey(projectKey: string) { | |||
return projectKey.trim().replace(PROJECT_KEY_INVALID_CHARACTERS, '-'); | |||
} |
@@ -24,7 +24,6 @@ import withAvailableFeatures, { | |||
WithAvailableFeaturesProps, | |||
} from '../../../../app/components/available-features/withAvailableFeatures'; | |||
import DocumentationLink from '../../../../components/common/DocumentationLink'; | |||
import { ALM_DOCUMENTATION_PATHS } from '../../../../helpers/constants'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { convertGithubApiUrlToLink, stripTrailingSlash } from '../../../../helpers/urls'; | |||
import { | |||
@@ -294,7 +293,7 @@ export function AlmSpecificForm(props: AlmSpecificFormProps) { | |||
help: true, | |||
helpParams: { | |||
doc_link: ( | |||
<DocumentationLink to={ALM_DOCUMENTATION_PATHS[alm]}> | |||
<DocumentationLink to="/project-administration/monorepos/"> | |||
{translate('learn_more')} | |||
</DocumentationLink> | |||
), |
@@ -33,14 +33,14 @@ import { Location, withRouter } from '../hoc/withRouter'; | |||
import TutorialSelectionRenderer from './TutorialSelectionRenderer'; | |||
import { TutorialModes } from './types'; | |||
interface Props { | |||
export interface TutorialSelectionProps { | |||
component: Component; | |||
currentUser: LoggedInUser; | |||
willRefreshAutomatically?: boolean; | |||
location: Location; | |||
} | |||
export function TutorialSelection(props: Props) { | |||
export function TutorialSelection(props: Readonly<TutorialSelectionProps>) { | |||
const { component, currentUser, location, willRefreshAutomatically } = props; | |||
const [currentUserCanScanProject, setCurrentUserCanScanProject] = React.useState(false); | |||
const [baseUrl, setBaseUrl] = React.useState(getHostUrl()); |
@@ -258,6 +258,7 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender | |||
baseUrl={baseUrl} | |||
component={component} | |||
currentUser={currentUser} | |||
monorepo={projectBinding?.monorepo} | |||
mainBranchName={mainBranchName} | |||
willRefreshAutomatically={willRefreshAutomatically} | |||
/> |
@@ -29,12 +29,11 @@ import { mockComponent } from '../../../helpers/mocks/component'; | |||
import { mockLoggedInUser } from '../../../helpers/testMocks'; | |||
import { renderApp } from '../../../helpers/testReactTestingUtils'; | |||
import { byRole, byText } from '../../../helpers/testSelector'; | |||
import { ComponentPropsType } from '../../../helpers/testUtils'; | |||
import { AlmKeys } from '../../../types/alm-settings'; | |||
import { Feature } from '../../../types/features'; | |||
import { Permissions } from '../../../types/permissions'; | |||
import { SettingsKey } from '../../../types/settings'; | |||
import TutorialSelection from '../TutorialSelection'; | |||
import TutorialSelection, { TutorialSelectionProps } from '../TutorialSelection'; | |||
import { TutorialModes } from '../types'; | |||
jest.mock('../../../api/branches'); | |||
@@ -71,8 +70,14 @@ beforeEach(() => { | |||
const ui = { | |||
loading: byText('loading'), | |||
noScanRights: byText('onboarding.tutorial.no_scan_rights'), | |||
monoRepoSecretInfo: byText('onboarding.tutorial.with.github_action.create_secret.monorepo_info'), | |||
monoRepoYamlDocLink: byRole('link', { | |||
name: 'onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions', | |||
}), | |||
chooseTutorialLink: (mode: TutorialModes) => | |||
byRole('link', { name: `onboarding.tutorial.choose_method.${mode}` }), | |||
chooseBootstrapper: (bootstrapper: string) => | |||
byRole('radio', { name: `onboarding.build.${bootstrapper}` }), | |||
}; | |||
it.each([ | |||
@@ -100,6 +105,54 @@ it.each([ | |||
expect(screen.getByText(breadcrumbs)).toBeInTheDocument(); | |||
}); | |||
it('should properly detect and render GitHub monorepo-specific instructions for GitHub Actions', async () => { | |||
almMock.handleSetProjectBinding(AlmKeys.GitHub, { | |||
project: 'foo', | |||
almSetting: 'foo', | |||
repository: 'repo', | |||
monorepo: true, | |||
}); | |||
const user = userEvent.setup(); | |||
renderTutorialSelection({}); | |||
await waitOnDataLoaded(); | |||
await user.click(ui.chooseTutorialLink(TutorialModes.GitHubActions).get()); | |||
expect(ui.monoRepoSecretInfo.get()).toBeInTheDocument(); | |||
expect(ui.monoRepoYamlDocLink.query()).not.toBeInTheDocument(); | |||
await user.click(ui.chooseBootstrapper('maven').get()); | |||
expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); | |||
await user.click(ui.chooseBootstrapper('gradle').get()); | |||
expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); | |||
await user.click(ui.chooseBootstrapper('dotnet').get()); | |||
expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); | |||
await user.click(ui.chooseBootstrapper('other').get()); | |||
expect(ui.monoRepoYamlDocLink.get()).toBeInTheDocument(); | |||
}); | |||
it('should properly render GitHub project tutorials for GitHub Actions', async () => { | |||
almMock.handleSetProjectBinding(AlmKeys.GitHub, { | |||
project: 'foo', | |||
almSetting: 'foo', | |||
repository: 'repo', | |||
monorepo: false, | |||
}); | |||
const user = userEvent.setup(); | |||
renderTutorialSelection({}); | |||
await waitOnDataLoaded(); | |||
await user.click(ui.chooseTutorialLink(TutorialModes.GitHubActions).get()); | |||
expect(ui.monoRepoSecretInfo.query()).not.toBeInTheDocument(); | |||
await user.click(ui.chooseBootstrapper('maven').get()); | |||
expect(ui.monoRepoYamlDocLink.query()).not.toBeInTheDocument(); | |||
}); | |||
it.each([ | |||
[ | |||
AlmKeys.GitHub, | |||
@@ -189,7 +242,7 @@ async function startLocalTutorial(user: UserEvent) { | |||
} | |||
function renderTutorialSelection( | |||
props: Partial<ComponentPropsType<typeof TutorialSelection>> = {}, | |||
props: Partial<TutorialSelectionProps> = {}, | |||
navigateTo: string = 'tutorials?id=bar', | |||
) { | |||
return renderApp( |
@@ -24,17 +24,19 @@ import SentenceWithFilename from './SentenceWithFilename'; | |||
export interface DefaultProjectKeyProps { | |||
component: Component; | |||
monorepo?: boolean; | |||
} | |||
const sonarProjectSnippet = (key: string) => `sonar.projectKey=${key}`; | |||
export default function DefaultProjectKey(props: DefaultProjectKeyProps) { | |||
const { component } = props; | |||
const { component, monorepo } = props; | |||
return ( | |||
<NumberedListItem> | |||
<SentenceWithFilename | |||
filename="sonar-project.properties" | |||
translationKey="onboarding.tutorial.other.project_key" | |||
translationKey={`onboarding.tutorial.other.project_key${monorepo ? '.monorepo' : ''}`} | |||
/> | |||
<CodeSnippet snippet={sonarProjectSnippet(component.key)} isOneLine className="sw-p-6" /> | |||
</NumberedListItem> |
@@ -34,10 +34,11 @@ export interface AnalysisCommandProps extends WithAvailableFeaturesProps { | |||
buildTool: BuildTools; | |||
mainBranchName: string; | |||
component: Component; | |||
monorepo?: boolean; | |||
} | |||
export function AnalysisCommand(props: AnalysisCommandProps) { | |||
const { buildTool, component, mainBranchName } = props; | |||
export function AnalysisCommand(props: Readonly<AnalysisCommandProps>) { | |||
const { buildTool, component, mainBranchName, monorepo } = props; | |||
const branchSupportEnabled = props.hasFeature(Feature.BranchSupport); | |||
switch (buildTool) { | |||
@@ -46,6 +47,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { | |||
<JavaMaven | |||
branchesEnabled={branchSupportEnabled} | |||
mainBranchName={mainBranchName} | |||
monorepo={monorepo} | |||
component={component} | |||
/> | |||
); | |||
@@ -54,6 +56,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { | |||
<Gradle | |||
branchesEnabled={branchSupportEnabled} | |||
mainBranchName={mainBranchName} | |||
monorepo={monorepo} | |||
component={component} | |||
/> | |||
); | |||
@@ -62,6 +65,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { | |||
<DotNet | |||
branchesEnabled={branchSupportEnabled} | |||
mainBranchName={mainBranchName} | |||
monorepo={monorepo} | |||
component={component} | |||
/> | |||
); | |||
@@ -70,6 +74,7 @@ export function AnalysisCommand(props: AnalysisCommandProps) { | |||
<CFamily | |||
branchesEnabled={branchSupportEnabled} | |||
mainBranchName={mainBranchName} | |||
monorepo={monorepo} | |||
component={component} | |||
/> | |||
); | |||
@@ -78,9 +83,12 @@ export function AnalysisCommand(props: AnalysisCommandProps) { | |||
<Others | |||
branchesEnabled={branchSupportEnabled} | |||
mainBranchName={mainBranchName} | |||
monorepo={monorepo} | |||
component={component} | |||
/> | |||
); | |||
default: | |||
return undefined; | |||
} | |||
} | |||
@@ -33,27 +33,36 @@ export interface GitHubActionTutorialProps { | |||
baseUrl: string; | |||
component: Component; | |||
currentUser: LoggedInUser; | |||
monorepo?: boolean; | |||
mainBranchName: string; | |||
willRefreshAutomatically?: boolean; | |||
} | |||
export default function GitHubActionTutorial(props: GitHubActionTutorialProps) { | |||
const [done, setDone] = React.useState<boolean>(false); | |||
const { almBinding, baseUrl, currentUser, component, mainBranchName, willRefreshAutomatically } = | |||
props; | |||
const { | |||
almBinding, | |||
baseUrl, | |||
currentUser, | |||
component, | |||
monorepo, | |||
mainBranchName, | |||
willRefreshAutomatically, | |||
} = props; | |||
const secretStepTitle = `onboarding.tutorial.with.github_action.create_secret.title${monorepo ? '.monorepo' : ''}`; | |||
return ( | |||
<> | |||
<Title>{translate('onboarding.tutorial.with.github_ci.title')}</Title> | |||
<TutorialStepList className="sw-mb-8"> | |||
<TutorialStep | |||
title={translate('onboarding.tutorial.with.github_action.create_secret.title')} | |||
> | |||
<TutorialStep title={translate(secretStepTitle)}> | |||
<SecretStep | |||
almBinding={almBinding} | |||
baseUrl={baseUrl} | |||
component={component} | |||
currentUser={currentUser} | |||
monorepo={monorepo} | |||
/> | |||
</TutorialStep> | |||
<TutorialStep title={translate('onboarding.tutorial.with.github_action.yaml.title')}> | |||
@@ -63,6 +72,7 @@ export default function GitHubActionTutorial(props: GitHubActionTutorialProps) { | |||
buildTool={buildTool} | |||
mainBranchName={mainBranchName} | |||
component={component} | |||
monorepo={monorepo} | |||
/> | |||
)} | |||
</YamlFileStep> |
@@ -20,6 +20,7 @@ | |||
import { | |||
BasicSeparator, | |||
ClipboardIconButton, | |||
FlagMessage, | |||
NumberedList, | |||
NumberedListItem, | |||
StandoutLink, | |||
@@ -41,10 +42,11 @@ export interface SecretStepProps { | |||
baseUrl: string; | |||
component: Component; | |||
currentUser: LoggedInUser; | |||
monorepo?: boolean; | |||
} | |||
export default function SecretStep(props: SecretStepProps) { | |||
const { almBinding, baseUrl, component, currentUser } = props; | |||
const { almBinding, baseUrl, component, currentUser, monorepo } = props; | |||
const { data: projectBinding } = useProjectBindingQuery(component.key); | |||
return ( | |||
@@ -132,6 +134,11 @@ export default function SecretStep(props: SecretStepProps) { | |||
/> | |||
</NumberedListItem> | |||
</NumberedList> | |||
{monorepo && ( | |||
<FlagMessage variant="info" className="sw-block sw-w-fit sw-mt-4"> | |||
{translate('onboarding.tutorial.with.github_action.create_secret.monorepo_info')} | |||
</FlagMessage> | |||
)} | |||
</> | |||
); | |||
} |
@@ -28,10 +28,12 @@ import GithubCFamilyExampleRepositories from '../../components/GithubCFamilyExam | |||
import RenderOptions from '../../components/RenderOptions'; | |||
import { OSs, TutorialModes } from '../../types'; | |||
import { generateGitHubActionsYaml } from '../utils'; | |||
import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; | |||
export interface CFamilyProps { | |||
branchesEnabled?: boolean; | |||
mainBranchName: string; | |||
monorepo?: boolean; | |||
component: Component; | |||
} | |||
@@ -84,7 +86,7 @@ const STEPS = { | |||
}; | |||
export default function CFamily(props: CFamilyProps) { | |||
const { component, branchesEnabled, mainBranchName } = props; | |||
const { component, branchesEnabled, mainBranchName, monorepo } = props; | |||
const [os, setOs] = React.useState<undefined | OSs>(OSs.Linux); | |||
const runsOn = { | |||
@@ -94,7 +96,7 @@ export default function CFamily(props: CFamilyProps) { | |||
}; | |||
return ( | |||
<> | |||
<DefaultProjectKey component={component} /> | |||
<DefaultProjectKey component={component} monorepo={monorepo} /> | |||
<NumberedListItem> | |||
<span>{translate('onboarding.build.other.os')}</span> | |||
<RenderOptions | |||
@@ -112,22 +114,25 @@ export default function CFamily(props: CFamilyProps) { | |||
/> | |||
)} | |||
</NumberedListItem> | |||
{os && ( | |||
<> | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" | |||
yamlTemplate={generateGitHubActionsYaml( | |||
mainBranchName, | |||
!!branchesEnabled, | |||
runsOn[os], | |||
STEPS[os], | |||
`env: | |||
{os && | |||
(monorepo ? ( | |||
<MonorepoDocLinkFallback /> | |||
) : ( | |||
<> | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" | |||
yamlTemplate={generateGitHubActionsYaml( | |||
mainBranchName, | |||
!!branchesEnabled, | |||
runsOn[os], | |||
STEPS[os], | |||
`env: | |||
BUILD_WRAPPER_OUT_DIR: build_wrapper_output_directory # Directory where build-wrapper output will be placed`, | |||
)} | |||
/> | |||
<CompilationInfo /> | |||
</> | |||
)} | |||
)} | |||
/> | |||
<CompilationInfo /> | |||
</> | |||
))} | |||
</> | |||
); | |||
} |
@@ -22,10 +22,12 @@ import { Component } from '../../../../types/types'; | |||
import CreateYmlFile from '../../components/CreateYmlFile'; | |||
import { GITHUB_ACTIONS_RUNS_ON_WINDOWS } from '../constants'; | |||
import { generateGitHubActionsYaml } from '../utils'; | |||
import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; | |||
export interface DotNetProps { | |||
branchesEnabled?: boolean; | |||
mainBranchName: string; | |||
monorepo?: boolean; | |||
component: Component; | |||
} | |||
@@ -63,7 +65,12 @@ function dotnetYamlSteps(projectKey: string) { | |||
} | |||
export default function DotNet(props: DotNetProps) { | |||
const { component, branchesEnabled, mainBranchName } = props; | |||
const { component, branchesEnabled, mainBranchName, monorepo } = props; | |||
if (monorepo) { | |||
return <MonorepoDocLinkFallback />; | |||
} | |||
return ( | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" |
@@ -23,10 +23,12 @@ import CreateYmlFile from '../../components/CreateYmlFile'; | |||
import GradleBuild from '../../components/GradleBuild'; | |||
import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants'; | |||
import { generateGitHubActionsYaml } from '../utils'; | |||
import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; | |||
export interface GradleProps { | |||
branchesEnabled?: boolean; | |||
mainBranchName: string; | |||
monorepo?: boolean; | |||
component: Component; | |||
} | |||
@@ -54,20 +56,25 @@ const GRADLE_YAML_STEPS = ` | |||
run: ./gradlew build sonar --info`; | |||
export default function Gradle(props: GradleProps) { | |||
const { component, branchesEnabled, mainBranchName } = props; | |||
const { component, branchesEnabled, mainBranchName, monorepo } = props; | |||
return ( | |||
<> | |||
<GradleBuild component={component} /> | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" | |||
yamlTemplate={generateGitHubActionsYaml( | |||
mainBranchName, | |||
!!branchesEnabled, | |||
GITHUB_ACTIONS_RUNS_ON_LINUX, | |||
GRADLE_YAML_STEPS, | |||
)} | |||
/> | |||
{monorepo ? ( | |||
<MonorepoDocLinkFallback /> | |||
) : ( | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" | |||
yamlTemplate={generateGitHubActionsYaml( | |||
mainBranchName, | |||
!!branchesEnabled, | |||
GITHUB_ACTIONS_RUNS_ON_LINUX, | |||
GRADLE_YAML_STEPS, | |||
)} | |||
/> | |||
)} | |||
</> | |||
); | |||
} |
@@ -22,10 +22,12 @@ import { Component } from '../../../../types/types'; | |||
import CreateYmlFile from '../../components/CreateYmlFile'; | |||
import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants'; | |||
import { generateGitHubActionsYaml } from '../utils'; | |||
import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; | |||
export interface JavaMavenProps { | |||
branchesEnabled?: boolean; | |||
mainBranchName: string; | |||
monorepo?: boolean; | |||
component: Component; | |||
} | |||
@@ -55,7 +57,12 @@ function mavenYamlSteps(projectKey: string, projectName: string) { | |||
} | |||
export default function JavaMaven(props: JavaMavenProps) { | |||
const { component, branchesEnabled, mainBranchName } = props; | |||
const { component, branchesEnabled, mainBranchName, monorepo } = props; | |||
if (monorepo) { | |||
return <MonorepoDocLinkFallback />; | |||
} | |||
return ( | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" |
@@ -0,0 +1,37 @@ | |||
/* | |||
* 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 { NumberedListItem } from 'design-system'; | |||
import * as React from 'react'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import DocumentationLink from '../../../common/DocumentationLink'; | |||
const MONOREPO_DOC = | |||
'/devops-platform-integration/github-integration/monorepo/#workflow-file-example'; | |||
export default function MonorepoDocLinkFallback() { | |||
return ( | |||
<NumberedListItem> | |||
<DocumentationLink className="sw-mt-4" to={MONOREPO_DOC}> | |||
{translate('onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions')} | |||
</DocumentationLink>{' '} | |||
{translate('onboarding.tutorial.with.github_action.monorepo.pre_see_yaml_instructions')} | |||
</NumberedListItem> | |||
); | |||
} |
@@ -23,10 +23,12 @@ import CreateYmlFile from '../../components/CreateYmlFile'; | |||
import DefaultProjectKey from '../../components/DefaultProjectKey'; | |||
import { GITHUB_ACTIONS_RUNS_ON_LINUX } from '../constants'; | |||
import { generateGitHubActionsYaml } from '../utils'; | |||
import MonorepoDocLinkFallback from './MonorepoDocLinkFallback'; | |||
export interface OthersProps { | |||
branchesEnabled?: boolean; | |||
mainBranchName: string; | |||
monorepo?: boolean; | |||
component: Component; | |||
} | |||
@@ -55,19 +57,24 @@ function otherYamlSteps(branchesEnabled: boolean) { | |||
} | |||
export default function Others(props: OthersProps) { | |||
const { component, branchesEnabled, mainBranchName } = props; | |||
const { component, branchesEnabled, mainBranchName, monorepo } = props; | |||
return ( | |||
<> | |||
<DefaultProjectKey component={component} /> | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" | |||
yamlTemplate={generateGitHubActionsYaml( | |||
mainBranchName, | |||
!!branchesEnabled, | |||
GITHUB_ACTIONS_RUNS_ON_LINUX, | |||
otherYamlSteps(!!branchesEnabled), | |||
)} | |||
/> | |||
<DefaultProjectKey component={component} monorepo={monorepo} /> | |||
{monorepo ? ( | |||
<MonorepoDocLinkFallback /> | |||
) : ( | |||
<CreateYmlFile | |||
yamlFileName=".github/workflows/build.yml" | |||
yamlTemplate={generateGitHubActionsYaml( | |||
mainBranchName, | |||
!!branchesEnabled, | |||
GITHUB_ACTIONS_RUNS_ON_LINUX, | |||
otherYamlSteps(!!branchesEnabled), | |||
)} | |||
/> | |||
)} | |||
</> | |||
); | |||
} |
@@ -25,7 +25,7 @@ import { | |||
importGithubRepository, | |||
importGitlabProject, | |||
} from '../api/alm-integrations'; | |||
import { createImportedProjects } from '../api/dop-translation'; | |||
import { createBoundProject } from '../api/dop-translation'; | |||
import { createProject } from '../api/project-management'; | |||
import { ImportProjectParam } from '../apps/create/project/CreateProjectPage'; | |||
import { CreateProjectModes } from '../apps/create/project/types'; | |||
@@ -34,20 +34,22 @@ export type MutationArg<AlmImport extends ImportProjectParam = ImportProjectPara | |||
AlmImport extends { | |||
creationMode: infer A; | |||
almSetting: string; | |||
monorepo: false; | |||
projects: (infer R)[]; | |||
} | |||
? { creationMode: A; almSetting: string } & R | |||
? { creationMode: A; almSetting: string; monorepo: false } & R | |||
: | |||
| { | |||
creationMode: CreateProjectModes.Manual; | |||
project: string; | |||
name: string; | |||
mainBranch: string; | |||
monorepo: false; | |||
} | |||
| { | |||
creationMode: CreateProjectModes.Monorepo; | |||
creationMode: CreateProjectModes; | |||
devOpsPlatformSettingId: string; | |||
monorepo: boolean; | |||
monorepo: true; | |||
projectKey: string; | |||
projectName: string; | |||
repositoryIdentifier: string; | |||
@@ -61,18 +63,21 @@ export function useImportProjectMutation() { | |||
newCodeDefinitionValue?: string; | |||
} & MutationArg, | |||
) => { | |||
if (data.creationMode === CreateProjectModes.GitHub) { | |||
return importGithubRepository(data); | |||
} else if (data.creationMode === CreateProjectModes.AzureDevOps) { | |||
return importAzureRepository(data); | |||
} else if (data.creationMode === CreateProjectModes.BitbucketCloud) { | |||
return importBitbucketCloudRepository(data); | |||
} else if (data.creationMode === CreateProjectModes.BitbucketServer) { | |||
return importBitbucketServerProject(data); | |||
} else if (data.creationMode === CreateProjectModes.GitLab) { | |||
return importGitlabProject(data); | |||
} else if (data.creationMode === CreateProjectModes.Monorepo) { | |||
return createImportedProjects(data); | |||
if (data.monorepo === true) { | |||
return createBoundProject(data); | |||
} | |||
switch (data.creationMode) { | |||
case CreateProjectModes.GitHub: | |||
return importGithubRepository(data); | |||
case CreateProjectModes.AzureDevOps: | |||
return importAzureRepository(data); | |||
case CreateProjectModes.BitbucketCloud: | |||
return importBitbucketCloudRepository(data); | |||
case CreateProjectModes.BitbucketServer: | |||
return importBitbucketServerProject(data); | |||
case CreateProjectModes.GitLab: | |||
return importGitlabProject(data); | |||
} | |||
return createProject(data); |
@@ -0,0 +1,39 @@ | |||
/* | |||
* 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 { AlmKeys } from './alm-settings'; | |||
export interface DopSetting { | |||
appId?: string; | |||
id: string; | |||
key: string; | |||
type: AlmKeys; | |||
url?: string; | |||
} | |||
export interface BoundProject { | |||
devOpsPlatformSettingId: string; | |||
monorepo: boolean; | |||
newCodeDefinitionType?: string; | |||
newCodeDefinitionValue?: string; | |||
projectKey: string; | |||
projectName: string; | |||
repositoryIdentifier: string; | |||
} |
@@ -417,6 +417,7 @@ alm.bitbucketcloud.short=Bitbucket | |||
alm.bitbucketcloud.long=Bitbucket Cloud | |||
alm.github=GitHub | |||
alm.github.short=GitHub | |||
alm.github.organization=organization | |||
alm.gitlab=GitLab | |||
alm.gitlab.short=GitLab | |||
alm.configuration.selector.label={0} configuration | |||
@@ -1671,9 +1672,9 @@ settings.pr_decoration.binding.check_configuration.contact_admin=Please contact | |||
settings.pr_decoration.binding.check_configuration.success=Configuration valid. | |||
settings.pr_decoration.binding.form.name=Configuration name | |||
settings.pr_decoration.binding.form.name.help=Each DevOps Platform instance must be configured globally first, and given a unique name. Pick the instance your project is hosted on. | |||
settings.pr_decoration.binding.form.monorepo=Enable mono repository support | |||
settings.pr_decoration.binding.form.monorepo.help=Enable this setting if your project is part of a mono repository. {doc_link} | |||
settings.pr_decoration.binding.form.monorepo.warning=This setting must be enabled for all SonarQube projects that are part of a mono repository. | |||
settings.pr_decoration.binding.form.monorepo=Enable monorepository support | |||
settings.pr_decoration.binding.form.monorepo.help=Enable this setting if your project is part of a monorepository. {doc_link} | |||
settings.pr_decoration.binding.form.monorepo.warning=This setting must be enabled for all SonarQube projects that are part of a monorepository. | |||
settings.pr_decoration.binding.form.azure.project=Project name | |||
settings.pr_decoration.binding.form.azure.project.help=The name of the Azure DevOps project containing your repository. You can find this name on your project's Overview page. | |||
settings.pr_decoration.binding.form.azure.repository=Repository name | |||
@@ -4407,16 +4408,21 @@ onboarding.create_project.bitbucketcloud.no_projects=No projects could be fetche | |||
onboarding.create_project.bitbucketcloud.link=See on Bitbucket | |||
onboarding.create_project.github.title=GitHub project onboarding | |||
onboarding.create_project.github.subtitle=Import repositories from one of your GitHub organizations. | |||
onboarding.create_project.github.subtitle.with_monorepo=Import repositories from one of your GitHub organizations or {monorepoSetupLink}. | |||
onboarding.create_project.github.subtitle.link=set up a monorepo | |||
onboarding.create_project.github.choose_organization=Choose an organization | |||
onboarding.create_project.github.choose_repository=Choose the repository | |||
onboarding.create_project.github.warning.message=Could not connect to GitHub. Please contact an administrator to configure GitHub integration. | |||
onboarding.create_project.github.warning.message_admin=Could not connect to GitHub. Please make sure the GitHub instance is correctly configured in the {link} to create a new project from a repository. | |||
onboarding.create_project.github.warning.message_admin.link=DevOps Platform integration settings | |||
onboarding.create_project.github.no_orgs=We couldn't load any organizations with your key. Contact an administrator. | |||
onboarding.create_project.github.no_orgs_admin=We couldn't load any organizations. Make sure the GitHub App is installed in at least one organization and check the GitHub instance configuration in the {link}. | |||
onboarding.create_project.github.no_projects=No projects could be fetched from GitHub. Contact your system administrator. | |||
onboarding.create_project.gitlab.title=Gitlab project onboarding | |||
onboarding.create_project.gitlab.subtitle=Import projects from one of your GitLab groups | |||
onboarding.create_project.gitlab.no_projects=No projects could be fetched from Gitlab. Contact your system administrator, or {link}. | |||
onboarding.create_project.gitlab.link=See on GitLab | |||
onboarding.create_project.monorepo.no_projects=No projects could be fetch from {almKey}. Contact your system administrator. | |||
onboarding.create_project.bitbucket.title=Bitbucket Server project onboarding | |||
onboarding.create_project.bitbucket.subtitle=Import projects from one of your Bitbucket server workspaces | |||
onboarding.create_project.x_repositories_selected={count} {count, plural, one {repository} other {repositories}} selected | |||
@@ -4424,6 +4430,19 @@ onboarding.create_project.x_repository_created={count} {count, plural, one {repo | |||
onboarding.create_project.please_dont_leave=If you leave the page the import could fail. Are you sure you want to leave? | |||
onboarding.create_project.import_in_progress={count} of {total} projects imported. Please do not close this page until the import is complete. | |||
onboarding.create_project.monorepo.title={almName} monorepo project onboarding | |||
onboarding.create_project.monorepo.subtitle=Create multiple SonarQube projects corresponding to the same monorepo and bound to the same repository. | |||
onboarding.create_project.monorepo.doc_link=Learn more and get help setting up your monorepo | |||
onboarding.create_project.monorepo.choose_organization_and_repository.github=Choose the organization and the repository | |||
onboarding.create_project.monorepo.choose_dop_setting.github=Choose the GitHub configuration | |||
onboarding.create_project.monorepo.choose_organization.github=Choose the organization | |||
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.project_title=Create new projects | |||
onboarding.create_project.monorepo.add_project=Add new project | |||
onboarding.create_project.monorepo.remove_project=Remove project | |||
onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code | |||
onboarding.create_x_project.new_code_definition.title=Set up {count, plural, one {project} other {# projects}} for Clean as You Code | |||
onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code | |||
@@ -4432,6 +4451,7 @@ onboarding.create_project.new_code_definition.description.link=Defining New Code | |||
onboarding.create_project.new_code_definition.create_x_projects=Create {count, plural, one {project} other {# projects}} | |||
onboarding.create_projects.new_code_definition.change_info=You can change this setting for each project individually at any time in the project administration settings. | |||
onboarding.create_project.success=Your {count, plural, one {project has} other {# projects have}} been created. | |||
onboarding.create_project.monorepo.success=Your monorepo has been set up successfully. {count, plural, one {1 new project was} other {# new projects were}} created | |||
onboarding.create_project.success.admin=Project {project_link} has been successfully created. | |||
onboarding.create_project.failure=Import of {count, plural, one {# project} other {# projects}} failed. | |||
@@ -4550,6 +4570,7 @@ onboarding.tutorial.ci_outro.commit.why.no_branches=Each new push you make on yo | |||
onboarding.tutorial.ci_outro.refresh=This page will then refresh with your analysis results. | |||
onboarding.tutorial.ci_outro.refresh.why=If the page doesn't refresh after a while, please double-check the analysis configuration, and check your logs. | |||
onboarding.tutorial.other.project_key.sentence=Create a {file} file in your repository and paste the following code: | |||
onboarding.tutorial.other.project_key.monorepo.sentence=Create a {file} file at the root of your project and paste the following code: | |||
onboarding.tutorial.cfamilly.compilation_database_info=If you have trouble using the build wrapper, you can try using a {link}. | |||
onboarding.tutorial.cfamilly.compilation_database_info.link=compilation database | |||
onboarding.tutorial.cfamilly.speed_caching=You can also speed up your analysis by enabling {link}. | |||
@@ -4598,6 +4619,10 @@ onboarding.tutorial.with.bitbucket_pipelines.variables.secured.sentence.secured= | |||
onboarding.tutorial.with.github_ci.title=Analyze your project with GitHub CI | |||
onboarding.tutorial.with.github_action.create_secret.title=Create GitHub Secrets | |||
onboarding.tutorial.with.github_action.create_secret.title.monorepo=Create GitHub Secrets (once per monorepository) | |||
onboarding.tutorial.with.github_action.create_secret.monorepo_info=If the secrets were created already for one of the projects in the mono repository, please skip this step | |||
onboarding.tutorial.with.github_action.monorepo.pre_see_yaml_instructions=(once per monorepository) | |||
onboarding.tutorial.with.github_action.monorepo.see_yaml_instructions=See the documentation to create the Workflow YAML file at the root of your repository | |||
onboarding.tutorial.with.github_action.secret.intro=In your GitHub repository, go to {settings_secret} and create two new secrets: | |||
onboarding.tutorial.with.github_action.secret.intro.link=Settings > Secrets | |||
onboarding.tutorial.with.github_action.secret.name.sentence=In the {name} field, enter |