diff options
Diffstat (limited to 'server')
17 files changed, 747 insertions, 183 deletions
diff --git a/server/sonar-web/src/main/js/api/components.ts b/server/sonar-web/src/main/js/api/components.ts index 60d38ed488c..7c05e3ec95c 100644 --- a/server/sonar-web/src/main/js/api/components.ts +++ b/server/sonar-web/src/main/js/api/components.ts @@ -97,11 +97,27 @@ export function deletePortfolio(portfolio: string): Promise<void | Response> { return post('/api/views/delete', { key: portfolio }).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 createProject(data: { name: string; project: string; mainBranch: string; visibility?: Visibility; + newCodeDefinitionType?: string; + newCodeDefinitionValue?: string; }): Promise<{ project: ProjectBase }> { return postJSON('/api/projects/create', data).catch(throwGlobalError); } 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 53d317ad3af..379b9a2b27b 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 @@ -17,30 +17,37 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { noop } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; +import { FormattedMessage } from 'react-intl'; import { getAlmSettings } from '../../../api/alm-settings'; import withAppStateContext from '../../../app/components/app-state/withAppStateContext'; import withAvailableFeatures, { WithAvailableFeaturesProps, } from '../../../app/components/available-features/withAvailableFeatures'; import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; +import DocLink from '../../../components/common/DocLink'; +import { ButtonLink, SubmitButton } from '../../../components/controls/buttons'; import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; +import NewCodeDefinitionSelector from '../../../components/new-code-definition/NewCodeDefinitionSelector'; import { translate } from '../../../helpers/l10n'; import { getProjectUrl } from '../../../helpers/urls'; import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; import { AppState } from '../../../types/appstate'; import { Feature } from '../../../types/features'; +import { NewCodePeriodWithCompliance } from '../../../types/types'; import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm'; import AzureProjectCreate from './Azure/AzureProjectCreate'; import BitbucketCloudProjectCreate from './BitbucketCloud/BitbucketCloudProjectCreate'; import BitbucketProjectCreate from './BitbucketServer/BitbucketProjectCreate'; +import CreateProjectPageHeader from './components/CreateProjectPageHeader'; import CreateProjectModeSelection from './CreateProjectModeSelection'; import GitHubProjectCreate from './Github/GitHubProjectCreate'; import GitlabProjectCreate from './Gitlab/GitlabProjectCreate'; import ManualProjectCreate from './manual/ManualProjectCreate'; import './style.css'; -import { CreateProjectModes } from './types'; +import { CreateProjectApiCallback, CreateProjectModes } from './types'; export interface CreateProjectPageProps extends WithAvailableFeaturesProps { appState: AppState; @@ -55,7 +62,9 @@ interface State { githubSettings: AlmSettingsInstance[]; gitlabSettings: AlmSettingsInstance[]; loading: boolean; + isProjectSetupDone: boolean; creatingAlmDefinition?: AlmKeys; + selectedNcd: NewCodePeriodWithCompliance | null; } const PROJECT_MODE_FOR_ALM_KEY = { @@ -68,6 +77,8 @@ const PROJECT_MODE_FOR_ALM_KEY = { export class CreateProjectPage extends React.PureComponent<CreateProjectPageProps, State> { mounted = false; + createProjectFnRef: CreateProjectApiCallback | null = null; + state: State = { azureSettings: [], bitbucketSettings: [], @@ -75,6 +86,8 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp githubSettings: [], gitlabSettings: [], loading: true, + isProjectSetupDone: false, + selectedNcd: null, }; componentDidMount() { @@ -124,6 +137,21 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp this.props.router.push(getProjectUrl(projectKey)); }; + handleManualProjectCreate = () => { + const { selectedNcd } = this.state; + if (this.createProjectFnRef && selectedNcd) { + this.createProjectFnRef(selectedNcd.type, selectedNcd.value).then( + ({ project }) => this.handleProjectCreate(project.key), + noop + ); + } + }; + + handleProjectSetupDone = (createProject: CreateProjectApiCallback) => { + this.createProjectFnRef = createProject; + this.setState({ isProjectSetupDone: true }); + }; + handleOnCancelCreation = () => { this.setState({ creatingAlmDefinition: undefined }); }; @@ -146,6 +174,16 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp } }; + handleNcdChanged = (ncd: NewCodePeriodWithCompliance) => { + this.setState({ + selectedNcd: ncd, + }); + }; + + handleGoBack = () => { + this.setState({ isProjectSetupDone: false }); + }; + renderProjectCreation(mode?: CreateProjectModes) { const { appState: { canAdmin }, @@ -227,7 +265,7 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp return ( <ManualProjectCreate branchesEnabled={branchSupportEnabled} - onProjectCreate={this.handleProjectCreate} + onProjectSetupDone={this.handleProjectSetupDone} /> ); } @@ -251,9 +289,59 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp } } + renderNcdSelection() { + const { appState } = this.props; + const { selectedNcd } = this.state; + + return ( + <div id="project-ncd-selection"> + <CreateProjectPageHeader + title={translate('onboarding.create_project.new_code_definition.title')} + /> + + <h1 className="sw-mb-4">{translate('onboarding.create_project.new_code_definition')}</h1> + <p className="sw-mb-2"> + {translate('onboarding.create_project.new_code_definition.description')} + </p> + <p className="sw-mb-2"> + {translate('onboarding.create_project.new_code_definition.description1')} + </p> + + <p className="sw-mb-2"> + <FormattedMessage + defaultMessage={translate('onboarding.create_project.new_code_definition.description2')} + id="onboarding.create_project.new_code_definition.description2" + values={{ + link: ( + <DocLink to="/project-administration/defining-new-code/"> + {translate('onboarding.create_project.new_code_definition.description2.link')} + </DocLink> + ), + }} + /> + </p> + + <NewCodeDefinitionSelector + canAdmin={appState.canAdmin} + onNcdChanged={this.handleNcdChanged} + /> + + <div className="sw-flex sw-flex-row sw-gap-2 sw-mt-4"> + <ButtonLink onClick={this.handleGoBack}>{translate('back')}</ButtonLink> + <SubmitButton + onClick={this.handleManualProjectCreate} + disabled={!selectedNcd?.isCompliant} + > + {translate('onboarding.create_project.new_code_definition.create_project')} + </SubmitButton> + </div> + </div> + ); + } + render() { const { location } = this.props; - const { creatingAlmDefinition } = this.state; + const { creatingAlmDefinition, isProjectSetupDone } = this.state; const mode: CreateProjectModes | undefined = location.query?.mode; return ( @@ -261,7 +349,8 @@ export class CreateProjectPage extends React.PureComponent<CreateProjectPageProp <Helmet title={translate('onboarding.create_project.select_method')} titleTemplate="%s" /> <A11ySkipTarget anchor="create_project_main" /> <div className="page page-limited huge-spacer-bottom position-relative" id="create-project"> - {this.renderProjectCreation(mode)} + {isProjectSetupDone ? this.renderNcdSelection() : this.renderProjectCreation(mode)} + {creatingAlmDefinition && ( <AlmBindingDefinitionForm alm={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 new file mode 100644 index 00000000000..06bb6cb7c3e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/Manual-it.tsx @@ -0,0 +1,248 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 userEvent from '@testing-library/user-event'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import * as React from 'react'; +import { byRole, byText } from 'testing-library-selector'; +import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock'; +import { getNewCodePeriod } from '../../../../api/newCodePeriod'; +import { mockProject } from '../../../../helpers/mocks/projects'; +import { mockAppState } from '../../../../helpers/testMocks'; +import { renderApp } from '../../../../helpers/testReactTestingUtils'; +import { NewCodePeriodSettingType } from '../../../../types/types'; +import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage'; +import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock'; + +jest.mock('../../../../api/alm-settings'); +jest.mock('../../../../api/newCodePeriod'); +jest.mock('../../../../api/components', () => ({ + ...jest.requireActual('../../../../api/components'), + setupManualProjectCreation: jest + .fn() + .mockReturnValue(() => Promise.resolve({ project: mockProject() })), + doesComponentExists: jest + .fn() + .mockImplementation(({ component }) => Promise.resolve(component === 'exists')), +})); +jest.mock('../../../../api/settings', () => ({ + getValue: jest.fn().mockResolvedValue({ value: 'main' }), +})); + +const ui = { + manualCreateProjectOption: byText('onboarding.create_project.select_method.manual'), + manualProjectHeader: byText('onboarding.create_project.setup_manually'), + displayNameField: byRole('textbox', { + name: /onboarding.create_project.display_name/, + }), + projectNextButton: byRole('button', { name: 'next' }), + newCodeDefinitionHeader: byText('onboarding.create_project.new_code_definition.title'), + newCodeDefinitionBackButton: byRole('button', { name: 'back' }), + inheritGlobalNcdRadio: byRole('radio', { name: 'new_code_definition.global_setting' }), + projectCreateButton: byRole('button', { + name: 'onboarding.create_project.new_code_definition.create_project', + }), + overrideNcdRadio: byRole('radio', { name: 'new_code_definition.specific_setting' }), + ncdOptionPreviousVersionRadio: byRole('radio', { + name: /new_code_definition.previous_version/, + }), + ncdOptionRefBranchRadio: byRole('radio', { + name: /new_code_definition.reference_branch/, + }), + ncdOptionDaysRadio: byRole('radio', { + name: /new_code_definition.number_days/, + }), + ncdOptionDaysInput: byRole('textbox', { + name: /new_code_definition.number_days.specify_days/, + }), + ncdOptionDaysInputError: byText('new_code_definition.number_days.invalid.1.90'), + ncdWarningTextAdmin: byText('new_code_definition.compliance.warning.explanation.admin'), + ncdWarningText: byText('new_code_definition.compliance.warning.explanation'), + projectDashboardText: byText('/dashboard?id=foo'), +}; + +async function fillFormAndNext(displayName: string, user: UserEvent) { + await user.click(ui.manualCreateProjectOption.get()); + + expect(ui.manualProjectHeader.get()).toBeInTheDocument(); + + await user.click(ui.displayNameField.get()); + await user.keyboard(displayName); + + expect(ui.projectNextButton.get()).toBeEnabled(); + await user.click(ui.projectNextButton.get()); +} + +let almSettingsHandler: AlmSettingsServiceMock; +let newCodePeriodHandler: NewCodePeriodsServiceMock; + +beforeAll(() => { + almSettingsHandler = new AlmSettingsServiceMock(); + newCodePeriodHandler = new NewCodePeriodsServiceMock(); +}); + +beforeEach(() => { + jest.clearAllMocks(); + almSettingsHandler.reset(); + newCodePeriodHandler.reset(); +}); + +it('should fill form and move to NCD selection and back', async () => { + const user = userEvent.setup(); + renderCreateProject(); + await fillFormAndNext('test', user); + + expect(ui.newCodeDefinitionHeader.get()).toBeInTheDocument(); + + expect(ui.newCodeDefinitionBackButton.get()).toBeInTheDocument(); + await user.click(ui.newCodeDefinitionBackButton.get()); + + expect(ui.manualProjectHeader.get()).toBeInTheDocument(); + + // TODO this must work at some point + // expect(ui.displayNameField.get()).toHaveValue('test'); +}); + +it('should select the global NCD when it is compliant', async () => { + jest + .mocked(getNewCodePeriod) + .mockResolvedValue({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '30' }); + const user = userEvent.setup(); + renderCreateProject(); + await fillFormAndNext('test', user); + + expect(ui.newCodeDefinitionHeader.get()).toBeInTheDocument(); + expect(ui.inheritGlobalNcdRadio.get()).toBeInTheDocument(); + expect(ui.inheritGlobalNcdRadio.get()).toBeEnabled(); + expect(ui.projectCreateButton.get()).toBeDisabled(); + + await user.click(ui.inheritGlobalNcdRadio.get()); + + expect(ui.projectCreateButton.get()).toBeEnabled(); +}); + +it('global NCD option should be disabled if not compliant', async () => { + jest + .mocked(getNewCodePeriod) + .mockResolvedValue({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '96' }); + const user = userEvent.setup(); + renderCreateProject(); + await fillFormAndNext('test', user); + + expect(ui.newCodeDefinitionHeader.get()).toBeInTheDocument(); + expect(ui.inheritGlobalNcdRadio.get()).toBeInTheDocument(); + expect(ui.inheritGlobalNcdRadio.get()).toHaveClass('disabled'); + expect(ui.projectCreateButton.get()).toBeDisabled(); +}); + +it.each([ + { canAdmin: true, message: ui.ncdWarningTextAdmin }, + { canAdmin: false, message: ui.ncdWarningText }, +])( + 'should show warning message when global NCD is not compliant', + async ({ canAdmin, message }) => { + jest + .mocked(getNewCodePeriod) + .mockResolvedValue({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '96' }); + const user = userEvent.setup(); + renderCreateProject({ appState: mockAppState({ canAdmin }) }); + await fillFormAndNext('test', user); + + expect(message.get()).toBeInTheDocument(); + } +); + +it.each([ui.ncdOptionRefBranchRadio, ui.ncdOptionPreviousVersionRadio])( + 'should override the global NCD and pick a compliant NCD', + async (option) => { + jest + .mocked(getNewCodePeriod) + .mockResolvedValue({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '96' }); + const user = userEvent.setup(); + renderCreateProject(); + await fillFormAndNext('test', user); + + expect(ui.newCodeDefinitionHeader.get()).toBeInTheDocument(); + expect(ui.inheritGlobalNcdRadio.get()).toBeInTheDocument(); + expect(ui.inheritGlobalNcdRadio.get()).toHaveClass('disabled'); + expect(ui.projectCreateButton.get()).toBeDisabled(); + expect(ui.overrideNcdRadio.get()).not.toHaveClass('disabled'); + expect(option.get()).toHaveClass('disabled'); + + await user.click(ui.overrideNcdRadio.get()); + expect(option.get()).not.toHaveClass('disabled'); + + await user.click(option.get()); + + expect(ui.projectCreateButton.get()).toBeEnabled(); + } +); + +it('number of days should show error message if value is not a number', async () => { + jest + .mocked(getNewCodePeriod) + .mockResolvedValue({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '60' }); + const user = userEvent.setup(); + renderCreateProject(); + await fillFormAndNext('test', user); + + expect(ui.projectCreateButton.get()).toBeDisabled(); + expect(ui.overrideNcdRadio.get()).not.toHaveClass('disabled'); + expect(ui.ncdOptionDaysRadio.get()).toHaveClass('disabled'); + + await user.click(ui.overrideNcdRadio.get()); + expect(ui.ncdOptionDaysRadio.get()).not.toHaveClass('disabled'); + + await user.click(ui.ncdOptionDaysRadio.get()); + + expect(ui.ncdOptionDaysInput.get()).toBeInTheDocument(); + expect(ui.ncdOptionDaysInput.get()).toHaveValue('30'); + expect(ui.projectCreateButton.get()).toBeEnabled(); + + await user.click(ui.ncdOptionDaysInput.get()); + await user.keyboard('abc'); + + expect(ui.ncdOptionDaysInputError.get()).toBeInTheDocument(); + expect(ui.projectCreateButton.get()).toBeDisabled(); + + await user.clear(ui.ncdOptionDaysInput.get()); + await user.click(ui.ncdOptionDaysInput.get()); + await user.keyboard('30'); + + expect(ui.ncdOptionDaysInputError.query()).not.toBeInTheDocument(); + expect(ui.projectCreateButton.get()).toBeEnabled(); +}); + +it('the project onboarding page should be displayed when the project is created', async () => { + newCodePeriodHandler.setNewCodePeriod({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS }); + const user = userEvent.setup(); + renderCreateProject(); + await fillFormAndNext('testing', user); + + await user.click(ui.overrideNcdRadio.get()); + + expect(ui.projectCreateButton.get()).toBeEnabled(); + await user.click(ui.projectCreateButton.get()); + + expect(await ui.projectDashboardText.find()).toBeInTheDocument(); +}); + +function renderCreateProject(props: Partial<CreateProjectPageProps> = {}) { + renderApp('project/create', <CreateProjectPage {...props} />); +} 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 98a05291a2a..8bcecd0dd45 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 @@ -20,13 +20,17 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { createProject, doesComponentExists } from '../../../../api/components'; -import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock'; +import { byRole } from 'testing-library-selector'; +import { doesComponentExists } from '../../../../api/components'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; import ManualProjectCreate from '../manual/ManualProjectCreate'; +const ui = { + nextButton: byRole('button', { name: 'next' }), +}; + jest.mock('../../../../api/components', () => ({ - createProject: jest.fn().mockResolvedValue({ project: { key: 'bar', name: 'Bar' } }), + setupManualProjectCreation: jest.fn(), doesComponentExists: jest .fn() .mockImplementation(({ component }) => Promise.resolve(component === 'exists')), @@ -36,15 +40,8 @@ jest.mock('../../../../api/settings', () => ({ getValue: jest.fn().mockResolvedValue({ value: 'main' }), })); -let newCodePeriodHandler: NewCodePeriodsServiceMock; - -beforeAll(() => { - newCodePeriodHandler = new NewCodePeriodsServiceMock(); -}); - beforeEach(() => { jest.clearAllMocks(); - newCodePeriodHandler.reset(); }); it('should show branch information', async () => { @@ -68,7 +65,7 @@ it('should validate form input', async () => { expect( screen.getByRole('textbox', { name: 'onboarding.create_project.project_key field_required' }) ).toHaveValue('test'); - expect(screen.getByRole('button', { name: 'set_up' })).toBeEnabled(); + expect(ui.nextButton.get()).toBeEnabled(); // Sanitize the key await user.click( @@ -93,7 +90,7 @@ it('should validate form input', async () => { expect( screen.getByText('onboarding.create_project.display_name.error.empty') ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'set_up' })).toBeDisabled(); + expect(ui.nextButton.get()).toBeDisabled(); // Only key await user.click( @@ -142,8 +139,8 @@ it('should validate form input', async () => { it('should submit form input', async () => { const user = userEvent.setup(); - const onProjectCreate = jest.fn(); - renderManualProjectCreate({ onProjectCreate }); + const onProjectSetupDone = jest.fn(); + renderManualProjectCreate({ onProjectSetupDone }); // All input valid await user.click( @@ -152,38 +149,14 @@ 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', - mainBranch: 'main', - }); - expect(onProjectCreate).toHaveBeenCalled(); -}); - -it('should handle create failure', async () => { - const user = userEvent.setup(); - (createProject as jest.Mock).mockRejectedValueOnce({}); - const onProjectCreate = jest.fn(); - renderManualProjectCreate({ onProjectCreate }); - - // All input valid - await user.click( - await screen.findByRole('textbox', { - name: 'onboarding.create_project.display_name field_required', - }) - ); - await user.keyboard('test'); - await user.click(screen.getByRole('button', { name: 'set_up' })); - - expect(onProjectCreate).not.toHaveBeenCalled(); + await user.click(ui.nextButton.get()); + expect(onProjectSetupDone).toHaveBeenCalled(); }); it('should handle component exists failure', async () => { const user = userEvent.setup(); - (doesComponentExists as jest.Mock).mockRejectedValueOnce({}); - const onProjectCreate = jest.fn(); - renderManualProjectCreate({ onProjectCreate }); + jest.mocked(doesComponentExists).mockRejectedValueOnce({}); + renderManualProjectCreate(); // All input valid await user.click( @@ -199,6 +172,6 @@ it('should handle component exists failure', async () => { function renderManualProjectCreate(props: Partial<ManualProjectCreate['props']> = {}) { renderComponent( - <ManualProjectCreate branchesEnabled={false} onProjectCreate={jest.fn()} {...props} /> + <ManualProjectCreate branchesEnabled={false} onProjectSetupDone={jest.fn()} {...props} /> ); } 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 f2c681e6164..ef344ed928d 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 @@ -21,26 +21,25 @@ import classNames from 'classnames'; import { debounce, isEmpty } from 'lodash'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { createProject, doesComponentExists } from '../../../../api/components'; +import { doesComponentExists, setupManualProjectCreation } from '../../../../api/components'; import { getValue } from '../../../../api/settings'; import DocLink from '../../../../components/common/DocLink'; import ProjectKeyInput from '../../../../components/common/ProjectKeyInput'; import ValidationInput from '../../../../components/controls/ValidationInput'; import { SubmitButton } from '../../../../components/controls/buttons'; import { Alert } from '../../../../components/ui/Alert'; -import DeferredSpinner from '../../../../components/ui/DeferredSpinner'; import MandatoryFieldsExplanation from '../../../../components/ui/MandatoryFieldsExplanation'; 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 CreateProjectPageHeader from '../components/CreateProjectPageHeader'; -import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning'; import { PROJECT_NAME_MAX_LEN } from '../constants'; +import { CreateProjectApiCallback } from '../types'; interface Props { branchesEnabled: boolean; - onProjectCreate: (projectKey: string) => void; + onProjectSetupDone: (createProject: CreateProjectApiCallback) => void; } interface State { @@ -54,7 +53,6 @@ interface State { mainBranchName: string; mainBranchNameError?: string; mainBranchNameTouched: boolean; - submitting: boolean; } const DEBOUNCE_DELAY = 250; @@ -69,7 +67,6 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat this.state = { projectKey: '', projectName: '', - submitting: false, projectKeyTouched: false, projectNameTouched: false, mainBranchName: 'main', @@ -132,18 +129,12 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat event.preventDefault(); const { projectKey, projectName, mainBranchName } = this.state; if (this.canSubmit(this.state)) { - this.setState({ submitting: true }); - createProject({ - project: projectKey, - name: (projectName || projectKey).trim(), - mainBranch: mainBranchName, - }).then( - ({ project }) => this.props.onProjectCreate(project.key), - () => { - if (this.mounted) { - this.setState({ submitting: false }); - } - } + this.props.onProjectSetupDone( + setupManualProjectCreation({ + project: projectKey, + name: (projectName || projectKey).trim(), + mainBranch: mainBranchName, + }) ); } }; @@ -221,7 +212,6 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat mainBranchName, mainBranchNameError, mainBranchNameTouched, - submitting, } = this.state; const { branchesEnabled } = this.props; @@ -235,8 +225,6 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat <> <CreateProjectPageHeader title={translate('onboarding.create_project.setup_manually')} /> - <InstanceNewCodeDefinitionComplianceWarning /> - <form id="create-project-manual" onSubmit={this.handleFormSubmit}> <MandatoryFieldsExplanation className="big-spacer-bottom" /> @@ -308,10 +296,7 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat /> </ValidationInput> - <SubmitButton disabled={!this.canSubmit(this.state) || submitting}> - {translate('set_up')} - </SubmitButton> - <DeferredSpinner className="spacer-left" loading={submitting} /> + <SubmitButton disabled={!this.canSubmit(this.state)}>{translate('next')}</SubmitButton> </form> {branchesEnabled && ( diff --git a/server/sonar-web/src/main/js/apps/create/project/types.ts b/server/sonar-web/src/main/js/apps/create/project/types.ts index f8c9fc9b054..ddd7c3700a4 100644 --- a/server/sonar-web/src/main/js/apps/create/project/types.ts +++ b/server/sonar-web/src/main/js/apps/create/project/types.ts @@ -17,6 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { ProjectBase } from '../../../api/components'; +import { NewCodePeriodSettingType } from '../../../types/types'; + export enum CreateProjectModes { Manual = 'manual', AzureDevOps = 'azure', @@ -25,3 +28,8 @@ export enum CreateProjectModes { GitHub = 'github', GitLab = 'gitlab', } + +export type CreateProjectApiCallback = ( + newCodeDefinitionType?: NewCodePeriodSettingType, + newCodeDefinitionValue?: string +) => Promise<{ project: ProjectBase }>; diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx index 5dcca14156d..c60deb34b08 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx @@ -19,8 +19,10 @@ */ import * as React from 'react'; import { setNewCodePeriod } from '../../../api/newCodePeriod'; -import Modal from '../../../components/controls/Modal'; import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; +import Modal from '../../../components/controls/Modal'; +import NewCodeDefinitionDaysOption from '../../../components/new-code-definition/NewCodeDefinitionDaysOption'; +import NewCodeDefinitionPreviousVersionOption from '../../../components/new-code-definition/NewCodeDefinitionPreviousVersionOption'; import NewCodeDefinitionWarning from '../../../components/new-code-definition/NewCodeDefinitionWarning'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import { toISO8601WithOffsetString } from '../../../helpers/dates'; @@ -30,8 +32,6 @@ import { ParsedAnalysis } from '../../../types/project-activity'; import { NewCodePeriod, NewCodePeriodSettingType } from '../../../types/types'; import { getSettingValue, validateSetting } from '../utils'; import BaselineSettingAnalysis from './BaselineSettingAnalysis'; -import BaselineSettingDays from './BaselineSettingDays'; -import BaselineSettingPreviousVersion from './BaselineSettingPreviousVersion'; import BaselineSettingReferenceBranch from './BaselineSettingReferenceBranch'; import BranchAnalysisList from './BranchAnalysisList'; @@ -170,12 +170,12 @@ export default class BranchBaselineSettingModal extends React.PureComponent<Prop level="branch" /> <div className="display-flex-row huge-spacer-bottom" role="radiogroup"> - <BaselineSettingPreviousVersion + <NewCodeDefinitionPreviousVersionOption isDefault={false} onSelect={this.handleSelectSetting} selected={selected === NewCodePeriodSettingType.PREVIOUS_VERSION} /> - <BaselineSettingDays + <NewCodeDefinitionDaysOption days={days} isChanged={isChanged} isValid={isValid} diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchListRow.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchListRow.tsx index a386e12a2d1..39197e19dd2 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchListRow.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchListRow.tsx @@ -24,9 +24,9 @@ import BranchLikeIcon from '../../../components/icons/BranchLikeIcon'; import WarningIcon from '../../../components/icons/WarningIcon'; import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { isNewCodeDefinitionCompliant } from '../../../helpers/periods'; import { BranchWithNewCodePeriod } from '../../../types/branch-like'; import { NewCodePeriod, NewCodePeriodSettingType } from '../../../types/types'; -import { isNewCodeDefinitionCompliant } from '../../../helpers/periods'; export interface BranchListRowProps { branch: BranchWithNewCodePeriod; @@ -50,9 +50,9 @@ function renderNewCodePeriodSetting(newCodePeriod: NewCodePeriod) { </> ); case NewCodePeriodSettingType.NUMBER_OF_DAYS: - return `${translate('baseline.number_days')}: ${newCodePeriod.value}`; + return `${translate('new_code_definition.number_days')}: ${newCodePeriod.value}`; case NewCodePeriodSettingType.PREVIOUS_VERSION: - return translate('baseline.previous_version'); + return translate('new_code_definition.previous_version'); case NewCodePeriodSettingType.REFERENCE_BRANCH: return `${translate('baseline.reference_branch')}: ${newCodePeriod.value}`; default: diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx index 06503b77976..f1104622cfe 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx @@ -19,23 +19,22 @@ */ import classNames from 'classnames'; import * as React from 'react'; -import { FormattedMessage } from 'react-intl'; -import Link from '../../../components/common/Link'; +import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import Radio from '../../../components/controls/Radio'; import Tooltip from '../../../components/controls/Tooltip'; -import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; +import GlobalNewCodeDefinitionDescription from '../../../components/new-code-definition/GlobalNewCodeDefinitionDescription'; +import NewCodeDefinitionDaysOption from '../../../components/new-code-definition/NewCodeDefinitionDaysOption'; +import NewCodeDefinitionPreviousVersionOption from '../../../components/new-code-definition/NewCodeDefinitionPreviousVersionOption'; import NewCodeDefinitionWarning from '../../../components/new-code-definition/NewCodeDefinitionWarning'; import { Alert } from '../../../components/ui/Alert'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { translate } from '../../../helpers/l10n'; import { isNewCodeDefinitionCompliant } from '../../../helpers/periods'; import { Branch } from '../../../types/branch-like'; import { ParsedAnalysis } from '../../../types/project-activity'; import { NewCodePeriod, NewCodePeriodSettingType } from '../../../types/types'; import { validateSetting } from '../utils'; import BaselineSettingAnalysis from './BaselineSettingAnalysis'; -import BaselineSettingDays from './BaselineSettingDays'; -import BaselineSettingPreviousVersion from './BaselineSettingPreviousVersion'; import BaselineSettingReferenceBranch from './BaselineSettingReferenceBranch'; import BranchAnalysisList from './BranchAnalysisList'; @@ -63,33 +62,6 @@ export interface ProjectBaselineSelectorProps { overrideGeneralSetting: boolean; } -function renderGeneralSetting(generalSetting: NewCodePeriod) { - let setting: string; - let description: string; - let useCase: string; - if (generalSetting.type === NewCodePeriodSettingType.NUMBER_OF_DAYS) { - setting = `${translate('baseline.number_days')} (${translateWithParameters( - 'duration.days', - generalSetting.value || '?' - )})`; - description = translate('baseline.number_days.description'); - useCase = translate('baseline.number_days.usecase'); - } else { - setting = translate('baseline.previous_version'); - description = translate('baseline.previous_version.description'); - useCase = translate('baseline.previous_version.usecase'); - } - - return ( - <div className="general-setting display-flex-start"> - <span className="sw-font-bold flex-0">{setting}: </span> - <span> - {description} {useCase} - </span> - </div> - ); -} - function branchToOption(b: Branch) { return { label: b.name, value: b.name, isMain: b.isMain }; } @@ -112,7 +84,7 @@ export default function ProjectBaselineSelector(props: ProjectBaselineSelectorPr selected, } = props; - const isGeneralSettingCompliant = isNewCodeDefinitionCompliant(generalSetting); + const isGlobalNcdCompliant = isNewCodeDefinitionCompliant(generalSetting); const { isChanged, isValid } = validateSetting({ analysis, @@ -130,13 +102,13 @@ export default function ProjectBaselineSelector(props: ProjectBaselineSelectorPr <Radio checked={!overrideGeneralSetting} className="big-spacer-bottom" - disabled={!isGeneralSettingCompliant} + disabled={!isGlobalNcdCompliant} onCheck={() => props.onToggleSpecificSetting(false)} value="general" > <Tooltip overlay={ - isGeneralSettingCompliant + isGlobalNcdCompliant ? null : translate('project_baseline.compliance.warning.title.global') } @@ -145,34 +117,12 @@ export default function ProjectBaselineSelector(props: ProjectBaselineSelectorPr </Tooltip> </Radio> - <div className="big-spacer-left"> - {!isGeneralSettingCompliant && ( - <Alert variant="warning" className="sw-mb-4 sw-max-w-[800px]"> - <p className="sw-mb-2 sw-font-bold"> - {translate('project_baseline.compliance.warning.title.global')} - </p> - <p className="sw-mb-2"> - {canAdmin ? ( - <FormattedMessage - id="project_baseline.compliance.warning.explanation.admin" - defaultMessage={translate( - 'project_baseline.compliance.warning.explanation.admin' - )} - values={{ - link: ( - <Link to="/admin/settings?category=new_code_period"> - {translate('project_baseline.warning.explanation.action.admin.link')} - </Link> - ), - }} - /> - ) : ( - translate('project_baseline.compliance.warning.explanation') - )} - </p> - </Alert> - )} - {renderGeneralSetting(generalSetting)} + <div className="sw-ml-4"> + <GlobalNewCodeDefinitionDescription + globalNcd={generalSetting} + isGlobalNcdCompliant={isGlobalNcdCompliant} + canAdmin={canAdmin} + /> </div> <Radio @@ -193,14 +143,14 @@ export default function ProjectBaselineSelector(props: ProjectBaselineSelectorPr level="project" /> <div className="display-flex-row big-spacer-bottom" role="radiogroup"> - <BaselineSettingPreviousVersion + <NewCodeDefinitionPreviousVersionOption disabled={!overrideGeneralSetting} onSelect={props.onSelectSetting} selected={ overrideGeneralSetting && selected === NewCodePeriodSettingType.PREVIOUS_VERSION } /> - <BaselineSettingDays + <NewCodeDefinitionDaysOption days={days} disabled={!overrideGeneralSetting} isChanged={isChanged} diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx index 6cf0cc88d26..c51eb64863a 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx @@ -29,8 +29,8 @@ import { mockComponent } from '../../../../helpers/mocks/component'; import { mockNewCodePeriodBranch } from '../../../../helpers/mocks/new-code-period'; import { mockAppState } from '../../../../helpers/testMocks'; import { - RenderContext, renderAppWithComponentContext, + RenderContext, } from '../../../../helpers/testReactTestingUtils'; import { Feature } from '../../../../types/features'; import { NewCodePeriodSettingType } from '../../../../types/types'; @@ -213,7 +213,9 @@ it('can set a previous version setting for branch', async () => { await ui.appIsLoaded(); await ui.setBranchPreviousVersionSetting('main'); - expect(within(byRole('table').get()).getByText('baseline.previous_version')).toBeInTheDocument(); + expect( + within(byRole('table').get()).getByText('new_code_definition.previous_version') + ).toBeInTheDocument(); await user.click(await ui.branchActionsButton('main').find()); @@ -234,7 +236,9 @@ it('can set a number of days setting for branch', async () => { await ui.setBranchNumberOfDaysSetting('main', '15'); - expect(within(byRole('table').get()).getByText('baseline.number_days: 15')).toBeInTheDocument(); + expect( + within(byRole('table').get()).getByText('new_code_definition.number_days: 15') + ).toBeInTheDocument(); }); it('cannot set a specific analysis setting for branch', async () => { @@ -295,8 +299,10 @@ function getPageObjects() { generalSettingsLink: byRole('link', { name: 'project_baseline.page.description2.link' }), generalSettingRadio: byRole('radio', { name: 'project_baseline.global_setting' }), specificSettingRadio: byRole('radio', { name: 'project_baseline.specific_setting' }), - previousVersionRadio: byRole('radio', { name: /baseline.previous_version.description/ }), - numberDaysRadio: byRole('radio', { name: /baseline.number_days.description/ }), + previousVersionRadio: byRole('radio', { + name: /new_code_definition.previous_version.description/, + }), + numberDaysRadio: byRole('radio', { name: /new_code_definition.number_days.description/ }), numberDaysInput: byRole('textbox'), referenceBranchRadio: byRole('radio', { name: /baseline.reference_branch.description/ }), chooseBranchSelect: byRole('combobox', { name: 'baseline.reference_branch.choose' }), @@ -311,8 +317,8 @@ function getPageObjects() { editButton: byRole('button', { name: 'edit' }), resetToDefaultButton: byRole('button', { name: 'reset_to_default' }), saved: byText('settings.state.saved'), - complianceWarningAdmin: byText('project_baseline.compliance.warning.explanation.admin'), - complianceWarning: byText('project_baseline.compliance.warning.explanation'), + complianceWarningAdmin: byText('new_code_definition.compliance.warning.explanation.admin'), + complianceWarning: byText('new_code_definition.compliance.warning.explanation'), }; async function appIsLoaded() { diff --git a/server/sonar-web/src/main/js/apps/settings/components/NewCodePeriod.tsx b/server/sonar-web/src/main/js/apps/settings/components/NewCodePeriod.tsx index aea830a6d97..96498f2fd8a 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/NewCodePeriod.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/NewCodePeriod.tsx @@ -23,13 +23,13 @@ import { getNewCodePeriod, setNewCodePeriod } from '../../../api/newCodePeriod'; import DocLink from '../../../components/common/DocLink'; import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import AlertSuccessIcon from '../../../components/icons/AlertSuccessIcon'; +import NewCodeDefinitionDaysOption from '../../../components/new-code-definition/NewCodeDefinitionDaysOption'; +import NewCodeDefinitionPreviousVersionOption from '../../../components/new-code-definition/NewCodeDefinitionPreviousVersionOption'; import NewCodeDefinitionWarning from '../../../components/new-code-definition/NewCodeDefinitionWarning'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import { translate } from '../../../helpers/l10n'; import { isNewCodeDefinitionCompliant } from '../../../helpers/periods'; import { NewCodePeriodSettingType } from '../../../types/types'; -import BaselineSettingDays from '../../projectBaseline/components/BaselineSettingDays'; -import BaselineSettingPreviousVersion from '../../projectBaseline/components/BaselineSettingPreviousVersion'; interface State { currentSetting?: NewCodePeriodSettingType; @@ -184,12 +184,12 @@ export default class NewCodePeriod extends React.PureComponent<{}, State> { <div className="settings-definition-right"> <DeferredSpinner loading={loading} timeout={500}> <form onSubmit={this.onSubmit}> - <BaselineSettingPreviousVersion + <NewCodeDefinitionPreviousVersionOption isDefault onSelect={this.onSelectSetting} selected={selected === NewCodePeriodSettingType.PREVIOUS_VERSION} /> - <BaselineSettingDays + <NewCodeDefinitionDaysOption className="spacer-top sw-mb-4" days={days} isChanged={isChanged} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/NewCodePeriod-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/NewCodePeriod-it.tsx index 640829ede70..406b219b470 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/NewCodePeriod-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/NewCodePeriod-it.tsx @@ -39,9 +39,9 @@ afterEach(() => { const ui = { newCodeTitle: byRole('heading', { name: 'settings.new_code_period.title' }), savedMsg: byText('settings.state.saved'), - prevVersionRadio: byRole('radio', { name: /baseline.previous_version/ }), - daysNumberRadio: byRole('radio', { name: /baseline.number_days/ }), - daysNumberErrorMessage: byText('baseline.number_days.invalid', { exact: false }), + prevVersionRadio: byRole('radio', { name: /new_code_definition.previous_version/ }), + daysNumberRadio: byRole('radio', { name: /new_code_definition.number_days/ }), + daysNumberErrorMessage: byText('new_code_definition.number_days.invalid', { exact: false }), daysInput: byRole('textbox'), saveButton: byRole('button', { name: 'save' }), cancelButton: byRole('button', { name: 'cancel' }), diff --git a/server/sonar-web/src/main/js/components/new-code-definition/GlobalNewCodeDefinitionDescription.tsx b/server/sonar-web/src/main/js/components/new-code-definition/GlobalNewCodeDefinitionDescription.tsx new file mode 100644 index 00000000000..2751b703257 --- /dev/null +++ b/server/sonar-web/src/main/js/components/new-code-definition/GlobalNewCodeDefinitionDescription.tsx @@ -0,0 +1,92 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { translate, translateWithParameters } from '../../helpers/l10n'; +import { NewCodePeriod, NewCodePeriodSettingType } from '../../types/types'; +import Link from '../common/Link'; +import { Alert } from '../ui/Alert'; + +interface Props { + globalNcd: NewCodePeriod; + isGlobalNcdCompliant: boolean; + canAdmin?: boolean; +} + +export default function GlobalNewCodeDefinitionDescription({ + globalNcd, + isGlobalNcdCompliant, + canAdmin, +}: Props) { + let setting: string; + let description: string; + let useCase: string; + if (globalNcd.type === NewCodePeriodSettingType.NUMBER_OF_DAYS) { + setting = `${translate('new_code_definition.number_days')} (${translateWithParameters( + 'duration.days', + globalNcd.value ?? '?' + )})`; + description = translate('new_code_definition.number_days.description'); + useCase = translate('new_code_definition.number_days.usecase'); + } else { + setting = translate('new_code_definition.previous_version'); + description = translate('new_code_definition.previous_version.description'); + useCase = translate('new_code_definition.previous_version.usecase'); + } + + return ( + <> + <div className="general-setting display-flex-start"> + <span className="sw-font-bold flex-0">{setting}: </span> + <span> + {description} {useCase} + </span> + </div> + {!isGlobalNcdCompliant && ( + <Alert variant="warning" className="sw-mt-4 sw-max-w-[800px]"> + <p className="sw-mb-2 sw-font-bold"> + {translate('new_code_definition.compliance.warning.title.global')} + </p> + <p className="sw-mb-2"> + {canAdmin ? ( + <FormattedMessage + id="new_code_definition.compliance.warning.explanation.admin" + defaultMessage={translate( + 'new_code_definition.compliance.warning.explanation.admin' + )} + values={{ + link: ( + <Link to="/admin/settings?category=new_code_period"> + {translate( + 'new_code_definition.compliance.warning.explanation.action.admin.link' + )} + </Link> + ), + }} + /> + ) : ( + translate('new_code_definition.compliance.warning.explanation') + )} + </p> + </Alert> + )} + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingDays.tsx b/server/sonar-web/src/main/js/components/new-code-definition/NewCodeDefinitionDaysOption.tsx index b92f1de972a..e6482306e27 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingDays.tsx +++ b/server/sonar-web/src/main/js/components/new-code-definition/NewCodeDefinitionDaysOption.tsx @@ -18,14 +18,12 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import RadioCard from '../../../components/controls/RadioCard'; -import ValidationInput, { - ValidationInputErrorPlacement, -} from '../../../components/controls/ValidationInput'; -import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { MAX_NUMBER_OF_DAYS, MIN_NUMBER_OF_DAYS } from '../../../helpers/periods'; -import { NewCodePeriodSettingType } from '../../../types/types'; +import { translate, translateWithParameters } from '../../helpers/l10n'; +import { MAX_NUMBER_OF_DAYS, MIN_NUMBER_OF_DAYS } from '../../helpers/periods'; +import { NewCodePeriodSettingType } from '../../types/types'; +import RadioCard from '../controls/RadioCard'; +import ValidationInput, { ValidationInputErrorPlacement } from '../controls/ValidationInput'; +import MandatoryFieldsExplanation from '../ui/MandatoryFieldsExplanation'; export interface Props { className?: string; @@ -38,7 +36,7 @@ export interface Props { selected: boolean; } -export default function BaselineSettingDays(props: Props) { +export default function NewCodeDefinitionDaysOption(props: Props) { const { className, days, disabled, isChanged, isValid, onChangeDays, onSelect, selected } = props; return ( @@ -47,12 +45,12 @@ export default function BaselineSettingDays(props: Props) { disabled={disabled} onClick={() => onSelect(NewCodePeriodSettingType.NUMBER_OF_DAYS)} selected={selected} - title={translate('baseline.number_days')} + title={translate('new_code_definition.number_days')} > <> <div> - <p className="sw-mb-3">{translate('baseline.number_days.description')}</p> - <p className="sw-mb-4">{translate('baseline.number_days.usecase')}</p> + <p className="sw-mb-3">{translate('new_code_definition.number_days.description')}</p> + <p className="sw-mb-4">{translate('new_code_definition.number_days.usecase')}</p> </div> {selected && ( <> @@ -64,11 +62,11 @@ export default function BaselineSettingDays(props: Props) { isValid={isChanged && isValid} errorPlacement={ValidationInputErrorPlacement.Bottom} error={translateWithParameters( - 'baseline.number_days.invalid', + 'new_code_definition.number_days.invalid', MIN_NUMBER_OF_DAYS, MAX_NUMBER_OF_DAYS )} - label={translate('baseline.specify_days')} + label={translate('new_code_definition.number_days.specify_days')} required > <input diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingPreviousVersion.tsx b/server/sonar-web/src/main/js/components/new-code-definition/NewCodeDefinitionPreviousVersionOption.tsx index f2e2656ff9c..263dbc0429e 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingPreviousVersion.tsx +++ b/server/sonar-web/src/main/js/components/new-code-definition/NewCodeDefinitionPreviousVersionOption.tsx @@ -18,31 +18,36 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import RadioCard from '../../../components/controls/RadioCard'; -import { translate } from '../../../helpers/l10n'; -import { NewCodePeriodSettingType } from '../../../types/types'; +import { translate } from '../../helpers/l10n'; +import { NewCodePeriodSettingType } from '../../types/types'; +import RadioCard from '../controls/RadioCard'; -export interface Props { +interface Props { disabled?: boolean; isDefault?: boolean; onSelect: (selection: NewCodePeriodSettingType) => void; selected: boolean; } -export default function BaselineSettingPreviousVersion(props: Props) { - const { disabled, isDefault, onSelect, selected } = props; +export default function NewCodeDefinitionPreviousVersionOption({ + disabled, + isDefault, + onSelect, + selected, +}: Props) { return ( <RadioCard disabled={disabled} onClick={() => onSelect(NewCodePeriodSettingType.PREVIOUS_VERSION)} selected={selected} title={ - translate('baseline.previous_version') + (isDefault ? ` (${translate('default')})` : '') + translate('new_code_definition.previous_version') + + (isDefault ? ` (${translate('default')})` : '') } > <div> - <p>{translate('baseline.previous_version.description')}</p> - <p className="sw-mt-3">{translate('baseline.previous_version.usecase')}</p> + <p>{translate('new_code_definition.previous_version.description')}</p> + <p className="sw-mt-3">{translate('new_code_definition.previous_version.usecase')}</p> </div> </RadioCard> ); diff --git a/server/sonar-web/src/main/js/components/new-code-definition/NewCodeDefinitionSelector.tsx b/server/sonar-web/src/main/js/components/new-code-definition/NewCodeDefinitionSelector.tsx new file mode 100644 index 00000000000..a6d8887f6c0 --- /dev/null +++ b/server/sonar-web/src/main/js/components/new-code-definition/NewCodeDefinitionSelector.tsx @@ -0,0 +1,186 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { noop } from 'lodash'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { getNewCodePeriod } from '../../api/newCodePeriod'; +import { translate } from '../../helpers/l10n'; +import { isNewCodeDefinitionCompliant } from '../../helpers/periods'; +import { + NewCodePeriod, + NewCodePeriodSettingType, + NewCodePeriodWithCompliance, +} from '../../types/types'; +import Radio from '../controls/Radio'; +import RadioCard from '../controls/RadioCard'; +import Tooltip from '../controls/Tooltip'; +import { Alert } from '../ui/Alert'; +import GlobalNewCodeDefinitionDescription from './GlobalNewCodeDefinitionDescription'; +import NewCodeDefinitionDaysOption from './NewCodeDefinitionDaysOption'; +import NewCodeDefinitionPreviousVersionOption from './NewCodeDefinitionPreviousVersionOption'; + +interface Props { + canAdmin: boolean | undefined; + onNcdChanged: (ncd: NewCodePeriodWithCompliance) => void; +} + +const INITIAL_DAYS = '30'; + +export default function NewCodeDefinitionSelector(props: Props) { + const { canAdmin, onNcdChanged } = props; + + const [globalNcd, setGlobalNcd] = React.useState<NewCodePeriod | null>(null); + const [selectedNcdType, setSelectedNcdType] = React.useState<NewCodePeriodSettingType | null>( + null + ); + const [days, setDays] = React.useState<string>(INITIAL_DAYS); + + const iGlobalNcdCompliant = React.useMemo( + () => Boolean(globalNcd && isNewCodeDefinitionCompliant(globalNcd)), + [globalNcd] + ); + + const isChanged = React.useMemo( + () => selectedNcdType === NewCodePeriodSettingType.NUMBER_OF_DAYS && days !== INITIAL_DAYS, + [selectedNcdType, days] + ); + + const isCompliant = React.useMemo( + () => + !!selectedNcdType && + isNewCodeDefinitionCompliant({ + type: selectedNcdType, + value: days, + }), + [selectedNcdType, days] + ); + + useEffect(() => { + function fetchGlobalNcd() { + getNewCodePeriod().then(setGlobalNcd, noop); + } + + fetchGlobalNcd(); + }, []); + + useEffect(() => { + if (selectedNcdType) { + const type = + selectedNcdType === NewCodePeriodSettingType.INHERITED ? undefined : selectedNcdType; + const value = selectedNcdType === NewCodePeriodSettingType.NUMBER_OF_DAYS ? days : undefined; + onNcdChanged({ isCompliant, type, value }); + } + }, [selectedNcdType, days, isCompliant, onNcdChanged]); + + return ( + <> + <p className="sw-mt-10"> + <strong>{translate('new_code_definition.question')}</strong> + </p> + <div className="big-spacer-top spacer-bottom" role="radiogroup"> + <Radio + ariaLabel={translate('new_code_definition.global_setting')} + checked={selectedNcdType === NewCodePeriodSettingType.INHERITED} + className="big-spacer-bottom" + disabled={!iGlobalNcdCompliant} + onCheck={() => setSelectedNcdType(NewCodePeriodSettingType.INHERITED)} + value="general" + > + <Tooltip + overlay={ + iGlobalNcdCompliant + ? null + : translate('new_code_definition.compliance.warning.title.global') + } + > + <span>{translate('new_code_definition.global_setting')}</span> + </Tooltip> + </Radio> + + <div className="sw-ml-4"> + {globalNcd && ( + <GlobalNewCodeDefinitionDescription + globalNcd={globalNcd} + isGlobalNcdCompliant={iGlobalNcdCompliant} + canAdmin={canAdmin} + /> + )} + </div> + + <Radio + ariaLabel={translate('new_code_definition.specific_setting')} + checked={Boolean( + selectedNcdType && selectedNcdType !== NewCodePeriodSettingType.INHERITED + )} + className="huge-spacer-top" + onCheck={() => setSelectedNcdType(NewCodePeriodSettingType.PREVIOUS_VERSION)} + value="specific" + > + {translate('new_code_definition.specific_setting')} + </Radio> + </div> + + <div className="big-spacer-left big-spacer-right project-baseline-setting"> + <div className="display-flex-row big-spacer-bottom" role="radiogroup"> + <NewCodeDefinitionPreviousVersionOption + disabled={Boolean( + !selectedNcdType || selectedNcdType === NewCodePeriodSettingType.INHERITED + )} + onSelect={setSelectedNcdType} + selected={selectedNcdType === NewCodePeriodSettingType.PREVIOUS_VERSION} + /> + + <NewCodeDefinitionDaysOption + days={days} + disabled={Boolean( + !selectedNcdType || selectedNcdType === NewCodePeriodSettingType.INHERITED + )} + isChanged={isChanged} + isValid={isCompliant} + onChangeDays={setDays} + onSelect={setSelectedNcdType} + selected={selectedNcdType === NewCodePeriodSettingType.NUMBER_OF_DAYS} + /> + + <RadioCard + disabled={Boolean( + !selectedNcdType || selectedNcdType === NewCodePeriodSettingType.INHERITED + )} + onClick={() => setSelectedNcdType(NewCodePeriodSettingType.REFERENCE_BRANCH)} + selected={selectedNcdType === NewCodePeriodSettingType.REFERENCE_BRANCH} + title={translate('new_code_definition.reference_branch')} + > + <div> + <p className="sw-mb-3"> + {translate('new_code_definition.reference_branch.description')} + </p> + <p className="sw-mb-4">{translate('new_code_definition.reference_branch.usecase')}</p> + {selectedNcdType === NewCodePeriodSettingType.REFERENCE_BRANCH && ( + <Alert variant="info"> + {translate('new_code_definition.reference_branch.notice')} + </Alert> + )} + </div> + </RadioCard> + </div> + </div> + </> + ); +} diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts index cd5fcc34769..f31661e0c7f 100644 --- a/server/sonar-web/src/main/js/types/types.ts +++ b/server/sonar-web/src/main/js/types/types.ts @@ -404,6 +404,12 @@ export interface NewCodePeriod { inherited?: boolean; } +export interface NewCodePeriodWithCompliance { + type?: NewCodePeriodSettingType; + value?: string; + isCompliant: boolean; +} + export interface NewCodePeriodBranch extends NewCodePeriod { projectKey: string; branchKey: string; @@ -414,6 +420,7 @@ export enum NewCodePeriodSettingType { NUMBER_OF_DAYS = 'NUMBER_OF_DAYS', SPECIFIC_ANALYSIS = 'SPECIFIC_ANALYSIS', REFERENCE_BRANCH = 'REFERENCE_BRANCH', + INHERITED = 'INHERITED', } export interface Paging { @@ -617,6 +624,7 @@ export interface Snippet { export interface SnippetGroup extends SnippetsByComponent { locations: FlowLocation[]; } + export interface SnippetsByComponent { component: SourceViewerFile; sources: { [line: number]: SourceLine }; |