Browse Source

SONAR-21023 Review field input and validation in the local project creation

tags/10.4.0.87286
guillaume-peoch-sonarsource 5 months ago
parent
commit
c649d7b3f2

+ 0
- 14
server/sonar-web/src/main/js/api/project-management.ts View File

@@ -83,20 +83,6 @@ export function createProject(data: {
return postJSON('/api/projects/create', data).catch(throwGlobalError);
}

export function setupManualProjectCreation(data: {
name: string;
project: string;
mainBranch: string;
visibility?: Visibility;
}) {
return (newCodeDefinitionType?: string, newCodeDefinitionValue?: string) =>
createProject({
...data,
newCodeDefinitionType,
newCodeDefinitionValue,
});
}

export function changeProjectDefaultVisibility(
projectVisibility: Visibility,
): Promise<void | Response> {

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

@@ -58,6 +58,7 @@ interface State {
loading: boolean;
creatingAlmDefinition?: AlmKeys;
importProjects?: ImportProjectParam;
redirectTo: string;
}

const PROJECT_MODE_FOR_ALM_KEY = {
@@ -125,6 +126,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
githubSettings: [],
gitlabSettings: [],
loading: true,
redirectTo: this.props.location.state?.from || '/projects',
};

componentDidMount() {
@@ -228,6 +230,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
githubSettings,
gitlabSettings,
loading,
redirectTo,
} = this.state;
const branchSupportEnabled = this.props.hasFeature(Feature.BranchSupport);

@@ -297,6 +300,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
<ManualProjectCreate
branchesEnabled={branchSupportEnabled}
onProjectSetupDone={this.handleProjectSetupDone}
onClose={() => this.props.router.push({ pathname: redirectTo })}
/>
);
}
@@ -321,7 +325,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp

