Browse Source

SONAR-17527 Add main branch name during manual project creation

tags/9.8.0.63668
Guillaume Peoc'h 1 year ago
parent
commit
ef563f2a2e

+ 1
- 0
server/sonar-web/src/main/js/api/components.ts View File

@@ -86,6 +86,7 @@ export function deletePortfolio(portfolio: string): Promise<void | Response> {
export function createProject(data: {
name: string;
project: string;
mainBranch: string;
visibility?: Visibility;
}): Promise<{ project: ProjectBase }> {
return postJSON('/api/projects/create', data).catch(throwGlobalError);

+ 86
- 10
server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx View File

@@ -18,9 +18,12 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { debounce } from 'lodash';
import { debounce, isEmpty } from 'lodash';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { createProject, doesComponentExists } from '../../../api/components';
import { getValue } from '../../../api/settings';
import DocLink from '../../../components/common/DocLink';
import ProjectKeyInput from '../../../components/common/ProjectKeyInput';
import { SubmitButton } from '../../../components/controls/buttons';
import ValidationInput from '../../../components/controls/ValidationInput';
@@ -30,6 +33,7 @@ import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsEx
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 { PROJECT_NAME_MAX_LEN } from './constants';
import CreateProjectPageHeader from './CreateProjectPageHeader';
import './ManualProjectCreate.css';
@@ -47,6 +51,9 @@ interface State {
projectKeyError?: string;
projectKeyTouched: boolean;
validatingProjectKey: boolean;
mainBranchName: string;
mainBranchNameError?: string;
mainBranchNameTouched: boolean;
submitting: boolean;
}

@@ -63,6 +70,8 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
submitting: false,
projectKeyTouched: false,
projectNameTouched: false,
mainBranchName: 'main',
mainBranchNameTouched: false,
validatingProjectKey: false
};
this.checkFreeKey = debounce(this.checkFreeKey, 250);
@@ -70,12 +79,21 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat

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

componentWillUnmount() {
this.mounted = false;
}

fetchMainBranchName = async () => {
const mainBranchName = await getValue({ key: GlobalSettingKeys.MainBranchName });

if (this.mounted && mainBranchName.value !== undefined) {
this.setState({ mainBranchName: mainBranchName.value });
}
};

checkFreeKey = (key: string) => {
this.setState({ validatingProjectKey: true });

@@ -98,23 +116,25 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
};

canSubmit(state: State): state is ValidState {
const { projectKey, projectKeyError, projectName, projectNameError } = state;
const { projectKey, projectKeyError, projectName, projectNameError, mainBranchName } = state;
return Boolean(
projectKeyError === undefined &&
projectNameError === undefined &&
projectKey.length > 0 &&
projectName.length > 0
!isEmpty(projectKey) &&
!isEmpty(projectName) &&
!isEmpty(mainBranchName)
);
}

handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const { state } = this;
if (this.canSubmit(state)) {
const { projectKey, projectName, mainBranchName } = this.state;
if (this.canSubmit(this.state)) {
this.setState({ submitting: true });
createProject({
project: state.projectKey,
name: (state.projectName || state.projectKey).trim()
project: projectKey,
name: (projectName || projectKey).trim(),
mainBranch: mainBranchName
}).then(
({ project }) => this.props.onProjectCreate(project.key),
() => {
@@ -158,6 +178,14 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
);
};

handleBranchNameChange = (mainBranchName: string, fromUI = false) => {
this.setState({
mainBranchName,
mainBranchNameError: this.validateMainBranchName(mainBranchName),
mainBranchNameTouched: fromUI
});
};

validateKey = (projectKey: string) => {
const result = validateProjectKey(projectKey);
return result === ProjectKeyValidationResult.Valid
@@ -166,12 +194,19 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
};

validateName = (projectName: string) => {
if (projectName.length === 0) {
if (isEmpty(projectName)) {
return translate('onboarding.create_project.display_name.error.empty');
}
return undefined;
};

validateMainBranchName = (mainBranchName: string) => {
if (isEmpty(mainBranchName)) {
return translate('onboarding.create_project.main_branch_name.error.empty');
}
return undefined;
};

render() {
const {
projectKey,
@@ -181,13 +216,18 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
projectNameError,
projectNameTouched,
validatingProjectKey,
mainBranchName,
mainBranchNameError,
mainBranchNameTouched,
submitting
} = this.state;
const { branchesEnabled } = this.props;

const touched = !!(projectKeyTouched || projectNameTouched);
const touched = Boolean(projectKeyTouched || projectNameTouched);
const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined;
const projectNameIsValid = projectNameTouched && projectNameError === undefined;
const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined;
const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined;

return (
<>
@@ -230,6 +270,42 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
validating={validatingProjectKey}
/>

<ValidationInput
className="form-field"
description={
<FormattedMessage
id="onboarding.create_project.main_branch_name.description"
defaultMessage={translate(
'onboarding.create_project.main_branch_name.description'
)}
values={{
learn_more: (
<DocLink to="/project-administration/project-existence">
{translate('learn_more')}
</DocLink>
)
}}
/>
}
error={mainBranchNameError}
id="main-branch-name"
isInvalid={mainBranchNameIsInvalid}
isValid={mainBranchNameIsValid}
label={translate('onboarding.create_project.main_branch_name')}
required={true}>
<input
id="main-branch-name"
className={classNames('input-super-large', {
'is-invalid': mainBranchNameIsInvalid,
'is-valid': mainBranchNameIsValid
})}
minLength={1}
onChange={e => this.handleBranchNameChange(e.currentTarget.value, true)}
type="text"
value={mainBranchName}
/>
</ValidationInput>

<SubmitButton disabled={!this.canSubmit(this.state) || submitting}>
{translate('set_up')}
</SubmitButton>

+ 21
- 4
server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx View File

@@ -31,6 +31,10 @@ jest.mock('../../../../api/components', () => ({
.mockImplementation(({ component }) => Promise.resolve(component === 'exists'))
}));

jest.mock('../../../../api/settings', () => ({
getValue: jest.fn().mockResolvedValue({ value: 'main' })
}));

beforeEach(() => {
jest.clearAllMocks();
});
@@ -70,12 +74,11 @@ it('should validate form input', async () => {
).toHaveValue('This-is-not-a-key-');

// Clear name
await user.click(
await screen.findByRole('textbox', {
await user.clear(
screen.getByRole('textbox', {
name: 'onboarding.create_project.display_name field_required'
})
);
await user.keyboard('{Control>}a{/Control}{Backspace}');
expect(
screen.getByRole('textbox', { name: 'onboarding.create_project.project_key field_required' })
).toHaveValue('');
@@ -117,6 +120,16 @@ it('should validate form input', async () => {
expect(
await screen.findByText('onboarding.create_project.project_key.taken')
).toBeInTheDocument();

// Invalid main branch name
await user.clear(
screen.getByRole('textbox', {
name: 'onboarding.create_project.main_branch_name field_required'
})
);
expect(
await screen.findByText('onboarding.create_project.main_branch_name.error.empty')
).toBeInTheDocument();
});

it('should submit form input', async () => {
@@ -132,7 +145,11 @@ it('should submit form input', async () => {
);
await user.keyboard('test');
await user.click(screen.getByRole('button', { name: 'set_up' }));
expect(createProject).toHaveBeenCalledWith({ name: 'test', project: 'test' });
expect(createProject).toHaveBeenCalledWith({
name: 'test',
project: 'test',
mainBranch: 'main'
});
expect(onProjectCreate).toHaveBeenCalled();
});


+ 36
- 6
server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx View File

@@ -20,6 +20,7 @@
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { createProject } from '../../api/components';
import { getValue } from '../../api/settings';
import Link from '../../components/common/Link';
import VisibilitySelector from '../../components/common/VisibilitySelector';
import { ResetButtonLink, SubmitButton } from '../../components/controls/buttons';
@@ -29,6 +30,7 @@ import MandatoryFieldMarker from '../../components/ui/MandatoryFieldMarker';
import MandatoryFieldsExplanation from '../../components/ui/MandatoryFieldsExplanation';
import { translate } from '../../helpers/l10n';
import { getProjectUrl } from '../../helpers/urls';
import { GlobalSettingKeys } from '../../types/settings';
import { Visibility } from '../../types/types';

interface Props {
@@ -45,6 +47,7 @@ interface State {
visibility?: Visibility;
// add index declaration to be able to do `this.setState({ [name]: value });`
[x: string]: any;
mainBranchName: string;
}

export default class CreateProjectForm extends React.PureComponent<Props, State> {
@@ -57,12 +60,14 @@ export default class CreateProjectForm extends React.PureComponent<Props, State>
key: '',
loading: false,
name: '',
visibility: props.defaultProjectVisibility
visibility: props.defaultProjectVisibility,
mainBranchName: 'main'
};
}

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

componentDidUpdate() {
@@ -78,6 +83,14 @@ export default class CreateProjectForm extends React.PureComponent<Props, State>
this.mounted = false;
}

fetchMainBranchName = async () => {
const mainBranchName = await getValue({ key: GlobalSettingKeys.MainBranchName });

if (this.mounted && mainBranchName.value !== undefined) {
this.setState({ mainBranchName: mainBranchName.value });
}
};

handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const { name, value } = event.currentTarget;
this.setState({ [name]: value });
@@ -89,11 +102,13 @@ export default class CreateProjectForm extends React.PureComponent<Props, State>

handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
const { name, key, mainBranchName, visibility } = this.state;

const data = {
name: this.state.name,
project: this.state.key,
visibility: this.state.visibility
name,
project: key,
mainBranch: mainBranchName,
visibility
};

this.setState({ loading: true });
@@ -159,7 +174,7 @@ export default class CreateProjectForm extends React.PureComponent<Props, State>
<MandatoryFieldsExplanation className="modal-field" />
<div className="modal-field">
<label htmlFor="create-project-name">
{translate('name')}
{translate('onboarding.create_project.display_name')}
<MandatoryFieldMarker />
</label>
<input
@@ -175,7 +190,7 @@ export default class CreateProjectForm extends React.PureComponent<Props, State>
</div>
<div className="modal-field">
<label htmlFor="create-project-key">
{translate('key')}
{translate('onboarding.create_project.project_key')}
<MandatoryFieldMarker />
</label>
<input
@@ -188,6 +203,21 @@ export default class CreateProjectForm extends React.PureComponent<Props, State>
value={this.state.key}
/>
</div>
<div className="modal-field">
<label htmlFor="create-project-main-branch-name">
{translate('onboarding.create_project.main_branch_name')}
<MandatoryFieldMarker />
</label>
<input
id="create-project-main-branch-name"
maxLength={400}
name="mainBranchName"
onChange={this.handleInputChange}
required={true}
type="text"
value={this.state.mainBranchName}
/>
</div>
<div className="modal-field">
<label>{translate('visibility')}</label>
<VisibilitySelector

+ 53
- 35
server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx View File

@@ -17,49 +17,67 @@
* 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 import/first */
jest.mock('../../../api/components', () => ({
createProject: jest.fn(({ name }: { name: string }) =>
Promise.resolve({ project: { key: name, name } })
)
}));

import { shallow } from 'enzyme';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { change, submit, waitAndUpdate } from '../../../helpers/testUtils';
import { createProject } from '../../../api/components';
import CreateProjectForm from '../CreateProjectForm';

const createProject = require('../../../api/components').createProject as jest.Mock<any>;
jest.mock('../../../api/components', () => ({
createProject: jest.fn().mockResolvedValue({}),
doesComponentExists: jest
.fn()
.mockImplementation(({ component }) => Promise.resolve(component === 'exists'))
}));

jest.mock('../../../api/settings', () => ({
getValue: jest.fn().mockResolvedValue({ value: 'main' })
}));

beforeEach(() => {
jest.clearAllMocks();
});

it('should render all inputs and create a project', async () => {
const user = userEvent.setup();
renderCreateProjectForm();

it('creates project', async () => {
const wrapper = shallow(
<CreateProjectForm
defaultProjectVisibility="public"
onClose={jest.fn()}
onProjectCreated={jest.fn()}
/>
await user.type(
screen.getByRole('textbox', {
name: 'onboarding.create_project.display_name field_required'
}),
'ProjectName'
);
(wrapper.instance() as CreateProjectForm).mounted = true;
expect(wrapper).toMatchSnapshot();

change(wrapper.find('input[name="name"]'), 'name', {
currentTarget: { name: 'name', value: 'name' }
});
change(wrapper.find('input[name="key"]'), 'key', {
currentTarget: { name: 'key', value: 'key' }
});
wrapper.find('VisibilitySelector').prop<Function>('onChange')('private');
wrapper.update();
expect(wrapper).toMatchSnapshot();
await user.type(
screen.getByRole('textbox', {
name: 'onboarding.create_project.project_key field_required'
}),
'ProjectKey'
);

expect(
screen.getByRole('textbox', {
name: 'onboarding.create_project.main_branch_name field_required'
})
).toHaveValue('main');

submit(wrapper.find('form'));
await user.type(
screen.getByRole('textbox', {
name: 'onboarding.create_project.main_branch_name field_required'
}),
'{Control>}a{/Control}{Backspace}ProjectMainBranch'
);

await user.click(screen.getByRole('button', { name: 'create' }));
expect(createProject).toHaveBeenCalledWith({
name: 'name',
project: 'key',
visibility: 'private'
name: 'ProjectName',
project: 'ProjectKey',
mainBranch: 'ProjectMainBranch'
});
expect(wrapper).toMatchSnapshot();

await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
});

function renderCreateProjectForm(props: Partial<CreateProjectForm['props']> = {}) {
render(<CreateProjectForm onClose={jest.fn()} onProjectCreated={jest.fn()} {...props} />);
}

+ 0
- 343
server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap View File

@@ -1,343 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`creates project 1`] = `
<Modal
contentLabel="modal form"
onRequestClose={[MockFunction]}
>
<form
id="create-project-form"
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
qualifiers.create.TRK
</h2>
</header>
<div
className="modal-body"
>
<MandatoryFieldsExplanation
className="modal-field"
/>
<div
className="modal-field"
>
<label
htmlFor="create-project-name"
>
name
<MandatoryFieldMarker />
</label>
<input
autoFocus={true}
id="create-project-name"
maxLength={2000}
name="name"
onChange={[Function]}
required={true}
type="text"
value=""
/>
</div>
<div
className="modal-field"
>
<label
htmlFor="create-project-key"
>
key
<MandatoryFieldMarker />
</label>
<input
id="create-project-key"
maxLength={400}
name="key"
onChange={[Function]}
required={true}
type="text"
value=""
/>
</div>
<div
className="modal-field"
>
<label>
visibility
</label>
<VisibilitySelector
canTurnToPrivate={true}
className="little-spacer-top"
onChange={[Function]}
visibility="public"
/>
</div>
</div>
<footer
className="modal-foot"
>
<SubmitButton
disabled={false}
id="create-project-submit"
>
create
</SubmitButton>
<ResetButtonLink
id="create-project-cancel"
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`creates project 2`] = `
<Modal
contentLabel="modal form"
onRequestClose={[MockFunction]}
>
<form
id="create-project-form"
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
qualifiers.create.TRK
</h2>
</header>
<div
className="modal-body"
>
<MandatoryFieldsExplanation
className="modal-field"
/>
<div
className="modal-field"
>
<label
htmlFor="create-project-name"
>
name
<MandatoryFieldMarker />
</label>
<input
autoFocus={true}
id="create-project-name"
maxLength={2000}
name="name"
onChange={[Function]}
required={true}
type="text"
value="name"
/>
</div>
<div
className="modal-field"
>
<label
htmlFor="create-project-key"
>
key
<MandatoryFieldMarker />
</label>
<input
id="create-project-key"
maxLength={400}
name="key"
onChange={[Function]}
required={true}
type="text"
value="key"
/>
</div>
<div
className="modal-field"
>
<label>
visibility
</label>
<VisibilitySelector
canTurnToPrivate={true}
className="little-spacer-top"
onChange={[Function]}
visibility="private"
/>
</div>
</div>
<footer
className="modal-foot"
>
<SubmitButton
disabled={false}
id="create-project-submit"
>
create
</SubmitButton>
<ResetButtonLink
id="create-project-cancel"
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`creates project 3`] = `
<Modal
contentLabel="modal form"
onRequestClose={[MockFunction]}
>
<form
id="create-project-form"
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
qualifiers.create.TRK
</h2>
</header>
<div
className="modal-body"
>
<MandatoryFieldsExplanation
className="modal-field"
/>
<div
className="modal-field"
>
<label
htmlFor="create-project-name"
>
name
<MandatoryFieldMarker />
</label>
<input
autoFocus={true}
id="create-project-name"
maxLength={2000}
name="name"
onChange={[Function]}
required={true}
type="text"
value="name"
/>
</div>
<div
className="modal-field"
>
<label
htmlFor="create-project-key"
>
key
<MandatoryFieldMarker />
</label>
<input
id="create-project-key"
maxLength={400}
name="key"
onChange={[Function]}
required={true}
type="text"
value="key"
/>
</div>
<div
className="modal-field"
>
<label>
visibility
</label>
<VisibilitySelector
canTurnToPrivate={true}
className="little-spacer-top"
onChange={[Function]}
visibility="private"
/>
</div>
</div>
<footer
className="modal-foot"
>
<i
className="spinner spacer-right"
/>
<SubmitButton
disabled={true}
id="create-project-submit"
>
create
</SubmitButton>
<ResetButtonLink
id="create-project-cancel"
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`creates project 4`] = `
<Modal
contentLabel="modal form"
onRequestClose={[MockFunction]}
>
<div>
<header
className="modal-head"
>
<h2>
qualifiers.create.TRK
</h2>
</header>
<div
className="modal-body"
>
<Alert
variant="success"
>
<FormattedMessage
defaultMessage="projects_management.project_has_been_successfully_created"
id="projects_management.project_has_been_successfully_created"
values={
Object {
"project": <ForwardRef(Link)
to={
Object {
"pathname": "/dashboard",
"search": "?id=name",
}
}
>
name
</ForwardRef(Link)>,
}
}
/>
</Alert>
</div>
<footer
className="modal-foot"
>
<ResetButtonLink
id="create-project-close"
innerRef={[Function]}
onClick={[MockFunction]}
>
close
</ResetButtonLink>
</footer>
</div>
</Modal>
`;

+ 2
- 1
server/sonar-web/src/main/js/types/settings.ts View File

@@ -38,7 +38,8 @@ export enum GlobalSettingKeys {
DeveloperAggregatedInfoDisabled = 'sonar.developerAggregatedInfo.disabled',
UpdatecenterActivated = 'sonar.updatecenter.activate',
DisplayAnnouncementMessage = 'sonar.announcement.displayMessage',
AnnouncementMessage = 'sonar.announcement.message'
AnnouncementMessage = 'sonar.announcement.message',
MainBranchName = 'sonar.projectCreation.mainBranchName'
}

export type SettingDefinitionAndValue = {

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

@@ -3499,6 +3499,11 @@ onboarding.create_project.project_key.taken=This project key is already taken.
onboarding.create_project.display_name=Project display name
onboarding.create_project.display_name.error.empty=The display name is required.
onboarding.create_project.display_name.description=Up to 255 characters. Some scanners might override the value you provide.

onboarding.create_project.main_branch_name=Main branch name
onboarding.create_project.main_branch_name.error.empty=The main branch name is required.
onboarding.create_project.main_branch_name.description=The name of your project’s default branch { learn_more }

onboarding.create_project.pr_decoration.information=Manually created projects won’t benefit from the features associated with DevOps Platforms integration unless you configure them in the project settings.
onboarding.create_project.repository_imported=Already set up
onboarding.create_project.see_project=See the project

Loading…
Cancel
Save