diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2021-03-02 09:41:21 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-03-03 20:12:51 +0000 |
commit | 926932fc656afbb49f1cd2c26363cb120a4d7f39 (patch) | |
tree | 170f22aeb0adcbb369002ba9cb1784fa9f131102 | |
parent | efe113c7b07e995075b318099d9968c290771fe6 (diff) | |
download | sonarqube-926932fc656afbb49f1cd2c26363cb120a4d7f39.tar.gz sonarqube-926932fc656afbb49f1cd2c26363cb120a4d7f39.zip |
Refactor Quality Profiles page
- Move all API interactions to the parent component
- Merge copy, extend, and rename forms into a single component
- Simplify delete form
18 files changed, 1158 insertions, 835 deletions
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/CopyProfileForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/CopyProfileForm.tsx deleted file mode 100644 index b375c9af3f6..00000000000 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/CopyProfileForm.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 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 { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; -import Modal from 'sonar-ui-common/components/controls/Modal'; -import MandatoryFieldMarker from 'sonar-ui-common/components/ui/MandatoryFieldMarker'; -import MandatoryFieldsExplanation from 'sonar-ui-common/components/ui/MandatoryFieldsExplanation'; -import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; -import { copyProfile } from '../../../api/quality-profiles'; -import { Profile } from '../types'; - -interface Props { - onClose: () => void; - onCopy: (name: string) => void; - profile: Profile; -} - -interface State { - loading: boolean; - name: string | null; -} - -export default class CopyProfileForm extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false, name: null }; - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => { - this.setState({ name: event.currentTarget.value }); - }; - - handleFormSubmit = (event: React.SyntheticEvent<HTMLElement>) => { - event.preventDefault(); - - const { name } = this.state; - - if (name != null) { - this.setState({ loading: true }); - copyProfile(this.props.profile.key, name).then( - (profile: any) => this.props.onCopy(profile.name), - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - } - ); - } - }; - - render() { - const { profile } = this.props; - const header = translateWithParameters( - 'quality_profiles.copy_x_title', - profile.name, - profile.languageName - ); - const submitDisabled = - this.state.loading || !this.state.name || this.state.name === profile.name; - - return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose} size="small"> - <form id="copy-profile-form" onSubmit={this.handleFormSubmit}> - <div className="modal-head"> - <h2>{header}</h2> - </div> - <div className="modal-body"> - <MandatoryFieldsExplanation className="modal-field" /> - <div className="modal-field"> - <label htmlFor="copy-profile-name"> - {translate('quality_profiles.copy_new_name')} - <MandatoryFieldMarker /> - </label> - <input - autoFocus={true} - id="copy-profile-name" - maxLength={100} - name="name" - onChange={this.handleNameChange} - required={true} - size={50} - type="text" - value={this.state.name != null ? this.state.name : profile.name} - /> - </div> - </div> - <div className="modal-foot"> - {this.state.loading && <i className="spinner spacer-right" />} - <SubmitButton disabled={submitDisabled} id="copy-profile-submit"> - {translate('copy')} - </SubmitButton> - <ResetButtonLink id="copy-profile-cancel" onClick={this.props.onClose}> - {translate('cancel')} - </ResetButtonLink> - </div> - </form> - </Modal> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/DeleteProfileForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/DeleteProfileForm.tsx index 291d325733a..0e5b61310d9 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/DeleteProfileForm.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/DeleteProfileForm.tsx @@ -22,90 +22,61 @@ import { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/contro import Modal from 'sonar-ui-common/components/controls/Modal'; import { Alert } from 'sonar-ui-common/components/ui/Alert'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; -import { deleteProfile } from '../../../api/quality-profiles'; import { Profile } from '../types'; -interface Props { +export interface DeleteProfileFormProps { + loading: boolean; onClose: () => void; onDelete: () => void; profile: Profile; } -interface State { - loading: boolean; -} - -export default class DeleteProfileForm extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false }; - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { - event.preventDefault(); - this.setState({ loading: true }); - deleteProfile(this.props.profile).then(this.props.onDelete, () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }); - }; - - render() { - const { profile } = this.props; - const header = translate('quality_profiles.delete_confirm_title'); +export default function DeleteProfileForm(props: DeleteProfileFormProps) { + const { loading, profile } = props; + const header = translate('quality_profiles.delete_confirm_title'); - return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose}> - <form id="delete-profile-form" onSubmit={this.handleFormSubmit}> - <div className="modal-head"> - <h2>{header}</h2> - </div> - <div className="modal-body"> - <div className="js-modal-messages" /> - {profile.childrenCount > 0 ? ( - <div> - <Alert variant="warning"> - {translate('quality_profiles.this_profile_has_descendants')} - </Alert> - <p> - {translateWithParameters( - 'quality_profiles.are_you_sure_want_delete_profile_x_and_descendants', - profile.name, - profile.languageName - )} - </p> - </div> - ) : ( + return ( + <Modal contentLabel={header} onRequestClose={props.onClose}> + <form + onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => { + e.preventDefault(); + props.onDelete(); + }}> + <div className="modal-head"> + <h2>{header}</h2> + </div> + <div className="modal-body"> + {profile.childrenCount > 0 ? ( + <div> + <Alert variant="warning"> + {translate('quality_profiles.this_profile_has_descendants')} + </Alert> <p> {translateWithParameters( - 'quality_profiles.are_you_sure_want_delete_profile_x', + 'quality_profiles.are_you_sure_want_delete_profile_x_and_descendants', profile.name, profile.languageName )} </p> - )} - </div> - <div className="modal-foot"> - {this.state.loading && <i className="spinner spacer-right" />} - <SubmitButton - className="button-red" - disabled={this.state.loading} - id="delete-profile-submit"> - {translate('delete')} - </SubmitButton> - <ResetButtonLink id="delete-profile-cancel" onClick={this.props.onClose}> - {translate('cancel')} - </ResetButtonLink> - </div> - </form> - </Modal> - ); - } + </div> + ) : ( + <p> + {translateWithParameters( + 'quality_profiles.are_you_sure_want_delete_profile_x', + profile.name, + profile.languageName + )} + </p> + )} + </div> + <div className="modal-foot"> + {loading && <i className="spinner spacer-right" />} + <SubmitButton className="button-red" disabled={loading}> + {translate('delete')} + </SubmitButton> + <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink> + </div> + </form> + </Modal> + ); } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx deleted file mode 100644 index 2317d7edeb1..00000000000 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/ExtendProfileForm.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 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 { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; -import Modal from 'sonar-ui-common/components/controls/Modal'; -import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; -import MandatoryFieldMarker from 'sonar-ui-common/components/ui/MandatoryFieldMarker'; -import MandatoryFieldsExplanation from 'sonar-ui-common/components/ui/MandatoryFieldsExplanation'; -import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; -import { changeProfileParent, createQualityProfile } from '../../../api/quality-profiles'; -import { Profile } from '../types'; - -interface Props { - onClose: () => void; - onExtend: (name: string) => void; - profile: Profile; -} - -interface State { - loading: boolean; - name?: string; -} - -type ValidState = State & Required<Pick<State, 'name'>>; - -export default class ExtendProfileForm extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false }; - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - canSubmit = (state: State): state is ValidState => { - return Boolean(state.name && state.name.length); - }; - - handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => { - this.setState({ name: event.currentTarget.value }); - }; - - handleFormSubmit = async (event: React.SyntheticEvent<HTMLFormElement>) => { - event.preventDefault(); - if (this.canSubmit(this.state)) { - const { profile: parentProfile } = this.props; - const { name } = this.state; - - const data = new FormData(); - - data.append('language', parentProfile.language); - data.append('name', name); - - this.setState({ loading: true }); - - try { - const { profile: newProfile } = await createQualityProfile(data); - await changeProfileParent(newProfile, parentProfile); - this.props.onExtend(newProfile.name); - } finally { - if (this.mounted) { - this.setState({ loading: false }); - } - } - } - }; - - render() { - const { profile } = this.props; - const header = translateWithParameters( - 'quality_profiles.extend_x_title', - profile.name, - profile.languageName - ); - - return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose} size="small"> - <form onSubmit={this.handleFormSubmit}> - <div className="modal-head"> - <h2>{header}</h2> - </div> - <div className="modal-body"> - <MandatoryFieldsExplanation className="modal-field" /> - <div className="modal-field"> - <label htmlFor="extend-profile-name"> - {translate('quality_profiles.copy_new_name')} - <MandatoryFieldMarker /> - </label> - <input - autoFocus={true} - id="extend-profile-name" - maxLength={100} - name="name" - onChange={this.handleNameChange} - required={true} - size={50} - type="text" - value={this.state.name ? this.state.name : ''} - /> - </div> - </div> - <div className="modal-foot"> - <DeferredSpinner className="spacer-right" loading={this.state.loading} /> - <SubmitButton - disabled={this.state.loading || !this.canSubmit(this.state)} - id="extend-profile-submit"> - {translate('extend')} - </SubmitButton> - <ResetButtonLink id="extend-profile-cancel" onClick={this.props.onClose}> - {translate('cancel')} - </ResetButtonLink> - </div> - </form> - </Modal> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx index 9ce4622be11..c8ac23413f6 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx @@ -23,16 +23,22 @@ import ActionsDropdown, { ActionsDropdownItem } from 'sonar-ui-common/components/controls/ActionsDropdown'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import { getQualityProfileBackupUrl, setDefaultProfile } from '../../../api/quality-profiles'; +import { + changeProfileParent, + copyProfile, + createQualityProfile, + deleteProfile, + getQualityProfileBackupUrl, + renameProfile, + setDefaultProfile +} from '../../../api/quality-profiles'; import { Router, withRouter } from '../../../components/hoc/withRouter'; import { getBaseUrl } from '../../../helpers/system'; import { getRulesUrl } from '../../../helpers/urls'; -import { Profile } from '../types'; +import { Profile, ProfileActionModals } from '../types'; import { getProfileComparePath, getProfilePath, PROFILE_PATH } from '../utils'; -import CopyProfileForm from './CopyProfileForm'; import DeleteProfileForm from './DeleteProfileForm'; -import ExtendProfileForm from './ExtendProfileForm'; -import RenameProfileForm from './RenameProfileForm'; +import ProfileModalForm from './ProfileModalForm'; interface Props { className?: string; @@ -43,94 +49,129 @@ interface Props { } interface State { - copyFormOpen: boolean; - extendFormOpen: boolean; - deleteFormOpen: boolean; - renameFormOpen: boolean; + loading: boolean; + openModal?: ProfileActionModals; } export class ProfileActions extends React.PureComponent<Props, State> { + mounted = false; state: State = { - copyFormOpen: false, - extendFormOpen: false, - deleteFormOpen: false, - renameFormOpen: false + loading: false }; - closeCopyForm = () => { - this.setState({ copyFormOpen: false }); - }; + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } - closeDeleteForm = () => { - this.setState({ deleteFormOpen: false }); + handleCloseModal = () => { + this.setState({ openModal: undefined }); }; - closeExtendForm = () => { - this.setState({ extendFormOpen: false }); + handleCopyClick = () => { + this.setState({ openModal: ProfileActionModals.Copy }); }; - closeRenameForm = () => { - this.setState({ renameFormOpen: false }); + handleExtendClick = () => { + this.setState({ openModal: ProfileActionModals.Extend }); }; - handleCopyClick = () => { - this.setState({ copyFormOpen: true }); + handleRenameClick = () => { + this.setState({ openModal: ProfileActionModals.Rename }); }; handleDeleteClick = () => { - this.setState({ deleteFormOpen: true }); + this.setState({ openModal: ProfileActionModals.Delete }); }; - handleExtendClick = () => { - this.setState({ extendFormOpen: true }); - }; + handleProfileCopy = async (name: string) => { + this.setState({ loading: true }); - handleRenameClick = () => { - this.setState({ renameFormOpen: true }); + try { + await copyProfile(this.props.profile.key, name); + this.profileActionPerformed(name); + } catch { + this.profileActionError(); + } }; - handleProfileCopy = (name: string) => { - this.closeCopyForm(); - this.navigateToNewProfile(name); - }; + handleProfileExtend = async (name: string) => { + const { profile: parentProfile } = this.props; + + const data = { + language: parentProfile.language, + name + }; + + this.setState({ loading: true }); - handleProfileDelete = () => { - this.props.router.replace(PROFILE_PATH); - this.props.updateProfiles(); + try { + const { profile: newProfile } = await createQualityProfile(data); + await changeProfileParent(newProfile, parentProfile); + this.profileActionPerformed(name); + } catch { + this.profileActionError(); + } }; - handleProfileExtend = (name: string) => { - this.closeExtendForm(); - this.navigateToNewProfile(name); + handleProfileRename = async (name: string) => { + this.setState({ loading: true }); + + try { + await renameProfile(this.props.profile.key, name); + this.profileActionPerformed(name); + } catch { + this.profileActionError(); + } }; - handleProfileRename = (name: string) => { - this.closeRenameForm(); - this.props.updateProfiles().then( - () => { - if (!this.props.fromList) { - this.props.router.replace(getProfilePath(name, this.props.profile.language)); - } - }, - () => {} - ); + handleProfileDelete = async () => { + this.setState({ loading: true }); + + try { + await deleteProfile(this.props.profile); + + if (this.mounted) { + this.setState({ loading: false, openModal: undefined }); + this.props.router.replace(PROFILE_PATH); + this.props.updateProfiles(); + } + } catch { + this.profileActionError(); + } }; handleSetDefaultClick = () => { setDefaultProfile(this.props.profile).then(this.props.updateProfiles, () => {}); }; - navigateToNewProfile = (name: string) => { - this.props.updateProfiles().then( - () => { - this.props.router.push(getProfilePath(name, this.props.profile.language)); - }, - () => {} - ); + profileActionPerformed = (name: string) => { + const { profile, router } = this.props; + if (this.mounted) { + this.setState({ loading: false, openModal: undefined }); + this.props.updateProfiles().then( + () => { + router.push(getProfilePath(name, profile.language)); + }, + () => { + /* noop */ + } + ); + } + }; + + profileActionError = () => { + if (this.mounted) { + this.setState({ loading: false }); + } }; render() { const { profile } = this.props; + const { loading, openModal } = this.state; const { actions = {} } = profile; const backupUrl = `${getBaseUrl()}${getQualityProfileBackupUrl(profile)}`; @@ -144,86 +185,110 @@ export class ProfileActions extends React.PureComponent<Props, State> { <> <ActionsDropdown className={this.props.className}> {actions.edit && ( - <ActionsDropdownItem to={activateMoreUrl}> - <span data-test="quality-profiles__activate-more-rules"> - {translate('quality_profiles.activate_more_rules')} - </span> + <ActionsDropdownItem + className="it__quality-profiles__activate-more-rules" + to={activateMoreUrl}> + {translate('quality_profiles.activate_more_rules')} </ActionsDropdownItem> )} {!profile.isBuiltIn && ( - <ActionsDropdownItem download={`${profile.key}.xml`} to={backupUrl}> - <span data-test="quality-profiles__backup">{translate('backup_verb')}</span> + <ActionsDropdownItem + className="it__quality-profiles__backup" + download={`${profile.key}.xml`} + to={backupUrl}> + {translate('backup_verb')} </ActionsDropdownItem> )} - <ActionsDropdownItem to={getProfileComparePath(profile.name, profile.language)}> - <span data-test="quality-profiles__compare">{translate('compare')}</span> + <ActionsDropdownItem + className="it__quality-profiles__compare" + to={getProfileComparePath(profile.name, profile.language)}> + {translate('compare')} </ActionsDropdownItem> {actions.copy && ( <> - <ActionsDropdownItem onClick={this.handleCopyClick}> - <span data-test="quality-profiles__copy">{translate('copy')}</span> + <ActionsDropdownItem + className="it__quality-profiles__copy" + onClick={this.handleCopyClick}> + {translate('copy')} </ActionsDropdownItem> - <ActionsDropdownItem onClick={this.handleExtendClick}> - <span data-test="quality-profiles__extend">{translate('extend')}</span> + <ActionsDropdownItem + className="it__quality-profiles__extend" + onClick={this.handleExtendClick}> + {translate('extend')} </ActionsDropdownItem> </> )} {actions.edit && ( - <ActionsDropdownItem onClick={this.handleRenameClick}> - <span data-test="quality-profiles__rename">{translate('rename')}</span> + <ActionsDropdownItem + className="it__quality-profiles__rename" + onClick={this.handleRenameClick}> + {translate('rename')} </ActionsDropdownItem> )} {actions.setAsDefault && ( - <ActionsDropdownItem onClick={this.handleSetDefaultClick}> - <span data-test="quality-profiles__set-as-default"> - {translate('set_as_default')} - </span> + <ActionsDropdownItem + className="it__quality-profiles__set-as-default" + onClick={this.handleSetDefaultClick}> + {translate('set_as_default')} </ActionsDropdownItem> )} {actions.delete && <ActionsDropdownDivider />} {actions.delete && ( - <ActionsDropdownItem destructive={true} onClick={this.handleDeleteClick}> - <span data-test="quality-profiles__delete">{translate('delete')}</span> + <ActionsDropdownItem + className="it__quality-profiles__delete" + destructive={true} + onClick={this.handleDeleteClick}> + {translate('delete')} </ActionsDropdownItem> )} </ActionsDropdown> - {this.state.copyFormOpen && ( - <CopyProfileForm - onClose={this.closeCopyForm} - onCopy={this.handleProfileCopy} + {openModal === ProfileActionModals.Copy && ( + <ProfileModalForm + btnLabelKey="copy" + headerKey="quality_profiles.copy_x_title" + loading={loading} + onClose={this.handleCloseModal} + onSubmit={this.handleProfileCopy} profile={profile} /> )} - {this.state.extendFormOpen && ( - <ExtendProfileForm - onClose={this.closeExtendForm} - onExtend={this.handleProfileExtend} + {openModal === ProfileActionModals.Extend && ( + <ProfileModalForm + btnLabelKey="extend" + headerKey="quality_profiles.extend_x_title" + loading={loading} + onClose={this.handleCloseModal} + onSubmit={this.handleProfileExtend} profile={profile} /> )} - {this.state.deleteFormOpen && ( - <DeleteProfileForm - onClose={this.closeDeleteForm} - onDelete={this.handleProfileDelete} + {openModal === ProfileActionModals.Rename && ( + <ProfileModalForm + btnLabelKey="rename" + headerKey="quality_profiles.rename_x_title" + loading={loading} + onClose={this.handleCloseModal} + onSubmit={this.handleProfileRename} profile={profile} /> )} - {this.state.renameFormOpen && ( - <RenameProfileForm - onClose={this.closeRenameForm} - onRename={this.handleProfileRename} + {openModal === ProfileActionModals.Delete && ( + <DeleteProfileForm + loading={loading} + onClose={this.handleCloseModal} + onDelete={this.handleProfileDelete} profile={profile} /> )} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileModalForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileModalForm.tsx new file mode 100644 index 00000000000..d532c30599d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileModalForm.tsx @@ -0,0 +1,86 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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 { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; +import Modal from 'sonar-ui-common/components/controls/Modal'; +import MandatoryFieldMarker from 'sonar-ui-common/components/ui/MandatoryFieldMarker'; +import MandatoryFieldsExplanation from 'sonar-ui-common/components/ui/MandatoryFieldsExplanation'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import { Profile } from '../types'; + +export interface ProfileModalFormProps { + btnLabelKey: string; + headerKey: string; + loading: boolean; + onClose: () => void; + onSubmit: (name: string) => void; + profile: Profile; +} + +export default function ProfileModalForm(props: ProfileModalFormProps) { + const { btnLabelKey, headerKey, loading, profile } = props; + const [name, setName] = React.useState<string | undefined>(undefined); + + const submitDisabled = loading || !name || name === profile.name; + const header = translateWithParameters(headerKey, profile.name, profile.languageName); + + return ( + <Modal contentLabel={header} onRequestClose={props.onClose} size="small"> + <form + onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => { + e.preventDefault(); + if (name) { + props.onSubmit(name); + } + }}> + <div className="modal-head"> + <h2>{header}</h2> + </div> + <div className="modal-body"> + <MandatoryFieldsExplanation className="modal-field" /> + <div className="modal-field"> + <label htmlFor="profile-name"> + {translate('quality_profiles.new_name')} + <MandatoryFieldMarker /> + </label> + <input + autoFocus={true} + id="profile-name" + maxLength={100} + name="name" + onChange={(e: React.SyntheticEvent<HTMLInputElement>) => { + setName(e.currentTarget.value); + }} + required={true} + size={50} + type="text" + value={name ?? profile.name} + /> + </div> + </div> + <div className="modal-foot"> + {loading && <i className="spinner spacer-right" />} + <SubmitButton disabled={submitDisabled}>{translate(btnLabelKey)}</SubmitButton> + <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink> + </div> + </form> + </Modal> + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/RenameProfileForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/RenameProfileForm.tsx deleted file mode 100644 index 43ef7a02c94..00000000000 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/RenameProfileForm.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 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 { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; -import Modal from 'sonar-ui-common/components/controls/Modal'; -import MandatoryFieldMarker from 'sonar-ui-common/components/ui/MandatoryFieldMarker'; -import MandatoryFieldsExplanation from 'sonar-ui-common/components/ui/MandatoryFieldsExplanation'; -import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; -import { renameProfile } from '../../../api/quality-profiles'; -import { Profile } from '../types'; - -interface Props { - onClose: () => void; - onRename: (name: string) => void; - profile: Profile; -} - -interface State { - loading: boolean; - name: string | null; -} - -export default class RenameProfileForm extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false, name: null }; - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => { - this.setState({ name: event.currentTarget.value }); - }; - - handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { - event.preventDefault(); - - const { name } = this.state; - - if (name != null) { - this.setState({ loading: true }); - renameProfile(this.props.profile.key, name).then( - () => this.props.onRename(name), - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - } - ); - } - }; - - render() { - const { profile } = this.props; - const header = translateWithParameters( - 'quality_profiles.rename_x_title', - profile.name, - profile.languageName - ); - const submitDisabled = - this.state.loading || !this.state.name || this.state.name === profile.name; - - return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose} size="small"> - <form id="rename-profile-form" onSubmit={this.handleFormSubmit}> - <div className="modal-head"> - <h2>{header}</h2> - </div> - <div className="modal-body"> - <MandatoryFieldsExplanation className="modal-field" /> - <div className="modal-field"> - <label htmlFor="rename-profile-name"> - {translate('quality_profiles.new_name')} - <MandatoryFieldMarker /> - </label> - <input - autoFocus={true} - id="rename-profile-name" - maxLength={100} - name="name" - onChange={this.handleNameChange} - required={true} - size={50} - type="text" - value={this.state.name != null ? this.state.name : profile.name} - /> - </div> - </div> - <div className="modal-foot"> - {this.state.loading && <i className="spinner spacer-right" />} - <SubmitButton disabled={submitDisabled} id="rename-profile-submit"> - {translate('rename')} - </SubmitButton> - <ResetButtonLink id="rename-profile-cancel" onClick={this.props.onClose}> - {translate('cancel')} - </ResetButtonLink> - </div> - </form> - </Modal> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/DeleteProfileForm-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/DeleteProfileForm-test.tsx index 801c736874e..58cbfe7eb0d 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/DeleteProfileForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/DeleteProfileForm-test.tsx @@ -17,35 +17,35 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { shallow } from 'enzyme'; import * as React from 'react'; -import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { deleteProfile } from '../../../../api/quality-profiles'; import { mockEvent, mockQualityProfile } from '../../../../helpers/testMocks'; -import DeleteProfileForm from '../DeleteProfileForm'; - -beforeEach(() => jest.clearAllMocks()); - -jest.mock('../../../../api/quality-profiles', () => ({ - deleteProfile: jest.fn().mockResolvedValue({}) -})); +import DeleteProfileForm, { DeleteProfileFormProps } from '../DeleteProfileForm'; it('should render correctly', () => { - const wrapper = shallowRender(); - expect(wrapper).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender({ loading: true })).toMatchSnapshot('loading'); + expect(shallowRender({ profile: mockQualityProfile({ childrenCount: 2 }) })).toMatchSnapshot( + 'profile has children' + ); }); -it('should handle form submit correctly', async () => { - const wrapper = shallowRender(); - wrapper.instance().handleFormSubmit(mockEvent()); - await waitAndUpdate(wrapper); +it('should correctly submit the form', () => { + const onDelete = jest.fn(); + const wrapper = shallowRender({ onDelete }); - expect(deleteProfile).toHaveBeenCalled(); + const formOnSubmit = wrapper.find('form').props().onSubmit; + if (formOnSubmit) { + formOnSubmit(mockEvent()); + } + expect(onDelete).toBeCalled(); }); -function shallowRender(props: Partial<DeleteProfileForm['props']> = {}) { - return shallow<DeleteProfileForm>( +function shallowRender(props: Partial<DeleteProfileFormProps> = {}) { + return shallow<DeleteProfileFormProps>( <DeleteProfileForm + loading={false} onClose={jest.fn()} onDelete={jest.fn()} profile={mockQualityProfile()} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ExtendProfileForm-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ExtendProfileForm-test.tsx deleted file mode 100644 index 47e61da61ca..00000000000 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ExtendProfileForm-test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { mockEvent, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { changeProfileParent, createQualityProfile } from '../../../../api/quality-profiles'; -import { mockQualityProfile } from '../../../../helpers/testMocks'; -import ExtendProfileForm from '../ExtendProfileForm'; - -jest.mock('../../../../api/quality-profiles', () => ({ - createQualityProfile: jest.fn().mockResolvedValue({ profile: { key: 'new-profile' } }), - changeProfileParent: jest.fn().mockResolvedValue(true) -})); - -it('should render correctly', () => { - const wrapper = shallowRender(); - expect(wrapper).toMatchSnapshot(); -}); - -it('should correctly create a new profile and extend the existing one', async () => { - const profile = mockQualityProfile(); - const name = 'New name'; - const wrapper = shallowRender({ profile }); - - expect(wrapper.find('SubmitButton').props().disabled).toBe(true); - - wrapper.setState({ name }).update(); - wrapper.instance().handleFormSubmit(mockEvent()); - await waitAndUpdate(wrapper); - - const data = new FormData(); - data.append('language', profile.language); - data.append('name', name); - expect(createQualityProfile).toHaveBeenCalledWith(data); - expect(changeProfileParent).toHaveBeenCalledWith({ key: 'new-profile' }, profile); -}); - -function shallowRender(props: Partial<ExtendProfileForm['props']> = {}) { - return shallow<ExtendProfileForm>( - <ExtendProfileForm - onClose={jest.fn()} - onExtend={jest.fn()} - profile={mockQualityProfile()} - {...props} - /> - ); -} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx index 4c14e43e208..a6ad0fffb57 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx @@ -20,16 +20,36 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { click, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { setDefaultProfile } from '../../../../api/quality-profiles'; +import { + changeProfileParent, + copyProfile, + createQualityProfile, + deleteProfile, + renameProfile, + setDefaultProfile +} from '../../../../api/quality-profiles'; import { mockQualityProfile, mockRouter } from '../../../../helpers/testMocks'; +import { ProfileActionModals } from '../../types'; +import { PROFILE_PATH } from '../../utils'; +import DeleteProfileForm from '../DeleteProfileForm'; import { ProfileActions } from '../ProfileActions'; +import ProfileModalForm from '../ProfileModalForm'; -beforeEach(() => jest.clearAllMocks()); +jest.mock('../../../../api/quality-profiles', () => { + const { mockQualityProfile } = jest.requireActual('../../../../helpers/testMocks'); -jest.mock('../../../../api/quality-profiles', () => ({ - ...jest.requireActual('../../../../api/quality-profiles'), - setDefaultProfile: jest.fn().mockResolvedValue({}) -})); + return { + ...jest.requireActual('../../../../api/quality-profiles'), + copyProfile: jest.fn().mockResolvedValue(null), + changeProfileParent: jest.fn().mockResolvedValue(null), + createQualityProfile: jest + .fn() + .mockResolvedValue({ profile: mockQualityProfile({ key: 'newProfile' }) }), + deleteProfile: jest.fn().mockResolvedValue(null), + setDefaultProfile: jest.fn().mockResolvedValue(null), + renameProfile: jest.fn().mockResolvedValue(null) + }; +}); const PROFILE = mockQualityProfile({ activeRuleCount: 68, @@ -39,15 +59,13 @@ const PROFILE = mockQualityProfile({ rulesUpdatedAt: '2017-06-28T12:58:44+0000' }); -it('renders with no permissions', () => { - expect(shallowRender()).toMatchSnapshot(); -}); - -it('renders with permission to edit only', () => { - expect(shallowRender({ profile: { ...PROFILE, actions: { edit: true } } })).toMatchSnapshot(); -}); +beforeEach(() => jest.clearAllMocks()); -it('renders with all permissions', () => { +it('renders correctly', () => { + expect(shallowRender()).toMatchSnapshot('no permissions'); + expect(shallowRender({ profile: { ...PROFILE, actions: { edit: true } } })).toMatchSnapshot( + 'edit only' + ); expect( shallowRender({ profile: { @@ -61,58 +79,240 @@ it('renders with all permissions', () => { } } }) - ).toMatchSnapshot(); + ).toMatchSnapshot('all permissions'); + + expect(shallowRender().setState({ openModal: ProfileActionModals.Copy })).toMatchSnapshot( + 'copy modal' + ); + expect(shallowRender().setState({ openModal: ProfileActionModals.Extend })).toMatchSnapshot( + 'extend modal' + ); + expect(shallowRender().setState({ openModal: ProfileActionModals.Rename })).toMatchSnapshot( + 'rename modal' + ); + expect(shallowRender().setState({ openModal: ProfileActionModals.Delete })).toMatchSnapshot( + 'delete modal' + ); }); -it('should copy profile', async () => { - const name = 'new-name'; - const updateProfiles = jest.fn(() => Promise.resolve()); - const push = jest.fn(); - const wrapper = shallowRender({ - profile: { ...PROFILE, actions: { copy: true } }, - router: { push, replace: jest.fn() }, - updateProfiles +describe('copy a profile', () => { + it('should correctly copy a profile', async () => { + const name = 'new-name'; + const updateProfiles = jest.fn().mockResolvedValue(null); + const push = jest.fn(); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { copy: true } }, + router: mockRouter({ push }), + updateProfiles + }); + + click(wrapper.find('.it__quality-profiles__copy')); + expect(wrapper.find(ProfileModalForm).exists()).toBe(true); + + wrapper + .find(ProfileModalForm) + .props() + .onSubmit(name); + expect(copyProfile).toBeCalledWith(PROFILE.key, name); + await waitAndUpdate(wrapper); + + expect(updateProfiles).toBeCalled(); + expect(push).toBeCalledWith({ + pathname: '/profiles/show', + query: { language: 'js', name } + }); + expect(wrapper.find(ProfileModalForm).exists()).toBe(false); }); - click(wrapper.find('[data-test="quality-profiles__copy"]').parent()); - expect(wrapper.find('CopyProfileForm').exists()).toBe(true); + it('should correctly keep the modal open in case of an error', async () => { + (copyProfile as jest.Mock).mockRejectedValueOnce(null); - wrapper.find('CopyProfileForm').prop<Function>('onCopy')(name); - expect(updateProfiles).toBeCalled(); - await waitAndUpdate(wrapper); + const name = 'new-name'; + const updateProfiles = jest.fn(); + const push = jest.fn(); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { copy: true } }, + router: mockRouter({ push }), + updateProfiles + }); + wrapper.setState({ openModal: ProfileActionModals.Copy }); + + wrapper.instance().handleProfileCopy(name); + await waitAndUpdate(wrapper); + + expect(updateProfiles).not.toBeCalled(); + await waitAndUpdate(wrapper); - expect(push).toBeCalledWith({ - pathname: '/profiles/show', - query: { language: 'js', name } + expect(push).not.toBeCalled(); + expect(wrapper.state().openModal).toBe(ProfileActionModals.Copy); }); - expect(wrapper.find('CopyProfileForm').exists()).toBe(false); }); -it('should extend profile', async () => { - const name = 'new-name'; - const updateProfiles = jest.fn(() => Promise.resolve()); - const push = jest.fn(); - const wrapper = shallowRender({ - profile: { ...PROFILE, actions: { copy: true } }, - router: { push, replace: jest.fn() }, - updateProfiles +describe('extend a profile', () => { + it('should correctly extend a profile', async () => { + const name = 'new-name'; + const profile = { ...PROFILE, actions: { copy: true } }; + const updateProfiles = jest.fn().mockResolvedValue(null); + const push = jest.fn(); + const wrapper = shallowRender({ + profile, + router: mockRouter({ push }), + updateProfiles + }); + + click(wrapper.find('.it__quality-profiles__extend')); + expect(wrapper.find(ProfileModalForm).exists()).toBe(true); + + wrapper + .find(ProfileModalForm) + .props() + .onSubmit(name); + expect(createQualityProfile).toBeCalledWith({ language: profile.language, name }); + await waitAndUpdate(wrapper); + expect(changeProfileParent).toBeCalledWith( + expect.objectContaining({ + key: 'newProfile' + }), + profile + ); + await waitAndUpdate(wrapper); + + expect(updateProfiles).toBeCalled(); + await waitAndUpdate(wrapper); + + expect(push).toBeCalledWith({ + pathname: '/profiles/show', + query: { language: 'js', name } + }); + expect(wrapper.find(ProfileModalForm).exists()).toBe(false); }); - click(wrapper.find('[data-test="quality-profiles__extend"]').parent()); - expect(wrapper.find('ExtendProfileForm').exists()).toBe(true); + it('should correctly keep the modal open in case of an error', async () => { + (createQualityProfile as jest.Mock).mockRejectedValueOnce(null); - wrapper.find('ExtendProfileForm').prop<Function>('onExtend')(name); - expect(updateProfiles).toBeCalled(); - await waitAndUpdate(wrapper); + const name = 'new-name'; + const updateProfiles = jest.fn(); + const push = jest.fn(); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { copy: true } }, + router: mockRouter({ push }), + updateProfiles + }); + wrapper.setState({ openModal: ProfileActionModals.Extend }); + + wrapper.instance().handleProfileExtend(name); + await waitAndUpdate(wrapper); + + expect(updateProfiles).not.toBeCalled(); + expect(changeProfileParent).not.toBeCalled(); + expect(push).not.toBeCalled(); + expect(wrapper.state().openModal).toBe(ProfileActionModals.Extend); + }); +}); + +describe('rename a profile', () => { + it('should correctly rename a profile', async () => { + const name = 'new-name'; + const updateProfiles = jest.fn().mockResolvedValue(null); + const push = jest.fn(); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { edit: true } }, + router: mockRouter({ push }), + updateProfiles + }); + + click(wrapper.find('.it__quality-profiles__rename')); + expect(wrapper.find(ProfileModalForm).exists()).toBe(true); + + wrapper + .find(ProfileModalForm) + .props() + .onSubmit(name); + expect(renameProfile).toBeCalledWith(PROFILE.key, name); + await waitAndUpdate(wrapper); + + expect(updateProfiles).toBeCalled(); + expect(push).toBeCalledWith({ + pathname: '/profiles/show', + query: { language: 'js', name } + }); + expect(wrapper.find(ProfileModalForm).exists()).toBe(false); + }); + + it('should correctly keep the modal open in case of an error', async () => { + (renameProfile as jest.Mock).mockRejectedValueOnce(null); + + const name = 'new-name'; + const updateProfiles = jest.fn(); + const push = jest.fn(); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { copy: true } }, + router: mockRouter({ push }), + updateProfiles + }); + wrapper.setState({ openModal: ProfileActionModals.Rename }); + + wrapper.instance().handleProfileRename(name); + await waitAndUpdate(wrapper); + + expect(updateProfiles).not.toBeCalled(); + await waitAndUpdate(wrapper); + + expect(push).not.toBeCalled(); + expect(wrapper.state().openModal).toBe(ProfileActionModals.Rename); + }); +}); + +describe('delete a profile', () => { + it('should correctly delete a profile', async () => { + const updateProfiles = jest.fn().mockResolvedValue(null); + const replace = jest.fn(); + const profile = { ...PROFILE, actions: { delete: true } }; + const wrapper = shallowRender({ + profile, + router: mockRouter({ replace }), + updateProfiles + }); + + click(wrapper.find('.it__quality-profiles__delete')); + expect(wrapper.find(DeleteProfileForm).exists()).toBe(true); + + wrapper + .find(DeleteProfileForm) + .props() + .onDelete(); + expect(deleteProfile).toBeCalledWith(profile); + await waitAndUpdate(wrapper); + + expect(updateProfiles).toBeCalled(); + expect(replace).toBeCalledWith(PROFILE_PATH); + expect(wrapper.find(ProfileModalForm).exists()).toBe(false); + }); + + it('should correctly keep the modal open in case of an error', async () => { + (deleteProfile as jest.Mock).mockRejectedValueOnce(null); + + const updateProfiles = jest.fn(); + const replace = jest.fn(); + const wrapper = shallowRender({ + profile: { ...PROFILE, actions: { copy: true } }, + router: mockRouter({ replace }), + updateProfiles + }); + wrapper.setState({ openModal: ProfileActionModals.Delete }); + + wrapper.instance().handleProfileDelete(); + await waitAndUpdate(wrapper); + + expect(updateProfiles).not.toBeCalled(); + await waitAndUpdate(wrapper); - expect(push).toBeCalledWith({ - pathname: '/profiles/show', - query: { language: 'js', name } + expect(replace).not.toBeCalled(); + expect(wrapper.state().openModal).toBe(ProfileActionModals.Delete); }); - expect(wrapper.find('ExtendProfileForm').exists()).toBe(false); }); -it('should delete profile properly', async () => { +it('should correctly set a profile as the default', async () => { const updateProfiles = jest.fn(); const wrapper = shallowRender({ updateProfiles }); diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileModalForm-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileModalForm-test.tsx new file mode 100644 index 00000000000..6029ef64ac1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileModalForm-test.tsx @@ -0,0 +1,70 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { change } from 'sonar-ui-common/helpers/testUtils'; +import { mockEvent, mockQualityProfile } from '../../../../helpers/testMocks'; +import ProfileModalForm, { ProfileModalFormProps } from '../ProfileModalForm'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender({ loading: true })).toMatchSnapshot('loading'); + + const wrapper = shallowRender(); + change(wrapper.find('#profile-name'), 'new name'); + expect(wrapper).toMatchSnapshot('can submit'); +}); + +it('should correctly submit the form', () => { + const onSubmit = jest.fn(); + const wrapper = shallowRender({ onSubmit }); + + // Won't submit unless a new name was given. + let formOnSubmit = wrapper.find('form').props().onSubmit; + if (formOnSubmit) { + formOnSubmit(mockEvent()); + } + expect(onSubmit).not.toBeCalled(); + + // Input a new name. + change(wrapper.find('#profile-name'), 'new name'); + + // Now will submit the form. + formOnSubmit = wrapper.find('form').props().onSubmit; + if (formOnSubmit) { + formOnSubmit(mockEvent()); + } + expect(onSubmit).toBeCalledWith('new name'); +}); + +function shallowRender(props: Partial<ProfileModalFormProps> = {}) { + return shallow<ProfileModalFormProps>( + <ProfileModalForm + btnLabelKey="btn-label" + headerKey="header-label" + loading={false} + onClose={jest.fn()} + onSubmit={jest.fn()} + profile={mockQualityProfile()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/DeleteProfileForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/DeleteProfileForm-test.tsx.snap index 41855ab772c..d24c01159c2 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/DeleteProfileForm-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/DeleteProfileForm-test.tsx.snap @@ -1,12 +1,52 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render correctly 1`] = ` +exports[`should render correctly: default 1`] = ` +<Modal + contentLabel="quality_profiles.delete_confirm_title" + onRequestClose={[MockFunction]} +> + <form + onSubmit={[Function]} + > + <div + className="modal-head" + > + <h2> + quality_profiles.delete_confirm_title + </h2> + </div> + <div + className="modal-body" + > + <p> + quality_profiles.are_you_sure_want_delete_profile_x.name.JavaScript + </p> + </div> + <div + className="modal-foot" + > + <SubmitButton + className="button-red" + disabled={false} + > + delete + </SubmitButton> + <ResetButtonLink + onClick={[MockFunction]} + > + cancel + </ResetButtonLink> + </div> + </form> +</Modal> +`; + +exports[`should render correctly: loading 1`] = ` <Modal contentLabel="quality_profiles.delete_confirm_title" onRequestClose={[MockFunction]} > <form - id="delete-profile-form" onSubmit={[Function]} > <div @@ -19,9 +59,6 @@ exports[`should render correctly 1`] = ` <div className="modal-body" > - <div - className="js-modal-messages" - /> <p> quality_profiles.are_you_sure_want_delete_profile_x.name.JavaScript </p> @@ -29,15 +66,64 @@ exports[`should render correctly 1`] = ` <div className="modal-foot" > + <i + className="spinner spacer-right" + /> + <SubmitButton + className="button-red" + disabled={true} + > + delete + </SubmitButton> + <ResetButtonLink + onClick={[MockFunction]} + > + cancel + </ResetButtonLink> + </div> + </form> +</Modal> +`; + +exports[`should render correctly: profile has children 1`] = ` +<Modal + contentLabel="quality_profiles.delete_confirm_title" + onRequestClose={[MockFunction]} +> + <form + onSubmit={[Function]} + > + <div + className="modal-head" + > + <h2> + quality_profiles.delete_confirm_title + </h2> + </div> + <div + className="modal-body" + > + <div> + <Alert + variant="warning" + > + quality_profiles.this_profile_has_descendants + </Alert> + <p> + quality_profiles.are_you_sure_want_delete_profile_x_and_descendants.name.JavaScript + </p> + </div> + </div> + <div + className="modal-foot" + > <SubmitButton className="button-red" disabled={false} - id="delete-profile-submit" > delete </SubmitButton> <ResetButtonLink - id="delete-profile-cancel" onClick={[MockFunction]} > cancel diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap deleted file mode 100644 index 227de4584d0..00000000000 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ExtendProfileForm-test.tsx.snap +++ /dev/null @@ -1,69 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` -<Modal - contentLabel="quality_profiles.extend_x_title.name.JavaScript" - onRequestClose={[MockFunction]} - size="small" -> - <form - onSubmit={[Function]} - > - <div - className="modal-head" - > - <h2> - quality_profiles.extend_x_title.name.JavaScript - </h2> - </div> - <div - className="modal-body" - > - <MandatoryFieldsExplanation - className="modal-field" - /> - <div - className="modal-field" - > - <label - htmlFor="extend-profile-name" - > - quality_profiles.copy_new_name - <MandatoryFieldMarker /> - </label> - <input - autoFocus={true} - id="extend-profile-name" - maxLength={100} - name="name" - onChange={[Function]} - required={true} - size={50} - type="text" - value="" - /> - </div> - </div> - <div - className="modal-foot" - > - <DeferredSpinner - className="spacer-right" - loading={false} - /> - <SubmitButton - disabled={true} - id="extend-profile-submit" - > - extend - </SubmitButton> - <ResetButtonLink - id="extend-profile-cancel" - onClick={[MockFunction]} - > - cancel - </ResetButtonLink> - </div> - </form> -</Modal> -`; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap index f59b868a871..23a829c4d9a 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap @@ -1,9 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders with all permissions 1`] = ` +exports[`renders correctly: all permissions 1`] = ` <Fragment> <ActionsDropdown> <ActionsDropdownItem + className="it__quality-profiles__activate-more-rules" to={ Object { "pathname": "/coding_rules", @@ -14,23 +15,17 @@ exports[`renders with all permissions 1`] = ` } } > - <span - data-test="quality-profiles__activate-more-rules" - > - quality_profiles.activate_more_rules - </span> + quality_profiles.activate_more_rules </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__backup" download="key.xml" to="/api/qualityprofiles/backup?language=js&qualityProfile=name" > - <span - data-test="quality-profiles__backup" - > - backup_verb - </span> + backup_verb </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__compare" to={ Object { "pathname": "/profiles/compare", @@ -41,77 +36,56 @@ exports[`renders with all permissions 1`] = ` } } > - <span - data-test="quality-profiles__compare" - > - compare - </span> + compare </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__copy" onClick={[Function]} > - <span - data-test="quality-profiles__copy" - > - copy - </span> + copy </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__extend" onClick={[Function]} > - <span - data-test="quality-profiles__extend" - > - extend - </span> + extend </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__rename" onClick={[Function]} > - <span - data-test="quality-profiles__rename" - > - rename - </span> + rename </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__set-as-default" onClick={[Function]} > - <span - data-test="quality-profiles__set-as-default" - > - set_as_default - </span> + set_as_default </ActionsDropdownItem> <ActionsDropdownDivider /> <ActionsDropdownItem + className="it__quality-profiles__delete" destructive={true} onClick={[Function]} > - <span - data-test="quality-profiles__delete" - > - delete - </span> + delete </ActionsDropdownItem> </ActionsDropdown> </Fragment> `; -exports[`renders with no permissions 1`] = ` +exports[`renders correctly: copy modal 1`] = ` <Fragment> <ActionsDropdown> <ActionsDropdownItem + className="it__quality-profiles__backup" download="key.xml" to="/api/qualityprofiles/backup?language=js&qualityProfile=name" > - <span - data-test="quality-profiles__backup" - > - backup_verb - </span> + backup_verb </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__compare" to={ Object { "pathname": "/profiles/compare", @@ -122,20 +96,91 @@ exports[`renders with no permissions 1`] = ` } } > - <span - data-test="quality-profiles__compare" - > - compare - </span> + compare </ActionsDropdownItem> </ActionsDropdown> + <ProfileModalForm + btnLabelKey="copy" + headerKey="quality_profiles.copy_x_title" + loading={false} + onClose={[Function]} + onSubmit={[Function]} + profile={ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 68, + "childrenCount": 0, + "depth": 0, + "isBuiltIn": false, + "isDefault": false, + "isInherited": false, + "key": "key", + "language": "js", + "languageName": "JavaScript", + "name": "name", + "projectCount": 3, + "rulesUpdatedAt": "2017-06-28T12:58:44+0000", + } + } + /> +</Fragment> +`; + +exports[`renders correctly: delete modal 1`] = ` +<Fragment> + <ActionsDropdown> + <ActionsDropdownItem + className="it__quality-profiles__backup" + download="key.xml" + to="/api/qualityprofiles/backup?language=js&qualityProfile=name" + > + backup_verb + </ActionsDropdownItem> + <ActionsDropdownItem + className="it__quality-profiles__compare" + to={ + Object { + "pathname": "/profiles/compare", + "query": Object { + "language": "js", + "name": "name", + }, + } + } + > + compare + </ActionsDropdownItem> + </ActionsDropdown> + <DeleteProfileForm + loading={false} + onClose={[Function]} + onDelete={[Function]} + profile={ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 68, + "childrenCount": 0, + "depth": 0, + "isBuiltIn": false, + "isDefault": false, + "isInherited": false, + "key": "key", + "language": "js", + "languageName": "JavaScript", + "name": "name", + "projectCount": 3, + "rulesUpdatedAt": "2017-06-28T12:58:44+0000", + } + } + /> </Fragment> `; -exports[`renders with permission to edit only 1`] = ` +exports[`renders correctly: edit only 1`] = ` <Fragment> <ActionsDropdown> <ActionsDropdownItem + className="it__quality-profiles__activate-more-rules" to={ Object { "pathname": "/coding_rules", @@ -146,23 +191,17 @@ exports[`renders with permission to edit only 1`] = ` } } > - <span - data-test="quality-profiles__activate-more-rules" - > - quality_profiles.activate_more_rules - </span> + quality_profiles.activate_more_rules </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__backup" download="key.xml" to="/api/qualityprofiles/backup?language=js&qualityProfile=name" > - <span - data-test="quality-profiles__backup" - > - backup_verb - </span> + backup_verb </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__compare" to={ Object { "pathname": "/profiles/compare", @@ -173,21 +212,146 @@ exports[`renders with permission to edit only 1`] = ` } } > - <span - data-test="quality-profiles__compare" - > - compare - </span> + compare </ActionsDropdownItem> <ActionsDropdownItem + className="it__quality-profiles__rename" onClick={[Function]} > - <span - data-test="quality-profiles__rename" - > - rename - </span> + rename + </ActionsDropdownItem> + </ActionsDropdown> +</Fragment> +`; + +exports[`renders correctly: extend modal 1`] = ` +<Fragment> + <ActionsDropdown> + <ActionsDropdownItem + className="it__quality-profiles__backup" + download="key.xml" + to="/api/qualityprofiles/backup?language=js&qualityProfile=name" + > + backup_verb + </ActionsDropdownItem> + <ActionsDropdownItem + className="it__quality-profiles__compare" + to={ + Object { + "pathname": "/profiles/compare", + "query": Object { + "language": "js", + "name": "name", + }, + } + } + > + compare </ActionsDropdownItem> </ActionsDropdown> + <ProfileModalForm + btnLabelKey="extend" + headerKey="quality_profiles.extend_x_title" + loading={false} + onClose={[Function]} + onSubmit={[Function]} + profile={ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 68, + "childrenCount": 0, + "depth": 0, + "isBuiltIn": false, + "isDefault": false, + "isInherited": false, + "key": "key", + "language": "js", + "languageName": "JavaScript", + "name": "name", + "projectCount": 3, + "rulesUpdatedAt": "2017-06-28T12:58:44+0000", + } + } + /> +</Fragment> +`; + +exports[`renders correctly: no permissions 1`] = ` +<Fragment> + <ActionsDropdown> + <ActionsDropdownItem + className="it__quality-profiles__backup" + download="key.xml" + to="/api/qualityprofiles/backup?language=js&qualityProfile=name" + > + backup_verb + </ActionsDropdownItem> + <ActionsDropdownItem + className="it__quality-profiles__compare" + to={ + Object { + "pathname": "/profiles/compare", + "query": Object { + "language": "js", + "name": "name", + }, + } + } + > + compare + </ActionsDropdownItem> + </ActionsDropdown> +</Fragment> +`; + +exports[`renders correctly: rename modal 1`] = ` +<Fragment> + <ActionsDropdown> + <ActionsDropdownItem + className="it__quality-profiles__backup" + download="key.xml" + to="/api/qualityprofiles/backup?language=js&qualityProfile=name" + > + backup_verb + </ActionsDropdownItem> + <ActionsDropdownItem + className="it__quality-profiles__compare" + to={ + Object { + "pathname": "/profiles/compare", + "query": Object { + "language": "js", + "name": "name", + }, + } + } + > + compare + </ActionsDropdownItem> + </ActionsDropdown> + <ProfileModalForm + btnLabelKey="rename" + headerKey="quality_profiles.rename_x_title" + loading={false} + onClose={[Function]} + onSubmit={[Function]} + profile={ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 68, + "childrenCount": 0, + "depth": 0, + "isBuiltIn": false, + "isDefault": false, + "isInherited": false, + "key": "key", + "language": "js", + "languageName": "JavaScript", + "name": "name", + "projectCount": 3, + "rulesUpdatedAt": "2017-06-28T12:58:44+0000", + } + } + /> </Fragment> `; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileModalForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileModalForm-test.tsx.snap new file mode 100644 index 00000000000..96d57215b30 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileModalForm-test.tsx.snap @@ -0,0 +1,190 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: can submit 1`] = ` +<Modal + contentLabel="header-label.name.JavaScript" + onRequestClose={[MockFunction]} + size="small" +> + <form + onSubmit={[Function]} + > + <div + className="modal-head" + > + <h2> + header-label.name.JavaScript + </h2> + </div> + <div + className="modal-body" + > + <MandatoryFieldsExplanation + className="modal-field" + /> + <div + className="modal-field" + > + <label + htmlFor="profile-name" + > + quality_profiles.new_name + <MandatoryFieldMarker /> + </label> + <input + autoFocus={true} + id="profile-name" + maxLength={100} + name="name" + onChange={[Function]} + required={true} + size={50} + type="text" + value="new name" + /> + </div> + </div> + <div + className="modal-foot" + > + <SubmitButton + disabled={false} + > + btn-label + </SubmitButton> + <ResetButtonLink + onClick={[MockFunction]} + > + cancel + </ResetButtonLink> + </div> + </form> +</Modal> +`; + +exports[`should render correctly: default 1`] = ` +<Modal + contentLabel="header-label.name.JavaScript" + onRequestClose={[MockFunction]} + size="small" +> + <form + onSubmit={[Function]} + > + <div + className="modal-head" + > + <h2> + header-label.name.JavaScript + </h2> + </div> + <div + className="modal-body" + > + <MandatoryFieldsExplanation + className="modal-field" + /> + <div + className="modal-field" + > + <label + htmlFor="profile-name" + > + quality_profiles.new_name + <MandatoryFieldMarker /> + </label> + <input + autoFocus={true} + id="profile-name" + maxLength={100} + name="name" + onChange={[Function]} + required={true} + size={50} + type="text" + value="name" + /> + </div> + </div> + <div + className="modal-foot" + > + <SubmitButton + disabled={true} + > + btn-label + </SubmitButton> + <ResetButtonLink + onClick={[MockFunction]} + > + cancel + </ResetButtonLink> + </div> + </form> +</Modal> +`; + +exports[`should render correctly: loading 1`] = ` +<Modal + contentLabel="header-label.name.JavaScript" + onRequestClose={[MockFunction]} + size="small" +> + <form + onSubmit={[Function]} + > + <div + className="modal-head" + > + <h2> + header-label.name.JavaScript + </h2> + </div> + <div + className="modal-body" + > + <MandatoryFieldsExplanation + className="modal-field" + /> + <div + className="modal-field" + > + <label + htmlFor="profile-name" + > + quality_profiles.new_name + <MandatoryFieldMarker /> + </label> + <input + autoFocus={true} + id="profile-name" + maxLength={100} + name="name" + onChange={[Function]} + required={true} + size={50} + type="text" + value="name" + /> + </div> + </div> + <div + className="modal-foot" + > + <i + className="spinner spacer-right" + /> + <SubmitButton + disabled={true} + > + btn-label + </SubmitButton> + <ResetButtonLink + onClick={[MockFunction]} + > + cancel + </ResetButtonLink> + </div> + </form> +</Modal> +`; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileInheritanceBox.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileInheritanceBox.tsx index 834d936cd60..096152f3372 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileInheritanceBox.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileInheritanceBox.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import * as classNames from 'classnames'; import * as React from 'react'; import HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; @@ -46,7 +47,7 @@ export default function ProfileInheritanceBox(props: Props) { const offset = 25 * depth; return ( - <tr className={className} data-test={`quality-profiles__inheritance-${type}`}> + <tr className={classNames(`it__quality-profiles__inheritance-${type}`, className)}> <td> <div style={{ paddingLeft: offset }}> {displayLink ? ( diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileInheritanceBox-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileInheritanceBox-test.tsx.snap index c42184e6a85..18416a4f9e3 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileInheritanceBox-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileInheritanceBox-test.tsx.snap @@ -2,7 +2,7 @@ exports[`should render correctly 1`] = ` <tr - data-test="quality-profiles__inheritance-current" + className="it__quality-profiles__inheritance-current" > <td> <div @@ -34,7 +34,7 @@ exports[`should render correctly 1`] = ` exports[`should render correctly 2`] = ` <tr - data-test="quality-profiles__inheritance-current" + className="it__quality-profiles__inheritance-current" > <td> <div @@ -69,7 +69,7 @@ exports[`should render correctly 2`] = ` exports[`should render correctly 3`] = ` <tr - data-test="quality-profiles__inheritance-current" + className="it__quality-profiles__inheritance-current" > <td> <div @@ -105,7 +105,7 @@ exports[`should render correctly 3`] = ` exports[`should render correctly 4`] = ` <tr - data-test="quality-profiles__inheritance-current" + className="it__quality-profiles__inheritance-current" > <td> <div diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/types.ts b/server/sonar-web/src/main/js/apps/quality-profiles/types.ts index 61e9e4ae71c..03c64fcac44 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/types.ts +++ b/server/sonar-web/src/main/js/apps/quality-profiles/types.ts @@ -38,3 +38,10 @@ export interface ProfileChangelogEvent { ruleKey: string; ruleName: string; } + +export enum ProfileActionModals { + Copy, + Extend, + Rename, + Delete +} 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 b97f6a7e60d..04d2de164c0 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1506,7 +1506,6 @@ quality_profiles.x_rules_only_in={0} rules only in quality_profiles.x_rules_have_different_configuration={0} rules have a different configuration quality_profiles.copy_x_title=Copy Profile "{0}" - {1} quality_profiles.extend_x_title=Extend Profile "{0}" - {1} -quality_profiles.copy_new_name=New name quality_profiles.rename_x_title=Rename Profile {0} - {1} quality_profiles.deprecated=deprecated quality_profiles.severity_set_to=Severity set to |