From c649d7b3f2571122de9bdffbf01ce2d6ac629fa2 Mon Sep 17 00:00:00 2001 From: guillaume-peoch-sonarsource Date: Wed, 3 Jan 2024 11:28:34 +0100 Subject: [PATCH] SONAR-21023 Review field input and validation in the local project creation --- .../src/main/js/api/project-management.ts | 14 - .../apps/create/project/CreateProjectPage.tsx | 12 +- .../create/project/__tests__/Manual-it.tsx | 69 +++- .../__tests__/ManualProjectCreate-test.tsx | 15 +- .../components/NewCodeDefinitionSelection.tsx | 105 ++++-- .../project/manual/ManualProjectCreate.tsx | 333 ++++++++++-------- .../projectsManagement/CreateProjectForm.tsx | 248 ------------- .../js/apps/projectsManagement/Header.tsx | 13 +- .../ProjectManagementApp.tsx | 20 -- .../__tests__/ProjectManagementApp-it.tsx | 45 +-- .../resources/org/sonar/l10n/core.properties | 6 + 11 files changed, 379 insertions(+), 501 deletions(-) delete mode 100644 server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx diff --git a/server/sonar-web/src/main/js/api/project-management.ts b/server/sonar-web/src/main/js/api/project-management.ts index 279d78a19b8..f684e4b3750 100644 --- a/server/sonar-web/src/main/js/api/project-management.ts +++ b/server/sonar-web/src/main/js/api/project-management.ts @@ -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 { diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx index 515ee92366f..973e39baa86 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -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 this.props.router.push({ pathname: redirectTo })} /> ); } @@ -321,7 +325,7 @@ export class CreateProjectPage extends React.PureComponent {importProjects !== undefined && isProjectSetupDone && ( - + this.props.router.push({ pathname: redirectTo })} + redirectTo={redirectTo} + /> )} {creatingAlmDefinition && ( diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx index aedc1bd3ebc..bb6f76a3e91 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx @@ -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 = {}) { - renderApp('project/create', , { - 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 }), }); } diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx index bddb70d8edf..2e2e033ef76 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx @@ -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 = {}) { +function renderManualProjectCreate(props: Partial[0]> = {}) { renderComponent( - , + , ); } diff --git a/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx b/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx index 6ed492fc3ed..e35f0e6907f 100644 --- a/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/components/NewCodeDefinitionSelection.tsx @@ -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(); const [failedImports, setFailedImports] = React.useState(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( + {data.project.name}, + }} + />, + ); } + 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 ( -
+
+
+ + +
<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> ); } diff --git a/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx index 727bccee2e6..0df7e73c469 100644 --- a/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/manual/ManualProjectCreate.tsx @@ -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')} - {branchesEnabled && ( - - {translate('onboarding.create_project.pr_decoration.information')} - - )} + return ( +
+
+ + +
+ {translate('onboarding.create_project.manual.title')} + {branchesEnabled && ( + + {translate('onboarding.create_project.pr_decoration.information')} + + )} +
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 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} {projectKeyIsValid && }
- - {translate('onboarding.create_project.project_key.description')} + + {projectKeyError === 'DUPLICATE_KEY' && ( + + )} + {!isEmpty(projectKey) && projectKeyError === 'WRONG_FORMAT' && ( + + )} +

{translate('onboarding.create_project.project_key.description')}

@@ -312,7 +358,7 @@ export default class ManualProjectCreate extends React.PureComponent 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}
- + + {translate('learn_more')} + + ), + }} + /> - + + {intl.formatMessage({ id: 'cancel' })} + + {translate('next')} - ); - } -} - -function FormattedMessageWithDocLink() { - const docUrl = useDocUrl(); - - return ( - - {translate('learn_more')} - - ), - }} - /> + ); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx deleted file mode 100644 index 20b7d2ff178..00000000000 --- a/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx +++ /dev/null @@ -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 { - 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) => { - const { name, value } = event.currentTarget; - this.setState({ [name]: value }); - }; - - handleVisibilityChange = (visibility: Visibility) => { - this.setState({ visibility }); - }; - - handleFormSubmit = (event: React.SyntheticEvent) => { - 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 ( - - {createdProject ? ( -
-
-

{header}

-
- -
- - {createdProject.name} - ), - }} - /> - -
- -
- (this.closeButton = node)} - onClick={this.props.onClose} - > - {translate('close')} - -
-
- ) : ( -
-
-

{header}

-
- -
- -
- - -
-
- - -
-
- - -
-
- - -
-
- -
- {this.state.loading && } - - {translate('create')} - - - {translate('cancel')} - -
- - )} -
- ); - } -} diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx index f426a8611cd..b9076134e04 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx @@ -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) { 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) { {hasProvisionPermission && ( - )} diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx index cd0078f0bf2..baf0a3bccda 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectManagementApp.tsx @@ -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 { constructor(props: Props) { super(props); this.state = { - createProjectForm: false, ready: false, projects: [], provisioned: false, @@ -201,14 +198,6 @@ class ProjectManagementApp extends React.PureComponent { 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 { defaultProjectVisibility={defaultProjectVisibility} hasProvisionPermission={hasGlobalPermission(currentUser, Permissions.ProjectCreation)} onChangeDefaultProjectVisibility={this.handleDefaultProjectVisibilityChange} - onProjectCreate={this.openCreateProjectForm} /> { ready={this.state.ready} total={this.state.total} /> - - {this.state.createProjectForm && ( - - )} ); } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx index 9bf4f787426..16a2141c423 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectManagementApp-it.tsx @@ -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 () => { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 83bfd80c7ee..b280d8d0824 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 -- 2.39.5