aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2021-08-12 10:21:24 +0200
committersonartech <sonartech@sonarsource.com>2021-08-13 20:03:54 +0000
commitf8f1b4c9fdf6688e2336e57ebe1d23a82a1c58b7 (patch)
tree28d95d453b405cf7dedb327c4a634ead204ed0ae /server/sonar-web/src
parent0971ca99e937be30a54965bf616f78ec4779d108 (diff)
downloadsonarqube-f8f1b4c9fdf6688e2336e57ebe1d23a82a1c58b7.tar.gz
sonarqube-f8f1b4c9fdf6688e2336e57ebe1d23a82a1c58b7.zip
SONAR-13150 Prevent using quality profiles with no active rules
Diffstat (limited to 'server/sonar-web/src')
-rw-r--r--server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx33
-rw-r--r--server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx38
-rw-r--r--server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx26
-rw-r--r--server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx18
-rw-r--r--server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap35
-rw-r--r--server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap40
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileActions.tsx35
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/ProfileActions-test.tsx16
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/components/__tests__/__snapshots__/ProfileActions-test.tsx.snap40
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileDetails.tsx17
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.tsx23
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileDetails-test.tsx54
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileProjects-test.tsx21
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileDetails-test.tsx.snap420
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileProjects-test.tsx.snap193
-rw-r--r--server/sonar-web/src/main/js/components/common/DisableableSelectOption.tsx44
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/DisableableSelectOption-test.tsx47
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DisableableSelectOption-test.tsx.snap42
18 files changed, 1071 insertions, 71 deletions
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx
index 0aae23b00c7..871a5ce7885 100644
--- a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx
+++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/AddLanguageModal.tsx
@@ -20,11 +20,14 @@
import { difference } from 'lodash';
import * as React from 'react';
import { connect } from 'react-redux';
+import { Link } from 'react-router';
import { ButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons';
import Select from 'sonar-ui-common/components/controls/Select';
import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
import { translate } from 'sonar-ui-common/helpers/l10n';
import { Profile } from '../../../api/quality-profiles';
+import DisableableSelectOption from '../../../components/common/DisableableSelectOption';
+import { getQualityProfileUrl } from '../../../helpers/urls';
import { Store } from '../../../store/rootReducer';
export interface AddLanguageModalProps {
@@ -52,7 +55,11 @@ export function AddLanguageModal(props: AddLanguageModalProps) {
const profileOptions =
language !== undefined
- ? profilesByLanguage[language].map(p => ({ value: p.key, label: p.name }))
+ ? profilesByLanguage[language].map(p => ({
+ value: p.key,
+ label: p.name,
+ disabled: p.activeRuleCount === 0
+ }))
: [];
return (
@@ -102,6 +109,30 @@ export function AddLanguageModal(props: AddLanguageModalProps) {
id="profiles"
onChange={({ value }: { value: string }) => setSelected({ language, key: value })}
options={profileOptions}
+ optionRenderer={option => (
+ <DisableableSelectOption
+ option={option}
+ disabledReason={translate(
+ 'project_quality_profile.add_language_modal.no_active_rules'
+ )}
+ tooltipOverlay={
+ <>
+ <p>
+ {translate(
+ 'project_quality_profile.add_language_modal.profile_unavailable_no_active_rules'
+ )}
+ </p>
+ {option.label && language && (
+ <Link to={getQualityProfileUrl(option.label, language)}>
+ {translate(
+ 'project_quality_profile.add_language_modal.go_to_profile'
+ )}
+ </Link>
+ )}
+ </>
+ }
+ />
+ )}
value={key}
/>
</div>
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx
index 5c0217f6b3e..20d5effd671 100644
--- a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx
+++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/SetQualityProfileModal.tsx
@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
+import { Link } from 'react-router';
import { ButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons';
import Radio from 'sonar-ui-common/components/controls/Radio';
import Select from 'sonar-ui-common/components/controls/Select';
@@ -25,6 +26,8 @@ import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
import { Alert } from 'sonar-ui-common/components/ui/Alert';
import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
import { Profile } from '../../../api/quality-profiles';
+import DisableableSelectOption from '../../../components/common/DisableableSelectOption';
+import { getQualityProfileUrl } from '../../../helpers/urls';
import BuiltInQualityProfileBadge from '../../quality-profiles/components/BuiltInQualityProfileBadge';
import { USE_SYSTEM_DEFAULT } from '../constants';
@@ -54,7 +57,11 @@ export default function SetQualityProfileModal(props: SetQualityProfileModalProp
'project_quality_profile.change_lang_X_profile',
currentProfile.languageName
);
- const profileOptions = availableProfiles.map(p => ({ value: p.key, label: p.name }));
+ const profileOptions = availableProfiles.map(p => ({
+ value: p.key,
+ label: p.name,
+ disabled: p.activeRuleCount === 0
+ }));
const hasSelectedSysDefault = selected === USE_SYSTEM_DEFAULT;
const hasChanged = usesDefault ? !hasSelectedSysDefault : selected !== currentProfile.key;
const needsReanalysis = !component.qualityProfiles?.some(p =>
@@ -118,7 +125,34 @@ export default function SetQualityProfileModal(props: SetQualityProfileModalProp
disabled={submitting || hasSelectedSysDefault}
onChange={({ value }: { value: string }) => setSelected(value)}
options={profileOptions}
- optionRenderer={option => <span>{option.label}</span>}
+ optionRenderer={option => (
+ <DisableableSelectOption
+ option={option}
+ disabledReason={translate(
+ 'project_quality_profile.add_language_modal.no_active_rules'
+ )}
+ tooltipOverlay={
+ <>
+ <p>
+ {translate(
+ 'project_quality_profile.add_language_modal.profile_unavailable_no_active_rules'
+ )}
+ </p>
+ {option.label && (
+ <Link
+ to={getQualityProfileUrl(
+ option.label,
+ currentProfile.language
+ )}>
+ {translate(
+ 'project_quality_profile.add_language_modal.go_to_profile'
+ )}
+ </Link>
+ )}
+ </>
+ }
+ />
+ )}
value={!hasSelectedSysDefault ? selected : currentProfile.key}
/>
</div>
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx
index 60d5960ca89..9438ce4f61f 100644
--- a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx
+++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/AddLanguageModal-test.tsx
@@ -28,6 +28,27 @@ it('should render correctly', () => {
expect(diveIntoSimpleModal(shallowRender())).toMatchSnapshot('default');
});
+it('should render select options correctly', () => {
+ return new Promise<void>((resolve, reject) => {
+ const wrapper = shallowRender();
+
+ const langOnChange = getLanguageSelect(wrapper).props().onChange;
+ if (!langOnChange) {
+ reject();
+ return;
+ }
+ langOnChange({ value: 'js' });
+
+ const render = getProfileSelect(wrapper).props().optionRenderer;
+ if (!render) {
+ reject();
+ return;
+ }
+ expect(render({ value: 'bar', label: 'Profile 1' })).toMatchSnapshot('default');
+ resolve();
+ });
+});
+
it('should correctly handle changes', () => {
const onSubmit = jest.fn();
const wrapper = shallowRender({ onSubmit });
@@ -50,6 +71,9 @@ it('should correctly handle changes', () => {
// Should now show 2 available profiles.
profileSelect = getProfileSelect(wrapper);
expect(profileSelect.props().options).toHaveLength(2);
+ expect(profileSelect.props().options).toEqual(
+ expect.arrayContaining([expect.objectContaining({ disabled: true })])
+ );
// Choose 1 profile.
const profileChange = profileSelect.props().onChange;
@@ -100,7 +124,7 @@ function shallowRender(props: Partial<AddLanguageModalProps> = {}) {
onSubmit={jest.fn()}
profilesByLanguage={{
css: [
- mockQualityProfile({ key: 'css', name: 'CSS' }),
+ mockQualityProfile({ key: 'css', name: 'CSS', activeRuleCount: 0 }),
mockQualityProfile({ key: 'css2', name: 'CSS 2' })
],
ts: [mockQualityProfile({ key: 'ts', name: 'TS' })],
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx
index 6e3b4fe9145..9a7995ca6ee 100644
--- a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx
+++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/SetQualityProfileModal-test.tsx
@@ -32,12 +32,16 @@ it('should render correctly', () => {
});
it('should render select options correctly', () => {
- const wrapper = shallowRender();
- const render = wrapper.find(Select).props().optionRenderer;
-
- expect(render).toBeDefined();
-
- expect(render!({ value: 'bar', label: 'Profile 1' })).toMatchSnapshot('default');
+ return new Promise<void>((resolve, reject) => {
+ const wrapper = shallowRender();
+ const render = wrapper.find(Select).props().optionRenderer;
+ if (!render) {
+ reject();
+ return;
+ }
+ expect(render({ value: 'bar', label: 'Profile 1' })).toMatchSnapshot('default');
+ resolve();
+ });
});
it('should correctly handle changes', () => {
@@ -90,7 +94,7 @@ function shallowRender(props: Partial<SetQualityProfileModalProps> = {}, dive =
<SetQualityProfileModal
availableProfiles={[
mockQualityProfile({ key: 'foo', isDefault: true, language: 'js' }),
- mockQualityProfile({ key: 'bar', language: 'js' })
+ mockQualityProfile({ key: 'bar', language: 'js', activeRuleCount: 0 })
]}
component={mockComponent({ qualityProfiles: [{ key: 'foo', name: 'Foo', language: 'js' }] })}
currentProfile={mockQualityProfile({ key: 'foo', language: 'js' })}
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap
index 96957df74e4..8b22589ddcd 100644
--- a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/AddLanguageModal-test.tsx.snap
@@ -67,6 +67,7 @@ Array [
disabled={true}
id="profiles"
onChange={[Function]}
+ optionRenderer={[Function]}
options={Array []}
/>
</div>
@@ -89,3 +90,37 @@ Array [
</form>,
]
`;
+
+exports[`should render select options correctly: default 1`] = `
+<DisableableSelectOption
+ disabledReason="project_quality_profile.add_language_modal.no_active_rules"
+ option={
+ Object {
+ "label": "Profile 1",
+ "value": "bar",
+ }
+ }
+ tooltipOverlay={
+ <React.Fragment>
+ <p>
+ project_quality_profile.add_language_modal.profile_unavailable_no_active_rules
+ </p>
+ <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/profiles/show",
+ "query": Object {
+ "language": "js",
+ "name": "Profile 1",
+ },
+ }
+ }
+ >
+ project_quality_profile.add_language_modal.go_to_profile
+ </Link>
+ </React.Fragment>
+ }
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap
index d14dd4876b8..c61e39d1cff 100644
--- a/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/components/__tests__/__snapshots__/SetQualityProfileModal-test.tsx.snap
@@ -77,10 +77,12 @@ Array [
options={
Array [
Object {
+ "disabled": false,
"label": "name",
"value": "foo",
},
Object {
+ "disabled": true,
"label": "name",
"value": "bar",
},
@@ -189,10 +191,12 @@ Array [
options={
Array [
Object {
+ "disabled": false,
"label": "name",
"value": "foo",
},
Object {
+ "disabled": true,
"label": "name",
"value": "bar",
},
@@ -301,10 +305,12 @@ Array [
options={
Array [
Object {
+ "disabled": false,
"label": "name",
"value": "foo",
},
Object {
+ "disabled": true,
"label": "name",
"value": "bar",
},
@@ -342,7 +348,35 @@ Array [
`;
exports[`should render select options correctly: default 1`] = `
-<span>
- Profile 1
-</span>
+<DisableableSelectOption
+ disabledReason="project_quality_profile.add_language_modal.no_active_rules"
+ option={
+ Object {
+ "label": "Profile 1",
+ "value": "bar",
+ }
+ }
+ tooltipOverlay={
+ <React.Fragment>
+ <p>
+ project_quality_profile.add_language_modal.profile_unavailable_no_active_rules
+ </p>
+ <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/profiles/show",
+ "query": Object {
+ "language": "js",
+ "name": "Profile 1",
+ },
+ }
+ }
+ >
+ project_quality_profile.add_language_modal.go_to_profile
+ </Link>
+ </React.Fragment>
+ }
+/>
`;
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 c8ac23413f6..8b80c62cb64 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
@@ -22,6 +22,7 @@ import ActionsDropdown, {
ActionsDropdownDivider,
ActionsDropdownItem
} from 'sonar-ui-common/components/controls/ActionsDropdown';
+import Tooltip from 'sonar-ui-common/components/controls/Tooltip';
import { translate } from 'sonar-ui-common/helpers/l10n';
import {
changeProfileParent,
@@ -145,7 +146,12 @@ export class ProfileActions extends React.PureComponent<Props, State> {
};
handleSetDefaultClick = () => {
- setDefaultProfile(this.props.profile).then(this.props.updateProfiles, () => {});
+ const { profile } = this.props;
+ if (profile.activeRuleCount > 0) {
+ setDefaultProfile(profile).then(this.props.updateProfiles, () => {
+ /* noop */
+ });
+ }
};
profileActionPerformed = (name: string) => {
@@ -181,6 +187,8 @@ export class ProfileActions extends React.PureComponent<Props, State> {
activation: 'false'
});
+ const hasNoActiveRules = profile.activeRuleCount === 0;
+
return (
<>
<ActionsDropdown className={this.props.className}>
@@ -231,13 +239,24 @@ export class ProfileActions extends React.PureComponent<Props, State> {
</ActionsDropdownItem>
)}
- {actions.setAsDefault && (
- <ActionsDropdownItem
- className="it__quality-profiles__set-as-default"
- onClick={this.handleSetDefaultClick}>
- {translate('set_as_default')}
- </ActionsDropdownItem>
- )}
+ {actions.setAsDefault &&
+ (hasNoActiveRules ? (
+ <li>
+ <Tooltip
+ placement="left"
+ overlay={translate('quality_profiles.cannot_set_default_no_rules')}>
+ <span className="it__quality-profiles__set-as-default text-muted-2">
+ {translate('set_as_default')}
+ </span>
+ </Tooltip>
+ </li>
+ ) : (
+ <ActionsDropdownItem
+ className="it__quality-profiles__set-as-default"
+ onClick={this.handleSetDefaultClick}>
+ {translate('set_as_default')}
+ </ActionsDropdownItem>
+ ))}
{actions.delete && <ActionsDropdownDivider />}
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 a6ad0fffb57..d5320fa5067 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
@@ -323,6 +323,22 @@ it('should correctly set a profile as the default', async () => {
expect(updateProfiles).toHaveBeenCalled();
});
+it('should not allow to set a profile as the default if the profile has no active rules', async () => {
+ const profile = mockQualityProfile({
+ activeRuleCount: 0,
+ actions: {
+ setAsDefault: true
+ }
+ });
+
+ const wrapper = shallowRender({ profile });
+ wrapper.instance().handleSetDefaultClick();
+ await waitAndUpdate(wrapper);
+
+ expect(setDefaultProfile).not.toHaveBeenCalled();
+ expect(wrapper).toMatchSnapshot();
+});
+
function shallowRender(props: Partial<ProfileActions['props']> = {}) {
const router = mockRouter();
return shallow<ProfileActions>(
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 23a829c4d9a..ffb04e929d3 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
@@ -355,3 +355,43 @@ exports[`renders correctly: rename modal 1`] = `
/>
</Fragment>
`;
+
+exports[`should not allow to set a profile as the default if the profile has no active rules 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>
+ <li>
+ <Tooltip
+ overlay="quality_profiles.cannot_set_default_no_rules"
+ placement="left"
+ >
+ <span
+ className="it__quality-profiles__set-as-default text-muted-2"
+ >
+ set_as_default
+ </span>
+ </Tooltip>
+ </li>
+ </ActionsDropdown>
+</Fragment>
+`;
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileDetails.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileDetails.tsx
index d6e55d237a0..0e18bd84523 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileDetails.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileDetails.tsx
@@ -18,6 +18,8 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import { translate } from 'sonar-ui-common/helpers/l10n';
import { Exporter, Profile } from '../types';
import ProfileExporters from './ProfileExporters';
import ProfileInheritance from './ProfileInheritance';
@@ -25,14 +27,14 @@ import ProfilePermissions from './ProfilePermissions';
import ProfileProjects from './ProfileProjects';
import ProfileRules from './ProfileRules';
-interface Props {
+export interface ProfileDetailsProps {
exporters: Exporter[];
profile: Profile;
profiles: Profile[];
updateProfiles: () => Promise<void>;
}
-export default function ProfileDetails(props: Props) {
+export default function ProfileDetails(props: ProfileDetailsProps) {
const { profile } = props;
return (
<div>
@@ -45,6 +47,17 @@ export default function ProfileDetails(props: Props) {
)}
</div>
<div className="quality-profile-grid-right">
+ {profile.activeRuleCount === 0 && (profile.projectCount || profile.isDefault) && (
+ <Alert className="big-spacer-bottom" variant="warning">
+ {profile.projectCount !== undefined &&
+ profile.projectCount > 0 &&
+ translate('quality_profiles.warning.used_by_projects_no_rules')}
+ {!profile.projectCount &&
+ profile.isDefault &&
+ translate('quality_profiles.warning.is_default_no_rules')}
+ </Alert>
+ )}
+
<ProfileInheritance
profile={profile}
profiles={props.profiles}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.tsx
index 2bbb7955397..33758d5616c 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileProjects.tsx
@@ -21,6 +21,7 @@ import * as React from 'react';
import { Link } from 'react-router';
import { Button } from 'sonar-ui-common/components/controls/buttons';
import ListFooter from 'sonar-ui-common/components/controls/ListFooter';
+import Tooltip from 'sonar-ui-common/components/controls/Tooltip';
import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon';
import { translate } from 'sonar-ui-common/helpers/l10n';
import { getProfileProjects } from '../../../api/quality-profiles';
@@ -131,6 +132,11 @@ export default class ProfileProjects extends React.PureComponent<Props, State> {
}
const { projects } = this.state;
+ const { profile } = this.props;
+
+ if (profile.activeRuleCount === 0 && projects.length === 0) {
+ return <div>{translate('quality_profiles.cannot_associate_projects_no_rules')}</div>;
+ }
if (projects.length === 0) {
return <div>{translate('quality_profiles.no_projects_associated_to_profile')}</div>;
@@ -159,13 +165,24 @@ export default class ProfileProjects extends React.PureComponent<Props, State> {
render() {
const { profile } = this.props;
+ const hasNoActiveRules = profile.activeRuleCount === 0;
return (
<div className="boxed-group quality-profile-projects">
{profile.actions && profile.actions.associateProjects && (
<div className="boxed-group-actions">
- <Button className="js-change-projects" onClick={this.handleChangeClick}>
- {translate('quality_profiles.change_projects')}
- </Button>
+ <Tooltip
+ overlay={
+ hasNoActiveRules
+ ? translate('quality_profiles.cannot_associate_projects_no_rules')
+ : null
+ }>
+ <Button
+ className="js-change-projects"
+ onClick={this.handleChangeClick}
+ disabled={hasNoActiveRules}>
+ {translate('quality_profiles.change_projects')}
+ </Button>
+ </Tooltip>
</div>
)}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileDetails-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileDetails-test.tsx
index 5fca0c51a78..d564ce0d49c 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileDetails-test.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileDetails-test.tsx
@@ -19,31 +19,37 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
-import { Profile } from '../../types';
-import ProfileDetails from '../ProfileDetails';
+import { mockQualityProfile } from '../../../../helpers/testMocks';
+import ProfileDetails, { ProfileDetailsProps } from '../ProfileDetails';
-it('renders without permissions', () => {
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot('default');
expect(
- shallow(
- <ProfileDetails
- exporters={[]}
- profile={{} as Profile}
- profiles={[]}
- updateProfiles={jest.fn()}
- />
- )
- ).toMatchSnapshot();
-});
-
-it('renders with edit permission', () => {
+ shallowRender({ profile: mockQualityProfile({ actions: { edit: true } }) })
+ ).toMatchSnapshot('edit permissions');
+ expect(
+ shallowRender({
+ profile: mockQualityProfile({ activeRuleCount: 0, projectCount: 0 })
+ })
+ ).toMatchSnapshot('no active rules (same as default)');
expect(
- shallow(
- <ProfileDetails
- exporters={[]}
- profile={{ actions: { edit: true } } as Profile}
- profiles={[]}
- updateProfiles={jest.fn()}
- />
- )
- ).toMatchSnapshot();
+ shallowRender({
+ profile: mockQualityProfile({ projectCount: 0, isDefault: true, activeRuleCount: 0 })
+ })
+ ).toMatchSnapshot('is default profile, no active rules');
+ expect(
+ shallowRender({ profile: mockQualityProfile({ projectCount: 10, activeRuleCount: 0 }) })
+ ).toMatchSnapshot('projects associated, no active rules');
});
+
+function shallowRender(props: Partial<ProfileDetailsProps> = {}) {
+ return shallow<ProfileDetailsProps>(
+ <ProfileDetails
+ exporters={[]}
+ profile={mockQualityProfile()}
+ profiles={[]}
+ updateProfiles={jest.fn()}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileProjects-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileProjects-test.tsx
index 1303e4917c2..8599b62a419 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileProjects-test.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileProjects-test.tsx
@@ -41,9 +41,26 @@ jest.mock('../../../../api/quality-profiles', () => ({
it('should render correctly', async () => {
const wrapper = shallowRender();
- expect(wrapper).toMatchSnapshot();
+ expect(wrapper).toMatchSnapshot('loading');
await waitAndUpdate(wrapper);
- expect(wrapper).toMatchSnapshot();
+ expect(wrapper).toMatchSnapshot('default');
+ wrapper.setProps({
+ profile: mockQualityProfile({ actions: { associateProjects: false } })
+ });
+ expect(wrapper).toMatchSnapshot('no rights');
+ wrapper.setProps({
+ profile: mockQualityProfile({
+ projectCount: 0,
+ activeRuleCount: 0,
+ actions: { associateProjects: true }
+ })
+ });
+ expect(wrapper).toMatchSnapshot('no active rules, but associated projects');
+ wrapper.setProps({
+ profile: mockQualityProfile({ activeRuleCount: 0, actions: { associateProjects: true } })
+ });
+ wrapper.setState({ projects: [] });
+ expect(wrapper).toMatchSnapshot('no active rules, no associated projects');
});
it('should open and close the form', async () => {
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileDetails-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileDetails-test.tsx.snap
index 0dcae2b693e..ff98cd9515d 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileDetails-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileDetails-test.tsx.snap
@@ -1,6 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`renders with edit permission 1`] = `
+exports[`should render correctly: default 1`] = `
+<div>
+ <div
+ className="quality-profile-grid"
+ >
+ <div
+ className="quality-profile-grid-left"
+ >
+ <ProfileRules
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
+ }
+ }
+ />
+ <ProfileExporters
+ exporters={Array []}
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
+ }
+ }
+ />
+ </div>
+ <div
+ className="quality-profile-grid-right"
+ >
+ <ProfileInheritance
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
+ }
+ }
+ profiles={Array []}
+ updateProfiles={[MockFunction]}
+ />
+ <ProfileProjects
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
+ }
+ }
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`should render correctly: edit permissions 1`] = `
<div>
<div
className="quality-profile-grid"
@@ -14,6 +106,18 @@ exports[`renders with edit permission 1`] = `
"actions": Object {
"edit": true,
},
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
}
}
/>
@@ -24,6 +128,18 @@ exports[`renders with edit permission 1`] = `
"actions": Object {
"edit": true,
},
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
}
}
/>
@@ -33,6 +149,18 @@ exports[`renders with edit permission 1`] = `
"actions": Object {
"edit": true,
},
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
}
}
/>
@@ -46,6 +174,18 @@ exports[`renders with edit permission 1`] = `
"actions": Object {
"edit": true,
},
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
}
}
profiles={Array []}
@@ -57,6 +197,18 @@ exports[`renders with edit permission 1`] = `
"actions": Object {
"edit": true,
},
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 10,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 3,
}
}
/>
@@ -65,7 +217,7 @@ exports[`renders with edit permission 1`] = `
</div>
`;
-exports[`renders without permissions 1`] = `
+exports[`should render correctly: is default profile, no active rules 1`] = `
<div>
<div
className="quality-profile-grid"
@@ -74,23 +226,279 @@ exports[`renders without permissions 1`] = `
className="quality-profile-grid-left"
>
<ProfileRules
- profile={Object {}}
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": true,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
/>
<ProfileExporters
exporters={Array []}
- profile={Object {}}
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": true,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
/>
</div>
<div
className="quality-profile-grid-right"
>
+ <Alert
+ className="big-spacer-bottom"
+ variant="warning"
+ >
+ quality_profiles.warning.is_default_no_rules
+ </Alert>
<ProfileInheritance
- profile={Object {}}
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": true,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
profiles={Array []}
updateProfiles={[MockFunction]}
/>
<ProfileProjects
- profile={Object {}}
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": true,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`should render correctly: no active rules (same as default) 1`] = `
+<div>
+ <div
+ className="quality-profile-grid"
+ >
+ <div
+ className="quality-profile-grid-left"
+ >
+ <ProfileRules
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
+ />
+ <ProfileExporters
+ exporters={Array []}
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
+ />
+ </div>
+ <div
+ className="quality-profile-grid-right"
+ >
+ <ProfileInheritance
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
+ profiles={Array []}
+ updateProfiles={[MockFunction]}
+ />
+ <ProfileProjects
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 0,
+ }
+ }
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`should render correctly: projects associated, no active rules 1`] = `
+<div>
+ <div
+ className="quality-profile-grid"
+ >
+ <div
+ className="quality-profile-grid-left"
+ >
+ <ProfileRules
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 10,
+ }
+ }
+ />
+ <ProfileExporters
+ exporters={Array []}
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 10,
+ }
+ }
+ />
+ </div>
+ <div
+ className="quality-profile-grid-right"
+ >
+ <Alert
+ className="big-spacer-bottom"
+ variant="warning"
+ >
+ quality_profiles.warning.used_by_projects_no_rules
+ </Alert>
+ <ProfileInheritance
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 10,
+ }
+ }
+ profiles={Array []}
+ updateProfiles={[MockFunction]}
+ />
+ <ProfileProjects
+ profile={
+ Object {
+ "activeDeprecatedRuleCount": 2,
+ "activeRuleCount": 0,
+ "childrenCount": 0,
+ "depth": 1,
+ "isBuiltIn": false,
+ "isDefault": false,
+ "isInherited": false,
+ "key": "key",
+ "language": "js",
+ "languageName": "JavaScript",
+ "name": "name",
+ "projectCount": 10,
+ }
+ }
/>
</div>
</div>
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileProjects-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileProjects-test.tsx.snap
index 32a90773230..d69b1c67fa8 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileProjects-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfileProjects-test.tsx.snap
@@ -1,18 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`should render correctly 1`] = `
+exports[`should render correctly: default 1`] = `
<div
className="boxed-group quality-profile-projects"
>
<div
className="boxed-group-actions"
>
- <Button
- className="js-change-projects"
- onClick={[Function]}
+ <Tooltip
+ overlay={null}
>
- quality_profiles.change_projects
- </Button>
+ <Button
+ className="js-change-projects"
+ disabled={false}
+ onClick={[Function]}
+ >
+ quality_profiles.change_projects
+ </Button>
+ </Tooltip>
+ </div>
+ <header
+ className="boxed-group-header"
+ >
+ <h2>
+ projects
+ </h2>
+ </header>
+ <div
+ className="boxed-group-inner"
+ >
+ <ul>
+ <li
+ className="spacer-top js-profile-project"
+ data-key="org.sonarsource.xml:xml"
+ key="org.sonarsource.xml:xml"
+ >
+ <Link
+ className="link-with-icon"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/dashboard",
+ "query": Object {
+ "branch": undefined,
+ "id": "org.sonarsource.xml:xml",
+ },
+ }
+ }
+ >
+ <QualifierIcon
+ qualifier="TRK"
+ />
+
+ <span>
+ SonarXML
+ </span>
+ </Link>
+ </li>
+ </ul>
+ <ListFooter
+ count={1}
+ loadMore={[Function]}
+ ready={true}
+ total={10}
+ />
+ </div>
+</div>
+`;
+
+exports[`should render correctly: loading 1`] = `
+<div
+ className="boxed-group quality-profile-projects"
+>
+ <div
+ className="boxed-group-actions"
+ >
+ <Tooltip
+ overlay={null}
+ >
+ <Button
+ className="js-change-projects"
+ disabled={false}
+ onClick={[Function]}
+ >
+ quality_profiles.change_projects
+ </Button>
+ </Tooltip>
</div>
<header
className="boxed-group-header"
@@ -31,19 +105,24 @@ exports[`should render correctly 1`] = `
</div>
`;
-exports[`should render correctly 2`] = `
+exports[`should render correctly: no active rules, but associated projects 1`] = `
<div
className="boxed-group quality-profile-projects"
>
<div
className="boxed-group-actions"
>
- <Button
- className="js-change-projects"
- onClick={[Function]}
+ <Tooltip
+ overlay="quality_profiles.cannot_associate_projects_no_rules"
>
- quality_profiles.change_projects
- </Button>
+ <Button
+ className="js-change-projects"
+ disabled={true}
+ onClick={[Function]}
+ >
+ quality_profiles.change_projects
+ </Button>
+ </Tooltip>
</div>
<header
className="boxed-group-header"
@@ -94,3 +173,93 @@ exports[`should render correctly 2`] = `
</div>
</div>
`;
+
+exports[`should render correctly: no active rules, no associated projects 1`] = `
+<div
+ className="boxed-group quality-profile-projects"
+>
+ <div
+ className="boxed-group-actions"
+ >
+ <Tooltip
+ overlay="quality_profiles.cannot_associate_projects_no_rules"
+ >
+ <Button
+ className="js-change-projects"
+ disabled={true}
+ onClick={[Function]}
+ >
+ quality_profiles.change_projects
+ </Button>
+ </Tooltip>
+ </div>
+ <header
+ className="boxed-group-header"
+ >
+ <h2>
+ projects
+ </h2>
+ </header>
+ <div
+ className="boxed-group-inner"
+ >
+ <div>
+ quality_profiles.cannot_associate_projects_no_rules
+ </div>
+ </div>
+</div>
+`;
+
+exports[`should render correctly: no rights 1`] = `
+<div
+ className="boxed-group quality-profile-projects"
+>
+ <header
+ className="boxed-group-header"
+ >
+ <h2>
+ projects
+ </h2>
+ </header>
+ <div
+ className="boxed-group-inner"
+ >
+ <ul>
+ <li
+ className="spacer-top js-profile-project"
+ data-key="org.sonarsource.xml:xml"
+ key="org.sonarsource.xml:xml"
+ >
+ <Link
+ className="link-with-icon"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/dashboard",
+ "query": Object {
+ "branch": undefined,
+ "id": "org.sonarsource.xml:xml",
+ },
+ }
+ }
+ >
+ <QualifierIcon
+ qualifier="TRK"
+ />
+
+ <span>
+ SonarXML
+ </span>
+ </Link>
+ </li>
+ </ul>
+ <ListFooter
+ count={1}
+ loadMore={[Function]}
+ ready={true}
+ total={10}
+ />
+ </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/components/common/DisableableSelectOption.tsx b/server/sonar-web/src/main/js/components/common/DisableableSelectOption.tsx
new file mode 100644
index 00000000000..90c5612baf2
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/common/DisableableSelectOption.tsx
@@ -0,0 +1,44 @@
+/*
+ * 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 Tooltip from 'sonar-ui-common/components/controls/Tooltip';
+
+export interface DisableableSelectOptionProps {
+ option: { label?: string; value?: string | number | boolean; disabled?: boolean };
+ tooltipOverlay: React.ReactNode;
+ disabledReason?: string;
+}
+
+export default function DisableableSelectOption(props: DisableableSelectOptionProps) {
+ const { option, tooltipOverlay, disabledReason } = props;
+ const label = option.label || option.value;
+ return option.disabled ? (
+ <Tooltip overlay={tooltipOverlay} placement="left">
+ <span>
+ {label}
+ {disabledReason !== undefined && (
+ <em className="small little-spacer-left">({disabledReason})</em>
+ )}
+ </span>
+ </Tooltip>
+ ) : (
+ <span>{label}</span>
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/common/__tests__/DisableableSelectOption-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/DisableableSelectOption-test.tsx
new file mode 100644
index 00000000000..c810aa4a687
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/common/__tests__/DisableableSelectOption-test.tsx
@@ -0,0 +1,47 @@
+/*
+ * 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 DisableableSelectOption, { DisableableSelectOptionProps } from '../DisableableSelectOption';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot('default');
+ expect(shallowRender({ option: { value: 'baz' } })).toMatchSnapshot('no label');
+ expect(shallowRender({ option: { label: 'Bar', value: 'bar', disabled: true } })).toMatchSnapshot(
+ 'disabled'
+ );
+ expect(
+ shallowRender({
+ option: { label: 'Bar', value: 'bar', disabled: true },
+ disabledReason: 'bar baz'
+ })
+ ).toMatchSnapshot('disabled, with explanation');
+});
+
+function shallowRender(props: Partial<DisableableSelectOptionProps> = {}) {
+ return shallow<DisableableSelectOptionProps>(
+ <DisableableSelectOption
+ option={{ label: 'Foo', value: 'foo' }}
+ tooltipOverlay="foo bar"
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DisableableSelectOption-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DisableableSelectOption-test.tsx.snap
new file mode 100644
index 00000000000..5ed0c2ae05a
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DisableableSelectOption-test.tsx.snap
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+<span>
+ Foo
+</span>
+`;
+
+exports[`should render correctly: disabled 1`] = `
+<Tooltip
+ overlay="foo bar"
+ placement="left"
+>
+ <span>
+ Bar
+ </span>
+</Tooltip>
+`;
+
+exports[`should render correctly: disabled, with explanation 1`] = `
+<Tooltip
+ overlay="foo bar"
+ placement="left"
+>
+ <span>
+ Bar
+ <em
+ className="small little-spacer-left"
+ >
+ (
+ bar baz
+ )
+ </em>
+ </span>
+</Tooltip>
+`;
+
+exports[`should render correctly: no label 1`] = `
+<span>
+ baz
+</span>
+`;