From 1cb0f387e720cd1cb62e9b8d0954d90ff79d04e3 Mon Sep 17 00:00:00 2001 From: Mathieu Suen Date: Wed, 11 Aug 2021 15:51:04 +0200 Subject: SONAR-14139 Prevent users from using a Quality Gate with no conditions --- .../projectQualityGate/ProjectQualityGateApp.tsx | 13 +- .../ProjectQualityGateAppRenderer.tsx | 49 ++++- .../__tests__/ProjectQualityGateApp-test.tsx | 53 +++-- .../ProjectQualityGateAppRenderer-test.tsx | 11 +- .../ProjectQualityGateApp-test.tsx.snap | 75 ++++++- .../ProjectQualityGateAppRenderer-test.tsx.snap | 233 ++++++++++++++++++++- .../components/AddLanguageModal.tsx | 4 +- .../components/SetQualityProfileModal.tsx | 4 +- .../__snapshots__/AddLanguageModal-test.tsx.snap | 23 +- .../SetQualityProfileModal-test.tsx.snap | 23 +- .../components/common/DisableableSelectOption.tsx | 13 +- .../__tests__/DisableableSelectOption-test.tsx | 2 +- .../DisableableSelectOption-test.tsx.snap | 16 +- 13 files changed, 429 insertions(+), 90 deletions(-) (limited to 'server/sonar-web/src/main') diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateApp.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateApp.tsx index 5aa28ff3d43..095bde5a1e8 100644 --- a/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateApp.tsx @@ -22,6 +22,7 @@ import { translate } from 'sonar-ui-common/helpers/l10n'; import { associateGateWithProject, dissociateGateWithProject, + fetchQualityGate, fetchQualityGates, getGateForProject, searchProjects @@ -93,12 +94,22 @@ export default class ProjectQualityGateApp extends React.PureComponent { + const { qualitygates } = await fetchQualityGates(); + return Promise.all( + qualitygates.map(async qg => { + const detailedQp = await fetchQualityGate({ id: qg.id }).catch(() => qg); + return { ...detailedQp, ...qg }; + }) + ); + }; + fetchQualityGates = async () => { const { component } = this.props; this.setState({ loading: true }); const [allQualityGates, currentQualityGate] = await Promise.all([ - fetchQualityGates().then(({ qualitygates }) => qualitygates), + this.fetchDetailedQualityGates(), getGateForProject({ project: component.key }) ]).catch(() => []); diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateAppRenderer.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateAppRenderer.tsx index f1c0f20dcaf..2da84b07ec9 100644 --- a/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/ProjectQualityGateAppRenderer.tsx @@ -19,14 +19,18 @@ */ import * as React from 'react'; import { Helmet } from 'react-helmet-async'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; import { SubmitButton } from 'sonar-ui-common/components/controls/buttons'; import HelpTooltip from 'sonar-ui-common/components/controls/HelpTooltip'; import Radio from 'sonar-ui-common/components/controls/Radio'; import Select from 'sonar-ui-common/components/controls/Select'; import { Alert } from 'sonar-ui-common/components/ui/Alert'; import { translate } from 'sonar-ui-common/helpers/l10n'; +import { isDiffMetric } from 'sonar-ui-common/helpers/measures'; import A11ySkipTarget from '../../app/components/a11y/A11ySkipTarget'; import Suggestions from '../../app/components/embed-docs-modal/Suggestions'; +import DisableableSelectOption from '../../components/common/DisableableSelectOption'; import BuiltInQualityGateBadge from '../quality-gates/components/BuiltInQualityGateBadge'; import { USE_SYSTEM_DEFAULT } from './constants'; @@ -40,6 +44,10 @@ export interface ProjectQualityGateAppRendererProps { submitting: boolean; } +function hasConditionOnNewCode(qualityGate: T.QualityGate): boolean { + return !!qualityGate.conditions?.some(condition => isDiffMetric(condition.metric)); +} + export default function ProjectQualityGateAppRenderer(props: ProjectQualityGateAppRendererProps) { const { allQualityGates, currentQualityGate, loading, selectedQualityGateId, submitting } = props; const defaultQualityGate = allQualityGates?.find(g => g.isDefault); @@ -63,7 +71,10 @@ export default function ProjectQualityGateAppRenderer(props: ProjectQualityGateA defaultQualityGate.id !== currentQualityGate.id : selectedQualityGateId !== currentQualityGate.id; + const selectedQualityGate = allQualityGates.find(qg => qg.id === selectedQualityGateId); + const options = allQualityGates.map(g => ({ + disabled: g.conditions === undefined || g.conditions.length === 0, label: g.name, value: g.id })); @@ -141,15 +152,49 @@ export default function ProjectQualityGateAppRenderer(props: ProjectQualityGateA disabled={submitting || usesDefault} onChange={({ value }: { value: string }) => props.onSelect(value)} options={options} - optionRenderer={option => {option.label}} + optionRenderer={option => ( + ( + + {translate('project_quality_gate.no_condition.link')} + + ) + }} + /> + )} + /> + )} value={selectedQualityGateId} /> + {selectedQualityGate && !hasConditionOnNewCode(selectedQualityGate) && ( + + + {translate('project_quality_gate.no_condition.link')} + + ) + }} + /> + + )} {needsReanalysis && ( - + {translate('project_quality_gate.requires_new_analysis')} )} diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateApp-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateApp-test.tsx index 81c27e29df6..d1643b8aba5 100644 --- a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateApp-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateApp-test.tsx @@ -35,18 +35,29 @@ import ProjectQualityGateApp from '../ProjectQualityGateApp'; jest.mock('../../../api/quality-gates', () => { const { mockQualityGate } = jest.requireActual('../../../helpers/mocks/quality-gates'); - - const gate1 = mockQualityGate(); - const gate2 = mockQualityGate({ id: '2', isBuiltIn: true }); - const gate3 = mockQualityGate({ id: '3', isDefault: true }); + const { mockCondition } = jest.requireActual('../../../helpers/testMocks'); + + const conditions = [mockCondition(), mockCondition({ metric: 'new_bugs' })]; + const gates = { + gate1: mockQualityGate({ id: 'gate1' }), + gate2: mockQualityGate({ id: 'gate2', isBuiltIn: true }), + gate3: mockQualityGate({ id: 'gate3', isDefault: true }), + gate4: mockQualityGate({ id: 'gate4' }) + }; return { associateGateWithProject: jest.fn().mockResolvedValue(null), dissociateGateWithProject: jest.fn().mockResolvedValue(null), fetchQualityGates: jest.fn().mockResolvedValue({ - qualitygates: [gate1, gate2, gate3] + qualitygates: Object.values(gates) + }), + fetchQualityGate: jest.fn().mockImplementation((qg: { id: keyof typeof gates }) => { + if (qg.id === 'gate4') { + return Promise.reject(); + } + return Promise.resolve({ conditions, ...gates[qg.id] }); }), - getGateForProject: jest.fn().mockResolvedValue(gate2), + getGateForProject: jest.fn().mockResolvedValue(gates.gate2), searchProjects: jest.fn().mockResolvedValue({ results: [] }) }; }); @@ -61,8 +72,10 @@ jest.mock('../../../app/utils/handleRequiredAuthorization', () => ({ beforeEach(jest.clearAllMocks); -it('renders correctly', () => { - expect(shallowRender()).toMatchSnapshot(); +it('renders correctly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); }); it('correctly checks user permissions', () => { @@ -77,27 +90,27 @@ it('correctly loads Quality Gate data', async () => { expect(fetchQualityGates).toBeCalled(); expect(getGateForProject).toBeCalledWith({ project: 'foo' }); - expect(wrapper.state().allQualityGates).toHaveLength(3); - expect(wrapper.state().currentQualityGate?.id).toBe('2'); - expect(wrapper.state().selectedQualityGateId).toBe('2'); + expect(wrapper.state().allQualityGates).toHaveLength(4); + expect(wrapper.state().currentQualityGate?.id).toBe('gate2'); + expect(wrapper.state().selectedQualityGateId).toBe('gate2'); }); it('correctly fallbacks to the default Quality Gate', async () => { (getGateForProject as jest.Mock).mockResolvedValueOnce( - mockQualityGate({ id: '3', isDefault: true }) + mockQualityGate({ id: 'gate3', isDefault: true }) ); const wrapper = shallowRender(); await waitAndUpdate(wrapper); expect(searchProjects).toBeCalled(); - expect(wrapper.state().currentQualityGate?.id).toBe('3'); + expect(wrapper.state().currentQualityGate?.id).toBe('gate3'); expect(wrapper.state().selectedQualityGateId).toBe(USE_SYSTEM_DEFAULT); }); it('correctly detects if the default Quality Gate was explicitly selected', async () => { (getGateForProject as jest.Mock).mockResolvedValueOnce( - mockQualityGate({ id: '3', isDefault: true }) + mockQualityGate({ id: 'gate3', isDefault: true }) ); (searchProjects as jest.Mock).mockResolvedValueOnce({ results: [{ key: 'foo', selected: true }] @@ -107,19 +120,19 @@ it('correctly detects if the default Quality Gate was explicitly selected', asyn expect(searchProjects).toBeCalled(); - expect(wrapper.state().currentQualityGate?.id).toBe('3'); - expect(wrapper.state().selectedQualityGateId).toBe('3'); + expect(wrapper.state().currentQualityGate?.id).toBe('gate3'); + expect(wrapper.state().selectedQualityGateId).toBe('gate3'); }); it('correctly associates a selected Quality Gate', async () => { const wrapper = shallowRender(); await waitAndUpdate(wrapper); - wrapper.instance().handleSelect('3'); + wrapper.instance().handleSelect('gate3'); wrapper.instance().handleSubmit(); expect(associateGateWithProject).toHaveBeenCalledWith({ - gateId: '3', + gateId: 'gate3', projectKey: 'foo' }); }); @@ -129,13 +142,13 @@ it('correctly associates a project with the system default Quality Gate', async await waitAndUpdate(wrapper); wrapper.setState({ - currentQualityGate: mockQualityGate({ id: '1' }), + currentQualityGate: mockQualityGate({ id: 'gate1' }), selectedQualityGateId: USE_SYSTEM_DEFAULT }); wrapper.instance().handleSubmit(); expect(dissociateGateWithProject).toHaveBeenCalledWith({ - gateId: '1', + gateId: 'gate1', projectKey: 'foo' }); }); diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateAppRenderer-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateAppRenderer-test.tsx index cc1b3a36896..19691ab2088 100644 --- a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateAppRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/ProjectQualityGateAppRenderer-test.tsx @@ -23,6 +23,8 @@ import Radio from 'sonar-ui-common/components/controls/Radio'; import Select from 'sonar-ui-common/components/controls/Select'; import { submit } from 'sonar-ui-common/helpers/testUtils'; import { mockQualityGate } from '../../../helpers/mocks/quality-gates'; +import { mockCondition } from '../../../helpers/testMocks'; +import { MetricKey } from '../../../types/metrics'; import { USE_SYSTEM_DEFAULT } from '../constants'; import ProjectQualityGateAppRenderer, { ProjectQualityGateAppRendererProps @@ -38,6 +40,7 @@ it('should render correctly', () => { selectedQualityGateId: USE_SYSTEM_DEFAULT }) ).toMatchSnapshot('always use system default'); + expect(shallowRender({ selectedQualityGateId: '3' })).toMatchSnapshot('show new code warning'); expect( shallowRender({ selectedQualityGateId: '5' @@ -96,9 +99,15 @@ it('should correctly handle form submission', () => { }); function shallowRender(props: Partial = {}) { + const conditions = [mockCondition(), mockCondition({ metric: MetricKey.new_bugs })]; + const conditionsEmptyOnNew = [mockCondition({ metric: MetricKey.bugs })]; return shallow( `; diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateAppRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateAppRenderer-test.tsx.snap index 26e95c11361..154841d6739 100644 --- a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateAppRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/ProjectQualityGateAppRenderer-test.tsx.snap @@ -116,13 +116,20 @@ exports[`should render correctly: always use system default 1`] = ` options={ Array [ Object { + "disabled": false, "label": "qualitygate", "value": "1", }, Object { + "disabled": false, "label": "qualitygate", "value": "2", }, + Object { + "disabled": false, + "label": "qualitygate", + "value": "3", + }, ] } value="-1" @@ -259,13 +266,20 @@ exports[`should render correctly: default 1`] = ` options={ Array [ Object { + "disabled": false, "label": "qualitygate", "value": "1", }, Object { + "disabled": false, "label": "qualitygate", "value": "2", }, + Object { + "disabled": false, + "label": "qualitygate", + "value": "3", + }, ] } value="1" @@ -292,6 +306,186 @@ exports[`should render correctly: loading 1`] = ` /> `; +exports[`should render correctly: show new code warning 1`] = ` +
+ + + +
+
+

+ project_quality_gate.page +

+ + quality_gates.projects.help +
+ } + /> +
+ +
+

+ project_quality_gate.subtitle +

+
+

+ project_quality_gate.page.description +

+
+ +
+
+ project_quality_gate.always_use_default +
+
+ + current_noun + : + + qualitygate +
+
+
+
+
+ +
+
+ project_quality_gate.always_use_specific +
+
+