render() {
const { location } = this.props;
const { creatingAlmDefinition, importProjects } = this.state;
const { creatingAlmDefinition, importProjects, redirectTo } = this.state;
const mode: CreateProjectModes | undefined = location.query?.mode;
const isProjectSetupDone = location.query?.setncd === 'true';
const gridLayoutStyle = mode ? 'sw-col-start-2 sw-col-span-10' : 'sw-col-span-12';
@@ -342,7 +346,11 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp
{this.renderProjectCreation(mode)}
</div>
{importProjects !== undefined && isProjectSetupDone && (
<NewCodeDefinitionSelection importProjects={importProjects} />
<NewCodeDefinitionSelection
importProjects={importProjects}
onClose={() => this.props.router.push({ pathname: redirectTo })}
redirectTo={redirectTo}
/>
)}

{creatingAlmDefinition && (

+ 63
- 6
server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx View File

@@ -19,16 +19,20 @@
*/
import userEvent from '@testing-library/user-event';
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
import * as React from 'react';
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
import { ProjectsServiceMock } from '../../../../api/mocks/ProjectsServiceMock';
import { getNewCodeDefinition } from '../../../../api/newCodeDefinition';
import { mockProject } from '../../../../helpers/mocks/projects';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import { mockAppState, mockCurrentUser } from '../../../../helpers/testMocks';
import { renderAppRoutes } from '../../../../helpers/testReactTestingUtils';
import { byRole, byText } from '../../../../helpers/testSelector';
import { NewCodeDefinitionType } from '../../../../types/new-code-definition';
import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
import { Permissions } from '../../../../types/permissions';
import routes from '../../../projects/routes';

jest.mock('../../../../api/measures');
jest.mock('../../../../api/favorites');
jest.mock('../../../../api/alm-settings');
jest.mock('../../../../api/newCodeDefinition');
jest.mock('../../../../api/project-management', () => ({
@@ -36,6 +40,8 @@ jest.mock('../../../../api/project-management', () => ({
}));
jest.mock('../../../../api/components', () => ({
...jest.requireActual('../../../../api/components'),
searchProjects: jest.fn(),
getScannableProjects: jest.fn(),
doesComponentExists: jest
.fn()
.mockImplementation(({ component }) => Promise.resolve(component === 'exists')),
@@ -51,11 +57,18 @@ const ui = {
name: /onboarding.create_project.display_name/,
}),
projectNextButton: byRole('button', { name: 'next' }),
newCodeDefinitionSection: byRole('region', {
name: 'onboarding.create_project.new_code_definition.title',
}),
newCodeDefinitionHeader: byText('onboarding.create_x_project.new_code_definition.title1'),
inheritGlobalNcdRadio: byRole('radio', { name: 'new_code_definition.global_setting' }),
projectCreateButton: byRole('button', {
name: 'onboarding.create_project.new_code_definition.create_x_projects1',
}),
cancelButton: byRole('button', { name: 'cancel' }),
closeButton: byRole('button', { name: 'clear' }),
createProjectsButton: byRole('button', { name: 'projects.add' }),
createLocalProject: byRole('menuitem', { name: 'my_account.add_project.manual' }),
overrideNcdRadio: byRole('radio', { name: 'new_code_definition.specific_setting' }),
ncdOptionPreviousVersionRadio: byRole('radio', {
name: /new_code_definition.previous_version/,
@@ -71,6 +84,7 @@ const ui = {
}),
ncdOptionDaysInputError: byText('new_code_definition.number_days.invalid.1.90'),
projectDashboardText: byText('/dashboard?id=foo'),
projectsPageTitle: byRole('heading', { name: 'projects.page' }),
};

async function fillFormAndNext(displayName: string, user: UserEvent) {
@@ -85,6 +99,7 @@ async function fillFormAndNext(displayName: string, user: UserEvent) {

let almSettingsHandler: AlmSettingsServiceMock;
let newCodePeriodHandler: NewCodeDefinitionServiceMock;
let projectHandler: ProjectsServiceMock;

const original = window.location;

@@ -95,12 +110,14 @@ beforeAll(() => {
});
almSettingsHandler = new AlmSettingsServiceMock();
newCodePeriodHandler = new NewCodeDefinitionServiceMock();
projectHandler = new ProjectsServiceMock();
});

beforeEach(() => {
jest.clearAllMocks();
almSettingsHandler.reset();
newCodePeriodHandler.reset();
projectHandler.reset();
});

afterAll(() => {
@@ -175,8 +192,48 @@ it('the project onboarding page should be displayed when the project is created'
expect(await ui.projectDashboardText.find()).toBeInTheDocument();
});

function renderCreateProject(props: Partial<CreateProjectPageProps> = {}) {
renderApp('project/create', <CreateProjectPage {...props} />, {
navigateTo: 'project/create?mode=manual',
it('validate the provate key field', async () => {
const user = userEvent.setup();
renderCreateProject();
expect(ui.manualProjectHeader.get()).toBeInTheDocument();

await user.click(ui.displayNameField.get());
await user.keyboard('exists');

expect(ui.projectNextButton.get()).toBeDisabled();
await user.click(ui.projectNextButton.get());
});

it('should navigate back to the Projects page when clicking cancel or close', async () => {
newCodePeriodHandler.setNewCodePeriod({ type: NewCodeDefinitionType.NumberOfDays });
const user = userEvent.setup();
renderCreateProject();

await user.click(ui.cancelButton.get());
expect(await ui.projectsPageTitle.find()).toBeInTheDocument();

await user.click(ui.createProjectsButton.get());
await user.click(await ui.createLocalProject.find());

await user.click(ui.closeButton.get());
expect(await ui.projectsPageTitle.find()).toBeInTheDocument();

await user.click(ui.createProjectsButton.get());
await user.click(await ui.createLocalProject.find());

expect(await ui.manualProjectHeader.find()).toBeInTheDocument();
await fillFormAndNext('testing', user);
expect(ui.newCodeDefinitionHeader.get()).toBeInTheDocument();

await user.click(await ui.newCodeDefinitionSection.byRole('button', { name: 'clear' }).find());
expect(await ui.projectsPageTitle.find()).toBeInTheDocument();
});

function renderCreateProject() {
renderAppRoutes('projects/create?mode=manual', routes, {
currentUser: mockCurrentUser({
permissions: { global: [Permissions.ProjectCreation] },
}),
appState: mockAppState({ canAdmin: true }),
});
}

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

@@ -27,12 +27,10 @@ import ManualProjectCreate from '../manual/ManualProjectCreate';

const ui = {
nextButton: byRole('button', { name: 'next' }),
cancelButton: byRole('button', { name: 'cancel' }),
closeButton: byRole('button', { name: 'clear' }),
};

jest.mock('../../../../api/project-management', () => ({
setupManualProjectCreation: jest.fn(),
}));

jest.mock('../../../../api/components', () => ({
doesComponentExists: jest
.fn()
@@ -162,8 +160,13 @@ it('should handle component exists failure', async () => {
).toHaveValue('test');
});

function renderManualProjectCreate(props: Partial<ManualProjectCreate['props']> = {}) {
function renderManualProjectCreate(props: Partial<Parameters<typeof ManualProjectCreate>[0]> = {}) {
renderComponent(
<ManualProjectCreate branchesEnabled={false} onProjectSetupDone={jest.fn()} {...props} />,
<ManualProjectCreate
branchesEnabled={false}
onProjectSetupDone={jest.fn()}
onClose={jest.fn()}
{...props}
/>,
);
}

+ 81
- 24
server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx View File

@@ -17,7 +17,18 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ButtonPrimary, ButtonSecondary, FlagMessage, Link, Spinner, Title } from 'design-system';
import {
ButtonPrimary,
ButtonSecondary,
CloseIcon,
FlagMessage,
InteractiveIcon,
Link,
Spinner,
Title,
addGlobalErrorMessage,
addGlobalSuccessMessage,
} from 'design-system';
import { omit } from 'lodash';
import * as React from 'react';
import { useEffect } from 'react';
@@ -25,7 +36,6 @@ import { FormattedMessage, useIntl } from 'react-intl';
import { useNavigate, unstable_usePrompt as usePrompt } from 'react-router-dom';
import NewCodeDefinitionSelector from '../../../../components/new-code-definition/NewCodeDefinitionSelector';
import { useDocUrl } from '../../../../helpers/docs';
import { addGlobalErrorMessage, addGlobalSuccessMessage } from '../../../../helpers/globalMessages';
import { translate } from '../../../../helpers/l10n';
import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
import {
@@ -42,10 +52,12 @@ const listener = (event: BeforeUnloadEvent) => {

interface Props {
importProjects: ImportProjectParam;
onClose: () => void;
redirectTo: string;
}

export default function NewCodeDefinitionSelection(props: Props) {
const { importProjects } = props;
const { importProjects, redirectTo, onClose } = props;

const [selectedDefinition, selectDefinition] = React.useState<NewCodeDefinitiondWithCompliance>();
const [failedImports, setFailedImports] = React.useState<number>(0);
@@ -64,6 +76,21 @@ export default function NewCodeDefinitionSelection(props: Props) {
const isMultipleProjects = projectCount > 1;

useEffect(() => {
const redirect = (projectCount: number) => {
if (projectCount === 1 && data) {
if (redirectTo === '/projects') {
navigate(getProjectUrl(data.project.key));
} else {
onClose();
}
} else {
navigate({
pathname: '/projects',
search: queryToSearch({ sort: '-creation_date' }),
});
}
};

if (mutateCount > 0 || isIdle) {
return;
}
@@ -80,30 +107,43 @@ export default function NewCodeDefinitionSelection(props: Props) {
}

if (projectCount > failedImports) {
addGlobalSuccessMessage(
intl.formatMessage(
{ id: 'onboarding.create_project.success' },
{
count: projectCount - failedImports,
},
),
);

if (projectCount === 1) {
if (data) {
navigate(getProjectUrl(data.project.key));
}
} else {
navigate({
pathname: '/projects',
search: queryToSearch({ sort: '-creation_date' }),
});
if (redirectTo === '/projects') {
addGlobalSuccessMessage(
intl.formatMessage(
{ id: 'onboarding.create_project.success' },
{
count: projectCount - failedImports,
},
),
);
} else if (data) {
addGlobalSuccessMessage(
<FormattedMessage
defaultMessage={translate('onboarding.create_project.success.admin')}
id="onboarding.create_project.success.admin"
values={{
project_link: <Link to={getProjectUrl(data.project.key)}>{data.project.name}</Link>,
}}
/>,
);
}
redirect(projectCount);
}

reset();
setFailedImports(0);
}, [data, projectCount, failedImports, mutateCount, reset, intl, navigate, isIdle]);
}, [
data,
projectCount,
failedImports,
mutateCount,
reset,
intl,
navigate,
isIdle,
redirectTo,
onClose,
]);

React.useEffect(() => {
if (isImporting) {
@@ -133,7 +173,24 @@ export default function NewCodeDefinitionSelection(props: Props) {
};

return (
<div id="project-ncd-selection" className="sw-body-sm">
<section
aria-label={translate('onboarding.create_project.new_code_definition.title')}
id="project-ncd-selection"
className="sw-body-sm"
>
<div className="sw-flex sw-justify-between">
<FormattedMessage
id="onboarding.create_project.manual.step2"
defaultMessage={translate('onboarding.create_project.manual.step2')}
/>
<InteractiveIcon
Icon={CloseIcon}
aria-label={intl.formatMessage({ id: 'clear' })}
currentColor
onClick={onClose}
size="small"
/>
</div>
<Title>
<FormattedMessage
defaultMessage={translate('onboarding.create_x_project.new_code_definition.title')}
@@ -199,6 +256,6 @@ export default function NewCodeDefinitionSelection(props: Props) {
</FlagMessage>
)}
</div>
</div>
</section>
);
}

+ 185
- 148
server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx View File

@@ -20,18 +20,22 @@
import classNames from 'classnames';
import {
ButtonPrimary,
ButtonSecondary,
CloseIcon,
FlagErrorIcon,
FlagMessage,
FlagSuccessIcon,
FormField,
InputField,
InteractiveIcon,
Link,
Note,
TextError,
Title,
} from 'design-system';
import { debounce, isEmpty } from 'lodash';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { doesComponentExists } from '../../../../api/components';
import { getValue } from '../../../../api/settings';
import { useDocUrl } from '../../../../helpers/docs';
@@ -46,6 +50,7 @@ import { CreateProjectModes } from '../types';
interface Props {
branchesEnabled: boolean;
onProjectSetupDone: (importProjects: ImportProjectParam) => void;
onClose: () => void;
}

interface State {
@@ -53,7 +58,7 @@ interface State {
projectNameError?: boolean;
projectNameTouched: boolean;
projectKey: string;
projectKeyError?: boolean;
projectKeyError?: 'DUPLICATE_KEY' | 'WRONG_FORMAT';
projectKeyTouched: boolean;
validatingProjectKey: boolean;
mainBranchName: string;
@@ -65,60 +70,96 @@ const DEBOUNCE_DELAY = 250;

type ValidState = State & Required<Pick<State, 'projectKey' | 'projectName'>>;

export default class ManualProjectCreate extends React.PureComponent<Props, State> {
mounted = false;
export default function ManualProjectCreate(props: Readonly<Props>) {
const [project, setProject] = React.useState<State>({
projectKey: '',
projectName: '',
projectKeyTouched: false,
projectNameTouched: false,
mainBranchName: 'main',
mainBranchNameTouched: false,
validatingProjectKey: false,
});
const intl = useIntl();
const docUrl = useDocUrl();

constructor(props: Props) {
super(props);
this.state = {
projectKey: '',
projectName: '',
projectKeyTouched: false,
projectNameTouched: false,
mainBranchName: 'main',
mainBranchNameTouched: false,
validatingProjectKey: false,
};
this.checkFreeKey = debounce(this.checkFreeKey, DEBOUNCE_DELAY);
}
const checkFreeKey = React.useCallback(
debounce((key: string) => {
setProject((prevProject) => ({ ...prevProject, validatingProjectKey: true }));

componentDidMount() {
this.mounted = true;
this.fetchMainBranchName();
}
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),
[],
);

componentWillUnmount() {
this.mounted = false;
}
const handleProjectKeyChange = React.useCallback(
(projectKey: string, fromUI = false) => {
const projectKeyError = validateKey(projectKey);

fetchMainBranchName = async () => {
const mainBranchName = await getValue({ key: GlobalSettingKeys.MainBranchName });
setProject((prevProject) => ({
...prevProject,
projectKey,
projectKeyError,
projectKeyTouched: fromUI,
}));

if (this.mounted && mainBranchName.value !== undefined) {
this.setState({ mainBranchName: mainBranchName.value });
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,
mainBranchName,
}));
}
}
};

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

doesComponentExists({ component: key })
.then((alreadyExist) => {
if (this.mounted && key === this.state.projectKey) {
this.setState({
projectKeyError: alreadyExist ? true : undefined,
validatingProjectKey: false,
});
}
})
.catch(() => {
if (this.mounted && key === this.state.projectKey) {
this.setState({ projectKeyError: undefined, validatingProjectKey: false });
}
});
};
React.useEffect(() => {
if (!project.projectKeyTouched) {
const sanitizedProjectKey = project.projectName
.trim()
.replace(PROJECT_KEY_INVALID_CHARACTERS, '-');

canSubmit(state: State): state is ValidState {
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 &&
@@ -127,13 +168,13 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
!isEmpty(projectName) &&
!isEmpty(mainBranchName),
);
}
};

handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const { projectKey, projectName, mainBranchName } = this.state;
if (this.canSubmit(this.state)) {
this.props.onProjectSetupDone({
const { projectKey, projectName, mainBranchName } = project;
if (canSubmit(project)) {
props.onProjectSetupDone({
creationMode: CreateProjectModes.Manual,
projects: [
{
@@ -146,100 +187,97 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
}
};

handleProjectKeyChange = (projectKey: string, fromUI = false) => {
const projectKeyError = this.validateKey(projectKey);

this.setState({
projectKey,
projectKeyError,
projectKeyTouched: fromUI,
const handleProjectNameChange = (projectName: string, fromUI = false) => {
setProject({
...project,
projectName,
projectNameError: validateName(projectName),
projectNameTouched: fromUI,
});

if (projectKeyError === undefined) {
this.checkFreeKey(projectKey);
}
};

handleProjectNameChange = (projectName: string, fromUI = false) => {
this.setState(
{
projectName,
projectNameError: this.validateName(projectName),
projectNameTouched: fromUI,
},
() => {
if (!this.state.projectKeyTouched) {
const sanitizedProjectKey = this.state.projectName
.trim()
.replace(PROJECT_KEY_INVALID_CHARACTERS, '-');
this.handleProjectKeyChange(sanitizedProjectKey);
}
},
);
};

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

validateKey = (projectKey: string) => {
const validateKey = (projectKey: string) => {
const result = validateProjectKey(projectKey);
return result === ProjectKeyValidationResult.Valid ? undefined : true;
if (result !== ProjectKeyValidationResult.Valid) {
return 'WRONG_FORMAT';
}
return undefined;
};

validateName = (projectName: string) => {
const validateName = (projectName: string) => {
if (isEmpty(projectName)) {
return true;
}
return undefined;
};

validateMainBranchName = (mainBranchName: string) => {
const validateMainBranchName = (mainBranchName: string) => {
if (isEmpty(mainBranchName)) {
return true;
}
return undefined;
};

render() {
const {
projectKey,
projectKeyError,
projectKeyTouched,
projectName,
projectNameError,
projectNameTouched,
validatingProjectKey,
mainBranchName,
mainBranchNameError,
mainBranchNameTouched,
} = this.state;
const { branchesEnabled } = this.props;
const {
projectKey,
projectKeyError,
projectKeyTouched,
projectName,
projectNameError,
projectNameTouched,
validatingProjectKey,
mainBranchName,
mainBranchNameError,
mainBranchNameTouched,
} = project;
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;
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;

return (
<div className="sw-max-w-[50%]">
<Title>{translate('onboarding.create_project.manual.title')}</Title>
{branchesEnabled && (
<FlagMessage className="sw-my-4" variant="info">
{translate('onboarding.create_project.pr_decoration.information')}
</FlagMessage>
)}
return (
<section
aria-label={translate('onboarding.create_project.manual.title')}
className="sw-body-sm"
>
<div className="sw-flex sw-justify-between">
<FormattedMessage
id="onboarding.create_project.manual.step1"
defaultMessage={translate('onboarding.create_project.manual.step1')}
/>
<InteractiveIcon
Icon={CloseIcon}
aria-label={intl.formatMessage({ id: 'clear' })}
currentColor
onClick={props.onClose}
size="small"
/>
</div>
<Title>{translate('onboarding.create_project.manual.title')}</Title>
{branchesEnabled && (
<FlagMessage className="sw-my-4" variant="info">
{translate('onboarding.create_project.pr_decoration.information')}
</FlagMessage>
)}
<div className="sw-max-w-[50%] sw-mt-2">
<form
id="create-project-manual"
className="sw-flex-col sw-body-sm"
onSubmit={this.handleFormSubmit}
onSubmit={handleFormSubmit}
>
<FormField
htmlFor="project-name"
@@ -255,7 +293,7 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
id="project-name"
maxLength={PROJECT_NAME_MAX_LEN}
minLength={1}
onChange={(e) => this.handleProjectNameChange(e.currentTarget.value, true)}
onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)}
type="text"
value={projectName}
autoFocus
@@ -284,7 +322,7 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
size="large"
id="project-key"
minLength={1}
onChange={(e) => this.handleProjectKeyChange(e.currentTarget.value, true)}
onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)}
type="text"
value={projectKey}
isInvalid={projectKeyIsInvalid}
@@ -294,8 +332,16 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
{projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
{projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />}
</div>
<Note className="sw-mt-2">
{translate('onboarding.create_project.project_key.description')}
<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>

@@ -312,7 +358,7 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
size="large"
id="main-branch-name"
minLength={1}
onChange={(e) => this.handleBranchNameChange(e.currentTarget.value, true)}
onChange={(e) => handleBranchNameChange(e.currentTarget.value, true)}
type="text"
value={mainBranchName}
isInvalid={mainBranchNameIsInvalid}
@@ -323,37 +369,28 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
{mainBranchNameIsValid && <FlagSuccessIcon className="sw-ml-2" />}
</div>
<Note className="sw-mt-2">
<FormattedMessageWithDocLink />
<FormattedMessage
id="onboarding.create_project.main_branch_name.description"
defaultMessage={translate('onboarding.create_project.main_branch_name.description')}
values={{
learn_more: (
<Link to={docUrl('/analyzing-source-code/branches/branch-analysis')}>
{translate('learn_more')}
</Link>
),
}}
/>
</Note>
</FormField>

<ButtonPrimary
type="submit"
className="sw-mt-4 sw-mb-4"
disabled={!this.canSubmit(this.state)}
>
<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)}>
{translate('next')}
</ButtonPrimary>
</form>
</div>
);
}
}

function FormattedMessageWithDocLink() {
const docUrl = useDocUrl();

return (
<FormattedMessage
id="onboarding.create_project.main_branch_name.description"
defaultMessage={translate('onboarding.create_project.main_branch_name.description')}
values={{
learn_more: (
<Link to={docUrl('/analyzing-source-code/branches/branch-analysis')}>
{translate('learn_more')}
</Link>
),
}}
/>
</section>
);
}

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

@@ -1,248 +0,0 @@
/*
* 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 * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { createProject } from '../../api/project-management';
import { getValue } from '../../api/settings';
import Link from '../../components/common/Link';
import VisibilitySelector from '../../components/common/VisibilitySelector';
import Modal from '../../components/controls/Modal';
import { ResetButtonLink, SubmitButton } from '../../components/controls/buttons';
import { Alert } from '../../components/ui/Alert';
import MandatoryFieldMarker from '../../components/ui/MandatoryFieldMarker';
import MandatoryFieldsExplanation from '../../components/ui/MandatoryFieldsExplanation';
import { translate } from '../../helpers/l10n';
import { getProjectUrl } from '../../helpers/urls';
import { Visibility } from '../../types/component';
import { GlobalSettingKeys } from '../../types/settings';

interface Props {
defaultProjectVisibility?: Visibility;
onClose: () => void;
onProjectCreated: () => void;
}

interface State {
createdProject?: { key: string; name: string };
key: string;
loading: boolean;
name: string;
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> {
closeButton?: HTMLElement | null;
mounted = false;

constructor(props: Props) {
super(props);
this.state = {
key: '',
loading: false,
name: '',
visibility: props.defaultProjectVisibility,
mainBranchName: 'main',
};
}

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

componentDidUpdate() {
// wrap with `setTimeout` because of https://github.com/reactjs/react-modal/issues/338
setTimeout(() => {
if (this.closeButton) {
this.closeButton.focus();
}
}, 0);
}

componentWillUnmount() {
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 });
};

handleVisibilityChange = (visibility: Visibility) => {
this.setState({ visibility });
};

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

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

this.setState({ loading: true });
createProject(data).then(
(response) => {
if (this.mounted) {
this.setState({ createdProject: response.project, loading: false });
this.props.onProjectCreated();
}
},
() => {
if (this.mounted) {
this.setState({ loading: false });
}
},
);
};

render() {
const { defaultProjectVisibility } = this.props;
const { createdProject } = this.state;
const header = translate('qualifiers.create.TRK');

return (
<Modal contentLabel={header} onRequestClose={this.props.onClose}>
{createdProject ? (
<div>
<header className="modal-head">
<h2>{header}</h2>
</header>

<div className="modal-body">
<Alert variant="success">
<FormattedMessage
defaultMessage={translate(
'projects_management.project_has_been_successfully_created',
)}
id="projects_management.project_has_been_successfully_created"
values={{
project: (
<Link to={getProjectUrl(createdProject.key)}>{createdProject.name}</Link>
),
}}
/>
</Alert>
</div>

<footer className="modal-foot">
<ResetButtonLink
id="create-project-close"
innerRef={(node) => (this.closeButton = node)}
onClick={this.props.onClose}
>
{translate('close')}
</ResetButtonLink>
</footer>
</div>
) : (
<form id="create-project-form" onSubmit={this.handleFormSubmit}>
<header className="modal-head">
<h2>{header}</h2>
</header>

<div className="modal-body">
<MandatoryFieldsExplanation className="modal-field" />
<div className="modal-field">
<label htmlFor="create-project-name">
{translate('onboarding.create_project.display_name')}
<MandatoryFieldMarker />
</label>
<input
autoFocus
id="create-project-name"
maxLength={2000}
name="name"
onChange={this.handleInputChange}
required
type="text"
value={this.state.name}
/>
</div>
<div className="modal-field">
<label htmlFor="create-project-key">
{translate('onboarding.create_project.project_key')}
<MandatoryFieldMarker />
</label>
<input
id="create-project-key"
maxLength={400}
name="key"
onChange={this.handleInputChange}
required
type="text"
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
type="text"
value={this.state.mainBranchName}
/>
</div>
<div className="modal-field">
<label>{translate('visibility')}</label>
<VisibilitySelector
canTurnToPrivate={defaultProjectVisibility !== undefined}
className="little-spacer-top"
onChange={this.handleVisibilityChange}
visibility={this.state.visibility}
/>
</div>
</div>

<footer className="modal-foot">
{this.state.loading && <i className="spinner spacer-right" />}
<SubmitButton disabled={this.state.loading} id="create-project-submit">
{translate('create')}
</SubmitButton>
<ResetButtonLink id="create-project-cancel" onClick={this.props.onClose}>
{translate('cancel')}
</ResetButtonLink>
</footer>
</form>
)}
</Modal>
);
}
}

+ 11
- 2
server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx View File

@@ -19,6 +19,7 @@
*/
import * as React from 'react';
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button, EditButton } from '../../components/controls/buttons';
import { translate } from '../../helpers/l10n';
import { Visibility } from '../../types/component';
@@ -27,12 +28,13 @@ import ChangeDefaultVisibilityForm from './ChangeDefaultVisibilityForm';
export interface Props {
defaultProjectVisibility?: Visibility;
hasProvisionPermission?: boolean;
onProjectCreate: () => void;
onChangeDefaultProjectVisibility: (visibility: Visibility) => void;
}

export default function Header(props: Readonly<Props>) {
const [visibilityForm, setVisibilityForm] = useState(false);
const navigate = useNavigate();
const location = useLocation();

const { defaultProjectVisibility, hasProvisionPermission } = props;

@@ -56,7 +58,14 @@ export default function Header(props: Readonly<Props>) {
</span>

{hasProvisionPermission && (
<Button id="create-project" onClick={props.onProjectCreate}>
<Button
id="create-project"
onClick={() =>
navigate('/projects/create?mode=manual', {
state: { from: location.pathname },
})
}
>
{translate('qualifiers.create.TRK')}
</Button>
)}

+ 0
- 20
server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx View File

@@ -37,7 +37,6 @@ import { Visibility } from '../../types/component';
import { Permissions } from '../../types/permissions';
import { SettingsKey } from '../../types/settings';
import { LoggedInUser } from '../../types/users';
import CreateProjectForm from './CreateProjectForm';
import Header from './Header';
import Projects from './Projects';
import Search from './Search';
@@ -48,7 +47,6 @@ export interface Props {

interface State {
analyzedBefore?: Date;
createProjectForm: boolean;
defaultProjectVisibility?: Visibility;
page: number;
projects: Project[];
@@ -70,7 +68,6 @@ class ProjectManagementApp extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
createProjectForm: false,
ready: false,
projects: [],
provisioned: false,
@@ -201,14 +198,6 @@ class ProjectManagementApp extends React.PureComponent<Props, State> {
this.setState({ selection: [] });
};

openCreateProjectForm = () => {
this.setState({ createProjectForm: true });
};

closeCreateProjectForm = () => {
this.setState({ createProjectForm: false });
};

render() {
const { currentUser } = this.props;
const { defaultProjectVisibility } = this.state;
@@ -221,7 +210,6 @@ class ProjectManagementApp extends React.PureComponent<Props, State> {
defaultProjectVisibility={defaultProjectVisibility}
hasProvisionPermission={hasGlobalPermission(currentUser, Permissions.ProjectCreation)}
onChangeDefaultProjectVisibility={this.handleDefaultProjectVisibilityChange}
onProjectCreate={this.openCreateProjectForm}
/>

<Search
@@ -259,14 +247,6 @@ class ProjectManagementApp extends React.PureComponent<Props, State> {
ready={this.state.ready}
total={this.state.total}
/>

{this.state.createProjectForm && (
<CreateProjectForm
defaultProjectVisibility={defaultProjectVisibility}
onClose={this.closeCreateProjectForm}
onProjectCreated={this.requestProjects}
/>
)}
</main>
);
}

+ 14
- 31
server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx View File

@@ -101,7 +101,15 @@ const ui = {
createProject: byRole('button', {
name: 'qualifiers.create.TRK',
}),

manualProjectHeader: byText('onboarding.create_project.manual.title'),
displayNameField: byRole('textbox', {
name: /onboarding.create_project.display_name/,
}),
projectNextButton: byRole('button', { name: 'next' }),
newCodeDefinitionHeader: byText('onboarding.create_x_project.new_code_definition.title1'),
projectCreateButton: byRole('button', {
name: 'onboarding.create_project.new_code_definition.create_x_projects1',
}),
visibilityFilter: byRole('combobox', { name: 'projects_management.filter_by_visibility' }),
qualifierFilter: byRole('combobox', { name: 'projects_management.filter_by_component' }),
analysisDateFilter: byPlaceholderText('last_analysis_before'),
@@ -402,39 +410,14 @@ it('should load more and change the filter without caching old pages', async ()
});

it('should create project', async () => {
settingsHandler.set(GlobalSettingKeys.MainBranchName, 'main');
const user = userEvent.setup();
settingsHandler.set(GlobalSettingKeys.MainBranchName, 'main');
renderProjectManagementApp({}, { permissions: { global: [Permissions.ProjectCreation] } });
await waitFor(() => expect(ui.row.getAll()).toHaveLength(5));
await user.click(await ui.createProject.find());
expect(ui.createDialog.get()).toBeInTheDocument();
expect(ui.createDialog.by(ui.privateVisibility).get()).not.toBeChecked();
await user.click(ui.createDialog.by(ui.privateVisibility).get());
expect(ui.createDialog.by(ui.privateVisibility).get()).not.toBeChecked();
await user.click(ui.createDialog.by(ui.cancel).get());

expect(await ui.defaultVisibility.find()).toBeInTheDocument();
expect(ui.defaultVisibility.get()).toHaveTextContent('—');
await user.click(ui.editDefaultVisibility.get());
expect(await ui.changeDefaultVisibilityDialog.find()).toBeInTheDocument();
expect(ui.defaultVisibilityWarning.get()).not.toHaveTextContent('.github');
await user.click(ui.changeDefaultVisibilityDialog.by(ui.visibilityPublicRadio).get());
await user.click(ui.changeDefaultVisibilityDialog.by(ui.submitDefaultVisibilityChange).get());
expect(ui.changeDefaultVisibilityDialog.query()).not.toBeInTheDocument();
expect(ui.defaultVisibility.get()).toHaveTextContent('visibility.public');

await user.click(await ui.createProject.find());
expect(ui.createDialog.get()).toBeInTheDocument();
await user.click(ui.createDialog.by(ui.privateVisibility).get());
expect(ui.createDialog.by(ui.privateVisibility).get()).toBeChecked();
await user.type(ui.createDialog.by(ui.displayNameInput).get(), 'a Test');
await user.type(ui.createDialog.by(ui.projectKeyInput).get(), 'test');
expect(ui.createDialog.by(ui.mainBranchNameInput).get()).toHaveValue('main');
await user.click(ui.createDialog.by(ui.create).get());
expect(ui.createDialog.by(ui.successMsg).get()).toBeInTheDocument();
await user.click(ui.createDialog.by(ui.close).get());
expect(ui.row.getAll()).toHaveLength(6);
expect(ui.row.getAll()[1]).toHaveTextContent('qualifier.TRKa Testvisibility.privatetest—');

await user.click(ui.createProject.get());

expect(byText('/projects/create?mode=manual').get()).toBeInTheDocument();
});

it('should edit permissions of single project', async () => {

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

@@ -4207,6 +4207,8 @@ onboarding.project_analysis.header=Analyze your project
onboarding.project_analysis.description=We initialized your project on SonarQube, now it's up to you to launch analyses!
onboarding.project_analysis.guide_to_integrate_pipelines=follow the guide to integrating with Pipelines

onboarding.create_project.manual.step1=1 of 2
onboarding.create_project.manual.step2=2 of 2
onboarding.create_project.manual.title=Create a local project
onboarding.create_project.select_method=How do you want to create your project?
onboarding.create_project.select_method.manually=Are you just testing or have an advanced use-case? Create a local project.
@@ -4229,6 +4231,8 @@ onboarding.create_project.import_select_method.gitlab=Import from GitLab
onboarding.create_project.alm_not_configured=Contact your admin to set up the global configuration allowing you to import project from this DevOps Platform
onboarding.create_project.check_alm_supported=Checking if available
onboarding.create_project.project_key=Project key
onboarding.create_project.project_key.duplicate_key=The project key name is already taken.
onboarding.create_project.project_key.wrong_format=The provided value doesn't match the expected format.
onboarding.create_project.project_key.description=The project key is a unique identifier for your project. It may contain up to 400 characters. Allowed characters are alphanumeric, '-' (dash), '_' (underscore), '.' (period) and ':' (colon), with at least one non-digit.
onboarding.create_project.project_key.error.empty=You must provide at least one character.
onboarding.create_project.project_key.error.too_long=The provided key is too long.
@@ -4332,11 +4336,13 @@ onboarding.create_project.import_in_progress={count} of {total} projects importe

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
onboarding.create_project.new_code_definition.description=The new code definition sets which part of your code will be considered new code. This helps you focus attention on the most recent changes to your project, enabling you to follow the Clean as You Code methodology. Learn more: {link}
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.success.admin=Project {project_link} has been successfully created.
onboarding.create_project.failure=Import of {count, plural, one {# project} other {# projects}} failed.

onboarding.token.header=Provide a token

Loading…
Cancel
Save