Browse Source

SONAR-21825 Add monorepo setup to Azure project onboarding

pull/3360/head
Ambroise C 2 weeks ago
parent
commit
01f0b0692a

+ 7
- 6
server/sonar-web/src/main/js/api/mocks/AlmIntegrationsServiceMock.ts View File

@@ -146,8 +146,9 @@ export default class AlmIntegrationsServiceMock {
];

defaultAzureRepositories: AzureRepository[] = [
mockAzureRepository({ sqProjectKey: 'random' }),
mockAzureRepository({ name: 'Azure repo 2' }),
mockAzureRepository({ sqProjectKey: 'random', projectName: 'Azure project' }),
mockAzureRepository({ name: 'Azure repo 2', projectName: 'Azure project' }),
mockAzureRepository({ name: 'Azure repo 3', projectName: 'Azure project 2' }),
];

defaultGithubRepositories: GithubRepository[] = [
@@ -232,15 +233,15 @@ export default class AlmIntegrationsServiceMock {
return Promise.resolve({ projects: this.azureProjects });
};

getAzureRepositories = () => {
getAzureRepositories: typeof getAzureRepositories = (_, projectName) => {
return Promise.resolve({
repositories: this.azureRepositories,
repositories: this.azureRepositories.filter((repo) => repo.projectName === projectName),
});
};

searchAzureRepositories = () => {
searchAzureRepositories: typeof searchAzureRepositories = (_, searchQuery) => {
return Promise.resolve({
repositories: this.azureRepositories,
repositories: this.azureRepositories.filter((repo) => repo.name.includes(searchQuery)),
});
};


+ 299
- 227
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreate.tsx View File

@@ -17,273 +17,345 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { LabelValueSelectOption } from 'design-system/lib';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { GroupBase } from 'react-select';
import {
getAzureProjects,
getAzureRepositories,
searchAzureRepositories,
} from '../../../../api/alm-integrations';
import { Location, Router } from '../../../../components/hoc/withRouter';
import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
import { AlmSettingsInstance } from '../../../../types/alm-settings';
import { DopSetting } from '../../../../types/dop-translation';
import { Dict } from '../../../../types/types';
import { ImportProjectParam } from '../CreateProjectPage';
import MonorepoProjectCreate from '../monorepo/MonorepoProjectCreate';
import { CreateProjectModes } from '../types';
import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';

interface Props {
canAdmin: boolean;
loadingBindings: boolean;
almInstances: AlmSettingsInstance[];
location: Location;
router: Router;
dopSettings: DopSetting[];
isLoadingBindings: boolean;
onProjectSetupDone: (importProjects: ImportProjectParam) => void;
}

interface State {
loading: boolean;
loadingRepositories: Dict<boolean>;
projects?: AzureProject[];
repositories: Dict<AzureRepository[]>;
searching?: boolean;
searchResults?: AzureRepository[];
searchQuery?: string;
selectedAlmInstance?: AlmSettingsInstance;
showPersonalAccessTokenForm: boolean;
}
export default function AzureProjectCreate(props: Readonly<Props>) {
const { canAdmin, dopSettings, isLoadingBindings, onProjectSetupDone } = props;
const [isLoading, setIsLoading] = useState(false);
const [loadingRepositories, setLoadingRepositories] = useState<Dict<boolean>>({});
const [isSearching, setIsSearching] = useState(false);
const [projects, setProjects] = useState<AzureProject[] | undefined>();
const [repositories, setRepositories] = useState<Dict<AzureRepository[]>>({});
const [searchQuery, setSearchQuery] = useState<string>('');
const [searchResults, setSearchResults] = useState<AzureRepository[] | undefined>();
const [selectedDopSetting, setSelectedDopSetting] = useState<DopSetting | undefined>();
const [selectedRepository, setSelectedRepository] = useState<AzureRepository>();
const [showPersonalAccessTokenForm, setShowPersonalAccessTokenForm] = useState(true);

const location = useLocation();
const router = useRouter();

const almInstances = useMemo(
() =>
dopSettings?.map((dopSetting) => ({
alm: dopSetting.type,
key: dopSetting.key,
url: dopSetting.url,
})) ?? [],
[dopSettings],
);
const isMonorepoSetup = location.query?.mono === 'true';
const hasDopSettings = Boolean(dopSettings?.length);
const selectedAlmInstance = useMemo(
() =>
selectedDopSetting && {
alm: selectedDopSetting.type,
key: selectedDopSetting.key,
url: selectedDopSetting.url,
},
[selectedDopSetting],
);
const repositoryOptions = useMemo(
() => transformToOptions(projects ?? [], repositories),
[projects, repositories],
);

const cleanUrl = useCallback(() => {
delete location.query.resetPat;
router.replace(location);
}, [location, router]);

export default class AzureProjectCreate extends React.PureComponent<Props, State> {
mounted = false;

constructor(props: Props) {
super(props);
this.state = {
selectedAlmInstance: props.almInstances[0],
loading: false,
showPersonalAccessTokenForm: true,
loadingRepositories: {},
repositories: {},
};
}

componentDidMount() {
this.mounted = true;
this.fetchData();
}

componentDidUpdate(prevProps: Props) {
if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => {
this.fetchData().catch(() => {
/* noop */
});
});
const fetchAzureProjects = useCallback(async (): Promise<AzureProject[] | undefined> => {
if (selectedDopSetting === undefined) {
return undefined;
}
}

componentWillUnmount() {
this.mounted = false;
}
const azureProjects = await getAzureProjects(selectedDopSetting.key);

fetchData = async () => {
const { showPersonalAccessTokenForm } = this.state;
return azureProjects.projects;
}, [selectedDopSetting]);

if (!showPersonalAccessTokenForm) {
this.setState({ loading: true });
let projects: AzureProject[] | undefined;
try {
projects = await this.fetchAzureProjects();
} catch (_) {
if (this.mounted) {
this.setState({ showPersonalAccessTokenForm: true, loading: false });
}
const fetchAzureRepositories = useCallback(
async (projectName: string): Promise<AzureRepository[]> => {
if (!selectedDopSetting) {
return [];
}

const { repositories } = this.state;

let firstProjectName: string;

if (projects && projects.length > 0) {
firstProjectName = projects[0].name;

this.setState(({ loadingRepositories }) => ({
loadingRepositories: { ...loadingRepositories, [firstProjectName]: true },
}));

const repos = await this.fetchAzureRepositories(firstProjectName);
repositories[firstProjectName] = repos;
try {
const azureRepositories = await getAzureRepositories(selectedDopSetting.key, projectName);
return azureRepositories.repositories;
} catch {
return [];
}
},
[selectedDopSetting],
);

if (this.mounted) {
this.setState(({ loadingRepositories }) => {
if (firstProjectName !== '') {
loadingRepositories[firstProjectName] = false;
}

return {
loading: false,
loadingRepositories: { ...loadingRepositories },
projects,
repositories,
};
});
}
const fetchData = useCallback(async () => {
if (showPersonalAccessTokenForm) {
return;
}
};

fetchAzureProjects = (): Promise<AzureProject[] | undefined> => {
const { selectedAlmInstance } = this.state;

if (!selectedAlmInstance) {
return Promise.resolve(undefined);
setIsLoading(true);
let projects: AzureProject[] | undefined;
try {
projects = await fetchAzureProjects();
} catch (_) {
setShowPersonalAccessTokenForm(true);
setIsLoading(false);
return;
}

return getAzureProjects(selectedAlmInstance.key).then(({ projects }) => projects);
};

fetchAzureRepositories = (projectName: string): Promise<AzureRepository[]> => {
const { selectedAlmInstance } = this.state;
if (projects && projects.length > 0) {
if (isMonorepoSetup) {
// Load every projects repos if we're in monorepo setup
projects.forEach(async (project) => {
setLoadingRepositories((loadingRepositories) => ({
...loadingRepositories,
[project.name]: true,
}));

try {
const repos = await fetchAzureRepositories(project.name);
setRepositories((repositories) => ({
...repositories,
[project.name]: repos,
}));
} finally {
setLoadingRepositories((loadingRepositories) => {
loadingRepositories[project.name] = false;
return { ...loadingRepositories };
});
}
});
} else {
const firstProjectName = projects[0].name;

if (!selectedAlmInstance) {
return Promise.resolve([]);
}
setLoadingRepositories((loadingRepositories) => ({
...loadingRepositories,
[firstProjectName]: true,
}));

return getAzureRepositories(selectedAlmInstance.key, projectName)
.then(({ repositories }) => repositories)
.catch(() => []);
};
const repos = await fetchAzureRepositories(firstProjectName);

cleanUrl = () => {
const { location, router } = this.props;
delete location.query.resetPat;
router.replace(location);
};
setLoadingRepositories((loadingRepositories) => {
loadingRepositories[firstProjectName] = false;

handleOpenProject = async (projectName: string) => {
if (this.state.searchResults) {
return;
return { ...loadingRepositories };
});
setRepositories((repositories) => ({ ...repositories, [firstProjectName]: repos }));
}
}

this.setState(({ loadingRepositories }) => ({
loadingRepositories: { ...loadingRepositories, [projectName]: true },
}));

const projectRepos = await this.fetchAzureRepositories(projectName);
setProjects(projects);
setIsLoading(false);
}, [fetchAzureProjects, fetchAzureRepositories, isMonorepoSetup, showPersonalAccessTokenForm]);

const handleImportRepository = useCallback(
(selectedRepository: AzureRepository) => {
if (selectedDopSetting !== undefined && selectedRepository !== undefined) {
onProjectSetupDone({
creationMode: CreateProjectModes.AzureDevOps,
almSetting: selectedDopSetting.key,
monorepo: false,
projects: [
{
projectName: selectedRepository.projectName,
repositoryName: selectedRepository.name,
},
],
});
}
},
[onProjectSetupDone, selectedDopSetting],
);

const handleMonorepoSetupDone = useCallback(
(monorepoSetup: ImportProjectParam) => {
const azureMonorepoSetup = {
...monorepoSetup,
projectIdentifier: selectedRepository?.projectName,
};

onProjectSetupDone(azureMonorepoSetup);
},
[onProjectSetupDone, selectedRepository?.projectName],
);

const handleOpenProject = useCallback(
async (projectName: string) => {
if (searchResults !== undefined) {
return;
}

this.setState(({ loadingRepositories, repositories }) => ({
loadingRepositories: { ...loadingRepositories, [projectName]: false },
repositories: { ...repositories, [projectName]: projectRepos },
}));
};
setLoadingRepositories((loadingRepositories) => ({
...loadingRepositories,
[projectName]: true,
}));

const projectRepos = await fetchAzureRepositories(projectName);

setLoadingRepositories((loadingRepositories) => ({
...loadingRepositories,
[projectName]: false,
}));
setRepositories((repositories) => ({ ...repositories, [projectName]: projectRepos }));
},
[fetchAzureRepositories, searchResults],
);

const handlePersonalAccessTokenCreate = useCallback(() => {
cleanUrl();
setShowPersonalAccessTokenForm(false);
}, [cleanUrl]);

const handleSearchRepositories = useCallback(
async (searchQuery: string) => {
if (!selectedDopSetting) {
return;
}

handleSearchRepositories = async (searchQuery: string) => {
const { selectedAlmInstance } = this.state;
if (searchQuery.length === 0) {
setSearchResults(undefined);
setSearchQuery('');
return;
}

if (!selectedAlmInstance) {
return;
}
setIsSearching(true);

if (searchQuery.length === 0) {
this.setState({ searchResults: undefined, searchQuery: undefined });
return;
}

this.setState({ searching: true });
const searchResults: AzureRepository[] = await searchAzureRepositories(
selectedDopSetting.key,
searchQuery,
)
.then(({ repositories }) => repositories)
.catch(() => []);

setIsSearching(false);
setSearchQuery(searchQuery);
setSearchResults(searchResults);
},
[selectedDopSetting],
);

const handleSelectRepository = useCallback(
(repositoryKey: string) => {
setSelectedRepository(
Object.values(repositories)
.flat()
.find(({ name }) => name === repositoryKey),
);
},
[repositories],
);

const onSelectedAlmInstanceChange = useCallback(
(almInstance: AlmSettingsInstance) => {
setSelectedDopSetting(dopSettings?.find((dopSetting) => dopSetting.key === almInstance.key));
},
[dopSettings],
);

useEffect(() => {
setSelectedDopSetting(dopSettings?.[0]);
// We want to update this value only when the list of DOP settings changes from empty to not-empty (or vice-versa)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasDopSettings]);

useEffect(() => {
setSearchResults(undefined);
setSearchQuery('');
setShowPersonalAccessTokenForm(true);
}, [isMonorepoSetup, selectedDopSetting]);

useEffect(() => {
fetchData();
}, [fetchData]);

return isMonorepoSetup ? (
<MonorepoProjectCreate
canAdmin={canAdmin}
dopSettings={dopSettings}
error={false}
loadingBindings={isLoadingBindings}
loadingOrganizations={false}
loadingRepositories={isLoading}
onProjectSetupDone={handleMonorepoSetupDone}
onSearchRepositories={setSearchQuery}
onSelectDopSetting={setSelectedDopSetting}
onSelectRepository={handleSelectRepository}
personalAccessTokenComponent={
!isLoading &&
selectedAlmInstance && (
<AzurePersonalAccessTokenForm
almSetting={selectedAlmInstance}
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate}
resetPat={Boolean(location.query.resetPat)}
/>
)
}
repositoryOptions={repositoryOptions}
repositorySearchQuery={searchQuery}
selectedDopSetting={selectedDopSetting}
selectedRepository={selectedRepository ? transformToOption(selectedRepository) : undefined}
showPersonalAccessToken={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
) : (
<AzureCreateProjectRenderer
almInstances={almInstances}
canAdmin={canAdmin}
loading={isLoading || isLoadingBindings}
loadingRepositories={loadingRepositories}
onImportRepository={handleImportRepository}
onOpenProject={handleOpenProject}
onPersonalAccessTokenCreate={handlePersonalAccessTokenCreate}
onSearch={handleSearchRepositories}
onSelectedAlmInstanceChange={onSelectedAlmInstanceChange}
projects={projects}
repositories={repositories}
resetPat={Boolean(location.query.resetPat)}
searching={isSearching}
searchResults={searchResults}
searchQuery={searchQuery}
selectedAlmInstance={selectedAlmInstance}
showPersonalAccessTokenForm={showPersonalAccessTokenForm || Boolean(location.query.resetPat)}
/>
);
}

const searchResults: AzureRepository[] = await searchAzureRepositories(
selectedAlmInstance.key,
searchQuery,
)
.then(({ repositories }) => repositories)
.catch(() => []);
function transformToOptions(
projects: AzureProject[],
repositories: Dict<AzureRepository[]>,
): Array<GroupBase<LabelValueSelectOption<string>>> {
return projects.map(({ name: projectName }) => ({
label: projectName,
options: repositories[projectName]?.map(transformToOption) ?? [],
}));
}

if (this.mounted) {
this.setState({
searching: false,
searchResults,
searchQuery,
});
}
};

handleImportRepository = (selectedRepository: AzureRepository) => {
const { selectedAlmInstance } = this.state;

if (selectedAlmInstance && selectedRepository) {
this.props.onProjectSetupDone({
creationMode: CreateProjectModes.AzureDevOps,
almSetting: selectedAlmInstance.key,
monorepo: false,
projects: [
{
projectName: selectedRepository.projectName,
repositoryName: selectedRepository.name,
},
],
});
}
};

handlePersonalAccessTokenCreate = () => {
this.cleanUrl();

this.setState({ showPersonalAccessTokenForm: false }, () => {
this.fetchData();
});
};

onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
this.setState(
{
selectedAlmInstance: instance,
searchResults: undefined,
searchQuery: '',
showPersonalAccessTokenForm: true,
},
() => {
this.fetchData().catch(() => {
/* noop */
});
},
);
};

render() {
const { canAdmin, loadingBindings, location, almInstances } = this.props;
const {
loading,
loadingRepositories,
showPersonalAccessTokenForm,
projects,
repositories,
searching,
searchResults,
searchQuery,
selectedAlmInstance,
} = this.state;

return (
<AzureCreateProjectRenderer
canAdmin={canAdmin}
loading={loading || loadingBindings}
loadingRepositories={loadingRepositories}
onImportRepository={this.handleImportRepository}
onOpenProject={this.handleOpenProject}
onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
onSearch={this.handleSearchRepositories}
projects={projects}
repositories={repositories}
searching={searching}
searchResults={searchResults}
searchQuery={searchQuery}
almInstances={almInstances}
selectedAlmInstance={selectedAlmInstance}
resetPat={Boolean(location.query.resetPat)}
showPersonalAccessTokenForm={
showPersonalAccessTokenForm || Boolean(location.query.resetPat)
}
onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
/>
);
}
function transformToOption({ name }: AzureRepository): LabelValueSelectOption<string> {
return { value: name, label: name };
}

+ 40
- 11
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectCreateRenderer.tsx View File

@@ -17,25 +17,27 @@
* 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 {
FlagMessage,
InputSearch,
LightPrimary,
Link,
PageContentFontWrapper,
Spinner,
Title,
} from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext';
import { translate } from '../../../../helpers/l10n';
import { getGlobalSettingsUrl } from '../../../../helpers/urls';
import { getGlobalSettingsUrl, queryToSearch } from '../../../../helpers/urls';
import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
import { Feature } from '../../../../types/features';
import { Dict } from '../../../../types/types';
import { ALM_INTEGRATION_CATEGORY } from '../../../settings/constants';
import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
import { CreateProjectModes } from '../types';
import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
import AzureProjectsList from './AzureProjectsList';

@@ -59,7 +61,9 @@ export interface AzureProjectCreateRendererProps {
onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
}

export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) {
export default function AzureProjectCreateRenderer(
props: Readonly<AzureProjectCreateRendererProps>,
) {
const {
canAdmin,
loading,
@@ -75,15 +79,41 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
selectedAlmInstance,
} = props;

const showCountError = !loading && (!almInstances || almInstances?.length === 0);
const showUrlError = !loading && selectedAlmInstance && !selectedAlmInstance.url;
const isMonorepoSupported = React.useContext(AvailableFeaturesContext).includes(
Feature.MonoRepositoryPullRequestDecoration,
);

const showCountError = !loading && (!almInstances || almInstances.length === 0);
const showUrlError =
!loading && selectedAlmInstance !== undefined && selectedAlmInstance.url === undefined;

return (
<PageContentFontWrapper>
<header className="sw-mb-10">
<Title className="sw-mb-4">{translate('onboarding.create_project.azure.title')}</Title>
<LightPrimary className="sw-body-sm">
{translate('onboarding.create_project.azure.subtitle')}
{isMonorepoSupported ? (
<FormattedMessage
id="onboarding.create_project.azure.subtitle.with_monorepo"
values={{
monorepoSetupLink: (
<Link
to={{
pathname: '/projects/create',
search: queryToSearch({
mode: CreateProjectModes.AzureDevOps,
mono: true,
}),
}}
>
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" />
</Link>
),
}}
/>
) : (
<FormattedMessage id="onboarding.create_project.azure.subtitle" />
)}
</LightPrimary>
</header>

@@ -94,7 +124,7 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
onChangeConfig={props.onSelectedAlmInstanceChange}
/>

<Spinner loading={loading} />
<Spinner isLoading={loading} />

{showUrlError && (
<FlagMessage variant="error" className="sw-mb-2">
@@ -122,8 +152,7 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
{showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />}

{!loading &&
selectedAlmInstance &&
selectedAlmInstance.url &&
selectedAlmInstance?.url &&
(showPersonalAccessTokenForm ? (
<div>
<AzurePersonalAccessTokenForm
@@ -141,7 +170,7 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
size="full"
/>
</div>
<Spinner loading={Boolean(searching)}>
<Spinner isLoading={Boolean(searching)}>
<AzureProjectsList
loadingRepositories={loadingRepositories}
onOpenProject={props.onOpenProject}

+ 5
- 8
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx View File

@@ -51,7 +51,7 @@ export interface CreateProjectPageProps extends WithAvailableFeaturesProps {
}

interface State {
azureSettings: AlmSettingsInstance[];
azureSettings: DopSetting[];
bitbucketSettings: AlmSettingsInstance[];
bitbucketCloudSettings: AlmSettingsInstance[];
githubSettings: DopSetting[];
@@ -130,6 +130,7 @@ export type ImportProjectParam =
projectKey: string;
projectName: string;
}[];
projectIdentifier?: string;
repositoryIdentifier: string;
};

@@ -192,9 +193,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
return getDopSettings()
.then(({ dopSettings }) => {
this.setState({
azureSettings: dopSettings
.filter(({ type }) => type === AlmKeys.Azure)
.map(({ key, type, url }) => ({ alm: type, key, url })),
azureSettings: dopSettings.filter(({ type }) => type === AlmKeys.Azure),
bitbucketSettings: dopSettings
.filter(({ type }) => type === AlmKeys.BitbucketServer)
.map(({ key, type, url }) => ({ alm: type, key, url })),
@@ -276,10 +275,8 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
return (
<AzureProjectCreate
canAdmin={!!canAdmin}
loadingBindings={loading}
location={location}
router={router}
almInstances={azureSettings}
dopSettings={azureSettings}
isLoadingBindings={loading}
onProjectSetupDone={this.handleProjectSetupDone}
/>
);

+ 1
- 1
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx View File

@@ -232,7 +232,7 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
}),
}}
>
<FormattedMessage id="onboarding.create_project.github.subtitle.link" />
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" />
</Link>
),
}}

+ 1
- 1
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectCreateRenderer.tsx View File

@@ -93,7 +93,7 @@ export default function GitlabProjectCreateRenderer(
}),
}}
>
<FormattedMessage id="onboarding.create_project.gitlab.subtitle.link" />
<FormattedMessage id="onboarding.create_project.subtitle_monorepo_setup_link" />
</Link>
),
}}

+ 118
- 5
server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx View File

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';

import userEvent from '@testing-library/user-event';
import * as React from 'react';
@@ -28,7 +28,9 @@ import DopTranslationServiceMock from '../../../../api/mocks/DopTranslationServi
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
import { Feature } from '../../../../types/features';
import CreateProjectPage from '../CreateProjectPage';
import { CreateProjectModes } from '../types';

jest.mock('../../../../api/alm-integrations');
jest.mock('../../../../api/alm-settings');
@@ -39,10 +41,25 @@ let newCodePeriodHandler: NewCodeDefinitionServiceMock;

const ui = {
azureCreateProjectButton: byText('onboarding.create_project.select_method.azure'),
cancelButton: byRole('button', { name: 'cancel' }),
azureOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.azure.title' }),
monorepoDopSettingDropdown: byRole('combobox', {
name: 'onboarding.create_project.monorepo.choose_dop_settingalm.azure',
}),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.azure' }),
monorepoSetupLink: byRole('link', {
name: 'onboarding.create_project.subtitle_monorepo_setup_link',
}),
personalAccessTokenInput: byRole('textbox', {
name: /onboarding.create_project.enter_pat/,
}),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
repositorySelector: byRole('combobox', {
name: `onboarding.create_project.monorepo.choose_repository`,
}),
searchbox: byRole('searchbox', {
name: 'onboarding.create_project.search_projects_repositories',
}),
};

const original = window.location;
@@ -110,6 +127,47 @@ it('should show import project feature when PAT is already set', async () => {
}),
).toBeInTheDocument();

await user.click(screen.getByText('Azure project 2'));
expect(
screen.getByRole('listitem', {
name: 'Azure repo 3',
}),
).toBeInTheDocument();

await user.type(ui.searchbox.get(), 'repo 2');
expect(
screen.queryByRole('listitem', {
name: 'Azure repo 1',
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole('listitem', {
name: 'Azure repo 3',
}),
).not.toBeInTheDocument();
expect(
screen.getByRole('listitem', {
name: 'Azure repo 2',
}),
).toBeInTheDocument();

await user.clear(ui.searchbox.get());
expect(
screen.queryByRole('listitem', {
name: 'Azure repo 3',
}),
).not.toBeInTheDocument();
expect(
screen.getByRole('listitem', {
name: 'Azure repo 1',
}),
).toBeInTheDocument();
expect(
screen.getByRole('listitem', {
name: 'Azure repo 2',
}),
).toBeInTheDocument();

const importButton = screen.getByText('onboarding.create_project.import');
await user.click(importButton);

@@ -149,8 +207,63 @@ it('should show search filter when PAT is already set', async () => {
expect(screen.getByText('onboarding.create_project.azure.no_results')).toBeInTheDocument();
});

function renderCreateProject() {
renderApp('project/create', <CreateProjectPage />, {
navigateTo: 'project/create?mode=azure',
describe('Azure monorepo setup navigation', () => {
it('should not display monorepo setup link if feature is disabled', async () => {
renderCreateProject({ isMonorepoFeatureEnabled: false });

await waitFor(() => {
// This test raises an Act warning if the following expect is not wrapped inside a `waitFor`
// Feel free to investigate and fix it if you have time
expect(ui.monorepoSetupLink.query()).not.toBeInTheDocument();
});
});

it('should be able to access monorepo setup page from Azure project import page', async () => {
const user = userEvent.setup();
renderCreateProject();

await user.click(await ui.monorepoSetupLink.find());

expect(ui.monorepoTitle.get()).toBeInTheDocument();
});

it('should be able to go back to Azure onboarding page from monorepo setup page', async () => {
const user = userEvent.setup();
renderCreateProject({ isMonorepo: true });

await user.click(await ui.cancelButton.find());

expect(ui.azureOnboardingTitle.get()).toBeInTheDocument();
});

it('should load every repositories from every projects in monorepo setup mode', async () => {
renderCreateProject({ isMonorepo: true });

await selectEvent.select(await ui.monorepoDopSettingDropdown.find(), [/conf-azure-2/]);
selectEvent.openMenu(await ui.repositorySelector.find());

expect(screen.getByText('Azure repo 1')).toBeInTheDocument();
expect(screen.getByText('Azure repo 2')).toBeInTheDocument();
expect(screen.getByText('Azure repo 3')).toBeInTheDocument();
});
});

function renderCreateProject({
isMonorepo = false,
isMonorepoFeatureEnabled = true,
}: {
isMonorepo?: boolean;
isMonorepoFeatureEnabled?: boolean;
} = {}) {
let queryString = `mode=${CreateProjectModes.AzureDevOps}`;
if (isMonorepo) {
queryString += '&mono=true';
}

renderApp('projects/create', <CreateProjectPage />, {
navigateTo: `projects/create?${queryString}`,
featureList: isMonorepoFeatureEnabled
? [Feature.MonoRepositoryPullRequestDecoration]
: undefined,
});
}

+ 3
- 1
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx View File

@@ -43,7 +43,9 @@ const ui = {
gitlabCreateProjectButton: byText('onboarding.create_project.select_method.gitlab'),
gitLabOnboardingTitle: byRole('heading', { name: 'onboarding.create_project.gitlab.title' }),
instanceSelector: byLabelText(/alm.configuration.selector.label/),
monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.gitlab.subtitle.link' }),
monorepoSetupLink: byRole('link', {
name: 'onboarding.create_project.subtitle_monorepo_setup_link',
}),
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.gitlab' }),

personalAccessTokenInput: byRole('textbox', {

+ 3
- 1
server/sonar-web/src/main/js/apps/create/project/__tests__/MonorepoProjectCreate-it.tsx View File

@@ -57,7 +57,9 @@ const ui = {
monorepoProjectTitle: byRole('heading', {
name: 'onboarding.create_project.monorepo.project_title',
}),
monorepoSetupLink: byRole('link', { name: 'onboarding.create_project.github.subtitle.link' }),
monorepoSetupLink: byRole('link', {
name: 'onboarding.create_project.subtitle_monorepo_setup_link',
}),
monorepoTitle: byRole('heading', { name: 'onboarding.create_project.monorepo.titlealm.github' }),
organizationSelector: byRole('combobox', {
name: `onboarding.create_project.monorepo.choose_organization`,

+ 2
- 1
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoConnectionSelector.tsx View File

@@ -20,6 +20,7 @@
import { Title } from 'design-system/lib';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { GroupBase } from 'react-select';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { AlmKeys } from '../../../../types/alm-settings';
import { DopSetting } from '../../../../types/dop-translation';
@@ -48,7 +49,7 @@ interface Props {
onSelectRepository: (repositoryKey: string) => void;
organizationOptions?: LabelValueSelectOption[];
personalAccessTokenComponent?: React.ReactNode;
repositoryOptions?: LabelValueSelectOption[];
repositoryOptions?: LabelValueSelectOption[] | GroupBase<LabelValueSelectOption>[];
repositorySearchQuery: string;
selectedDopSetting?: DopSetting;
selectedOrganization?: LabelValueSelectOption;

+ 2
- 1
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoProjectCreate.tsx View File

@@ -21,6 +21,7 @@ import { Spinner } from '@sonarsource/echoes-react';
import { BlueGreySeparator, ButtonPrimary, ButtonSecondary } from 'design-system';
import React, { useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { GroupBase } from 'react-select';
import { getComponents } from '../../../../api/project-management';
import { useLocation, useRouter } from '../../../../components/hoc/withRouter';
import { throwGlobalError } from '../../../../helpers/error';
@@ -50,7 +51,7 @@ interface MonorepoProjectCreateProps {
onSelectRepository: (repositoryKey: string) => void;
organizationOptions?: LabelValueSelectOption[];
personalAccessTokenComponent?: React.ReactNode;
repositoryOptions?: LabelValueSelectOption[];
repositoryOptions?: LabelValueSelectOption[] | GroupBase<LabelValueSelectOption>[];
repositorySearchQuery: string;
selectedDopSetting?: DopSetting;
selectedOrganization?: LabelValueSelectOption;

+ 2
- 1
server/sonar-web/src/main/js/apps/create/project/monorepo/MonorepoRepositorySelector.tsx View File

@@ -21,6 +21,7 @@ import { LinkHighlight, LinkStandalone, Spinner } from '@sonarsource/echoes-reac
import { DarkLabel, FlagMessage, InputSelect } from 'design-system';
import React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { GroupBase } from 'react-select';
import { LabelValueSelectOption } from '../../../../helpers/search';
import { getProjectUrl } from '../../../../helpers/urls';
import { AlmKeys } from '../../../../types/alm-settings';
@@ -38,7 +39,7 @@ interface Props {
onSearchRepositories: (query: string) => void;
onSelectRepository: (repositoryKey: string) => void;
repositorySearchQuery: string;
repositoryOptions?: LabelValueSelectOption[];
repositoryOptions?: LabelValueSelectOption[] | GroupBase<LabelValueSelectOption>[];
selectedOrganization?: LabelValueSelectOption;
selectedRepository?: LabelValueSelectOption;
showOrganizations?: boolean;

+ 1
- 1
server/sonar-web/src/main/js/apps/create/project/usePersonalAccessToken.ts View File

@@ -63,7 +63,7 @@ export const usePersonalAccessToken = (
setCheckingPat(true);
const { patIsValid, error } = await checkPersonalAccessTokenIsValid(key)
.then(({ status, error }) => ({ patIsValid: status, error }))
.catch(() => ({ patIsValid: status, error: translate('default_error_message') }));
.catch(() => ({ patIsValid: false, error: translate('default_error_message') }));
if (patIsValid) {
onPersonalAccessTokenCreated();
return;

+ 1
- 0
server/sonar-web/src/main/js/types/dop-translation.ts View File

@@ -31,6 +31,7 @@ export interface BoundProject {
monorepo: boolean;
newCodeDefinitionType?: string;
newCodeDefinitionValue?: string;
projectIdentifier?: string;
projectKey: string;
projectName: string;
repositoryIdentifier: string;

+ 2
- 2
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -4396,8 +4396,10 @@ onboarding.create_project.see_on_github=See on GitHub

onboarding.create_project.search_prompt=Search for projects
onboarding.create_project.set_up=Set up
onboarding.create_project.subtitle_monorepo_setup_link=set up a monorepo
onboarding.create_project.azure.title=Azure project onboarding
onboarding.create_project.azure.subtitle=Import projects from one of your Azure projects
onboarding.create_project.azure.subtitle.with_monorepo=Import projects from one of your Azure projects or {monorepoSetupLink}.
onboarding.create_project.azure.no_projects=No projects could be fetched from Azure DevOps. Contact your system administrator, or {link}.
onboarding.create_project.azure.search_results_for_project_X=Search results for "{0}"
onboarding.create_project.azure.no_repositories=Could not fetch repositories for this project. Contact your system administrator, or {link}.
@@ -4409,7 +4411,6 @@ 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.
@@ -4421,7 +4422,6 @@ onboarding.create_project.github.no_projects=No projects could be fetched from G
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.subtitle.with_monorepo=Import projects from one of your GitLab groups or {monorepoSetupLink}.
onboarding.create_project.gitlab.subtitle.link=set up a monorepo
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.

Loading…
Cancel
Save