Browse Source

SONAR-21178 Improve quality gates page accessiblity

tags/10.4.0.87286
Mathieu Suen 4 months ago
parent
commit
d38fbd9b69

+ 2
- 5
server/sonar-web/src/main/js/apps/overview/components/QualityGateCondition.tsx View File

@@ -30,6 +30,7 @@ import {
import { getBranchLikeQuery } from '../../../helpers/branch-like';
import { translate } from '../../../helpers/l10n';
import { formatMeasure, isDiffMetric, localizeMetric } from '../../../helpers/measures';
import { getOperatorLabel } from '../../../helpers/qualityGates';
import {
getComponentDrilldownUrl,
getComponentIssuesUrl,
@@ -155,11 +156,7 @@ export default class QualityGateCondition extends React.PureComponent<Props> {
const threshold = (condition.level === 'ERROR' ? condition.error : condition.warning) as string;
const actual = (condition.period ? measure.period?.value : measure.value) as string;

let operator = translate('quality_gates.operator', condition.op);

if (metric.type === MetricType.Rating) {
operator = translate('quality_gates.operator', condition.op, 'rating');
}
const operator = getOperatorLabel(condition.op, metric);

return this.wrapWithLink(
<div className="sw-flex sw-items-center sw-p-2">

server/sonar-web/src/main/js/apps/quality-gates/components/ConditionModal.tsx → server/sonar-web/src/main/js/apps/quality-gates/components/AddConditionModal.tsx View File

@@ -21,10 +21,7 @@ import { ButtonPrimary, FormField, Modal, RadioButton } from 'design-system';
import * as React from 'react';
import { getLocalizedMetricName, translate } from '../../../helpers/l10n';
import { isDiffMetric } from '../../../helpers/measures';
import {
useCreateConditionMutation,
useUpdateConditionMutation,
} from '../../../queries/quality-gates';
import { useCreateConditionMutation } from '../../../queries/quality-gates';
import { Condition, Metric, QualityGate } from '../../../types/types';
import { getPossibleOperators } from '../utils';
import ConditionOperator from './ConditionOperator';
@@ -32,32 +29,19 @@ import MetricSelect from './MetricSelect';
import ThresholdInput from './ThresholdInput';

interface Props {
condition?: Condition;
metric?: Metric;
metrics?: Metric[];
header: string;
metrics: Metric[];
onClose: () => void;
qualityGate: QualityGate;
}

const ADD_CONDITION_MODAL_ID = 'add-condition-modal';

export default function ConditionModal({
condition,
metric,
metrics,
header,
onClose,
qualityGate,
}: Readonly<Props>) {
const [errorThreshold, setErrorThreshold] = React.useState(condition ? condition.error : '');
export default function AddConditionModal({ metrics, onClose, qualityGate }: Readonly<Props>) {
const [errorThreshold, setErrorThreshold] = React.useState('');
const [scope, setScope] = React.useState<'new' | 'overall'>('new');
const [selectedMetric, setSelectedMetric] = React.useState<Metric | undefined>(metric);
const [selectedOperator, setSelectedOperator] = React.useState<string | undefined>(
condition ? condition.op : undefined,
);
const [selectedMetric, setSelectedMetric] = React.useState<Metric | undefined>();
const [selectedOperator, setSelectedOperator] = React.useState<string | undefined>();
const { mutateAsync: createCondition } = useCreateConditionMutation(qualityGate.name);
const { mutateAsync: updateCondition } = useUpdateConditionMutation(qualityGate.name);

const getSinglePossibleOperator = (metric: Metric) => {
const operators = getPossibleOperators(metric);
@@ -73,10 +57,7 @@ export default function ConditionModal({
op: getSinglePossibleOperator(selectedMetric) ?? selectedOperator,
error: errorThreshold,
};
const submitPromise = condition
? updateCondition({ id: condition.id, ...newCondition })
: createCondition(newCondition);
await submitPromise;
await createCondition(newCondition);
onClose();
}
};
@@ -84,7 +65,7 @@ export default function ConditionModal({
const handleScopeChange = (scope: 'new' | 'overall') => {
let correspondingMetric;

if (selectedMetric && metrics) {
if (selectedMetric) {
const correspondingMetricKey =
scope === 'new' ? `new_${selectedMetric.key}` : selectedMetric.key.replace(/^new_/, '');
correspondingMetric = metrics.find((m) => m.key === correspondingMetricKey);
@@ -110,42 +91,34 @@ export default function ConditionModal({

const renderBody = () => {
return (
<form id={ADD_CONDITION_MODAL_ID} onSubmit={handleFormSubmit}>
{metric === undefined && (
<FormField label={translate('quality_gates.conditions.where')}>
<div className="sw-flex sw-gap-4">
<RadioButton checked={scope === 'new'} onCheck={handleScopeChange} value="new">
<span data-test="quality-gates__condition-scope-new">
{translate('quality_gates.conditions.new_code')}
</span>
</RadioButton>
<RadioButton
checked={scope === 'overall'}
onCheck={handleScopeChange}
value="overall"
>
<span data-test="quality-gates__condition-scope-overall">
{translate('quality_gates.conditions.overall_code')}
</span>
</RadioButton>
</div>
</FormField>
)}
<form onSubmit={handleFormSubmit} id={ADD_CONDITION_MODAL_ID}>
<FormField label={translate('quality_gates.conditions.where')}>
<div className="sw-flex sw-gap-4">
<RadioButton checked={scope === 'new'} onCheck={handleScopeChange} value="new">
<span data-test="quality-gates__condition-scope-new">
{translate('quality_gates.conditions.new_code')}
</span>
</RadioButton>
<RadioButton checked={scope === 'overall'} onCheck={handleScopeChange} value="overall">
<span data-test="quality-gates__condition-scope-overall">
{translate('quality_gates.conditions.overall_code')}
</span>
</RadioButton>
</div>
</FormField>

<FormField
description={metric && getLocalizedMetricName(metric)}
description={selectedMetric && getLocalizedMetricName(selectedMetric)}
htmlFor="condition-metric"
label={translate('quality_gates.conditions.fails_when')}
>
{metrics && (
<MetricSelect
metric={selectedMetric}
metricsArray={metrics.filter((m) =>
scope === 'new' ? isDiffMetric(m.key) : !isDiffMetric(m.key),
)}
onMetricChange={handleMetricChange}
/>
)}
<MetricSelect
metric={selectedMetric}
metricsArray={metrics.filter((m) =>
scope === 'new' ? isDiffMetric(m.key) : !isDiffMetric(m.key),
)}
onMetricChange={handleMetricChange}
/>
</FormField>

{selectedMetric && (
@@ -182,7 +155,7 @@ export default function ConditionModal({
<Modal
isScrollable={false}
isOverflowVisible
headerTitle={header}
headerTitle={translate('quality_gates.add_condition')}
onClose={onClose}
body={renderBody()}
primaryButton={
@@ -193,7 +166,7 @@ export default function ConditionModal({
form={ADD_CONDITION_MODAL_ID}
type="submit"
>
{header}
{translate('quality_gates.add_condition')}
</ButtonPrimary>
}
secondaryButtonLabel={translate('close')}

+ 5
- 11
server/sonar-web/src/main/js/apps/quality-gates/components/Condition.tsx View File

@@ -33,12 +33,12 @@ import {
import * as React from 'react';
import { useMetrics } from '../../../app/components/metrics/withMetricsContext';
import { getLocalizedMetricName, translate, translateWithParameters } from '../../../helpers/l10n';
import { getOperatorLabel } from '../../../helpers/qualityGates';
import { useDeleteConditionMutation } from '../../../queries/quality-gates';
import { MetricType } from '../../../types/metrics';
import { CaycStatus, Condition as ConditionType, Metric, QualityGate } from '../../../types/types';
import { getLocalizedMetricNameNoDiffMetric, isConditionWithFixedValue } from '../utils';
import ConditionModal from './ConditionModal';
import ConditionValue from './ConditionValue';
import EditConditionModal from './EditConditionModal';

export enum ConditionChange {
Added = 'added',
@@ -67,6 +67,7 @@ export default function ConditionComponent({
const [modal, setModal] = React.useState(false);
const { mutateAsync: deleteCondition } = useDeleteConditionMutation(qualityGate.name);
const metrics = useMetrics();
const { op = 'GT' } = condition;

const handleOpenUpdate = () => {
setModal(true);
@@ -84,13 +85,6 @@ export default function ConditionComponent({
setDeleteFormOpen(false);
};

const renderOperator = () => {
const { op = 'GT' } = condition;
return metric.type === MetricType.Rating
? translate('quality_gates.operator', op, 'rating')
: translate('quality_gates.operator', op);
};

const isCaycCompliantAndOverCompliant = qualityGate.caycStatus !== CaycStatus.NonCompliant;

return (
@@ -100,7 +94,7 @@ export default function ConditionComponent({
{metric.hidden && <TextError className="sw-ml-1" text={translate('deprecated')} />}
</ContentCell>

<ContentCell className="sw-whitespace-nowrap">{renderOperator()}</ContentCell>
<ContentCell className="sw-whitespace-nowrap">{getOperatorLabel(op, metric)}</ContentCell>

<NumericalCell className="sw-whitespace-nowrap">
<ConditionValue
@@ -126,7 +120,7 @@ export default function ConditionComponent({
size="small"
/>
{modal && (
<ConditionModal
<EditConditionModal
condition={condition}
header={translate('quality_gates.update_condition')}
metric={metric}

+ 3
- 9
server/sonar-web/src/main/js/apps/quality-gates/components/ConditionOperator.tsx View File

@@ -19,7 +19,7 @@
*/
import { InputSelect, Note } from 'design-system';
import * as React from 'react';
import { translate } from '../../../helpers/l10n';
import { getOperatorLabel } from '../../../helpers/qualityGates';
import { Metric } from '../../../types/types';
import { getPossibleOperators } from '../utils';

@@ -34,18 +34,12 @@ export default class ConditionOperator extends React.PureComponent<Props> {
this.props.onOperatorChange(value);
};

getLabel(op: string, metric: Metric) {
return metric.type === 'RATING'
? translate('quality_gates.operator', op, 'rating')
: translate('quality_gates.operator', op);
}

render() {
const operators = getPossibleOperators(this.props.metric);

if (Array.isArray(operators)) {
const operatorOptions = operators.map((op) => {
const label = this.getLabel(op, this.props.metric);
const label = getOperatorLabel(op, this.props.metric);
return { label, value: op };
});

@@ -64,6 +58,6 @@ export default class ConditionOperator extends React.PureComponent<Props> {
);
}

return <Note className="sw-w-abs-150">{this.getLabel(operators, this.props.metric)}</Note>;
return <Note className="sw-w-abs-150">{getOperatorLabel(operators, this.props.metric)}</Note>;
}
}

+ 2
- 7
server/sonar-web/src/main/js/apps/quality-gates/components/Conditions.tsx View File

@@ -42,11 +42,11 @@ import { Feature } from '../../../types/features';
import { MetricKey } from '../../../types/metrics';
import { CaycStatus, Condition as ConditionType, QualityGate } from '../../../types/types';
import { groupAndSortByPriorityConditions, isQualityGateOptimized } from '../utils';
import AddConditionModal from './AddConditionModal';
import CaYCConditionsSimplificationGuide from './CaYCConditionsSimplificationGuide';
import CaycCompliantBanner from './CaycCompliantBanner';
import CaycCondition from './CaycCondition';
import CaycFixOptimizeBanner from './CaycFixOptimizeBanner';
import ConditionModal from './ConditionModal';
import CaycReviewUpdateConditionsModal from './ConditionReviewAndUpdateModal';
import ConditionsTable from './ConditionsTable';

@@ -110,12 +110,7 @@ export default function Conditions({ qualityGate, isFetching }: Readonly<Props>)
(metric, condition) => metric.key === condition.metric,
);
return (
<ConditionModal
header={translate('quality_gates.add_condition')}
metrics={availableMetrics}
onClose={onClose}
qualityGate={qualityGate}
/>
<AddConditionModal metrics={availableMetrics} onClose={onClose} qualityGate={qualityGate} />
);
},
[metrics, qualityGate],

+ 131
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/EditConditionModal.tsx View File

@@ -0,0 +1,131 @@
/*
* SonarQube
* Copyright (C) 2009-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ButtonPrimary, FormField, Highlight, Modal, Note } from 'design-system';
import { isArray } from 'lodash';
import * as React from 'react';
import { getLocalizedMetricName, translate } from '../../../helpers/l10n';
import { useUpdateConditionMutation } from '../../../queries/quality-gates';
import { Condition, Metric, QualityGate } from '../../../types/types';
import { getPossibleOperators } from '../utils';
import ConditionOperator from './ConditionOperator';
import ThresholdInput from './ThresholdInput';

interface Props {
condition: Condition;
metric: Metric;
header: string;
onClose: () => void;
qualityGate: QualityGate;
}

const EDIT_CONDITION_MODAL_ID = 'edit-condition-modal';

export default function EditConditionModal({
condition,
metric,
onClose,
qualityGate,
}: Readonly<Props>) {
const [errorThreshold, setErrorThreshold] = React.useState(condition ? condition.error : '');

const [selectedOperator, setSelectedOperator] = React.useState<string | undefined>(
condition ? condition.op : undefined,
);
const { mutateAsync: updateCondition } = useUpdateConditionMutation(qualityGate.name);

const getSinglePossibleOperator = (metric: Metric) => {
const operators = getPossibleOperators(metric);
return isArray(operators) ? selectedOperator : operators;
};

const handleFormSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

const newCondition: Omit<Condition, 'id'> = {
metric: metric.key,
op: getSinglePossibleOperator(metric),
error: errorThreshold,
};
await updateCondition({ id: condition.id, ...newCondition });
onClose();
};

const handleErrorChange = (error: string) => {
setErrorThreshold(error);
};

const handleOperatorChange = (op: string) => {
setSelectedOperator(op);
};

const renderBody = () => {
return (
<form onSubmit={handleFormSubmit} id={EDIT_CONDITION_MODAL_ID}>
<span className="sw-flex sw-flex-col sw-w-full sw-mb-6" aria-hidden="true">
<Highlight className="sw-mb-2 sw-flex sw-items-center sw-gap-2">
<span>{translate('quality_gates.conditions.fails_when')}</span>
</Highlight>
<Note className="sw-mt-2">{getLocalizedMetricName(metric)}</Note>
</span>

<div className="sw-flex sw-gap-2">
<FormField
className="sw-mb-0"
htmlFor="condition-operator"
label={translate('quality_gates.conditions.operator')}
>
<ConditionOperator
metric={metric}
onOperatorChange={handleOperatorChange}
op={selectedOperator}
/>
</FormField>
<FormField
htmlFor="condition-threshold"
label={translate('quality_gates.conditions.value')}
>
<ThresholdInput
metric={metric}
name="error"
onChange={handleErrorChange}
value={errorThreshold}
/>
</FormField>
</div>
</form>
);
};

return (
<Modal
isScrollable={false}
isOverflowVisible
headerTitle={translate('quality_gates.update_condition')}
onClose={onClose}
body={renderBody()}
primaryButton={
<ButtonPrimary form={EDIT_CONDITION_MODAL_ID} type="submit">
{translate('quality_gates.update_condition')}
</ButtonPrimary>
}
secondaryButtonLabel={translate('close')}
/>
);
}

+ 30
- 27
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGate-it.tsx View File

@@ -26,7 +26,7 @@ import { searchProjects, searchUsers } from '../../../../api/quality-gates';
import { dismissNotice } from '../../../../api/users';
import { mockLoggedInUser } from '../../../../helpers/testMocks';
import { RenderContext, renderAppRoutes } from '../../../../helpers/testReactTestingUtils';
import { byRole } from '../../../../helpers/testSelector';
import { byRole, byTestId } from '../../../../helpers/testSelector';
import { Feature } from '../../../../types/features';
import { CaycStatus } from '../../../../types/types';
import { NoticeType } from '../../../../types/users';
@@ -218,49 +218,52 @@ it('should be able to add a condition', async () => {
// On new code
await user.click(await screen.findByText('quality_gates.add_condition'));

let dialog = within(screen.getByRole('dialog'));
const dialog = byRole('dialog');

await user.click(dialog.getByRole('radio', { name: 'quality_gates.conditions.new_code' }));
await selectEvent.select(dialog.getByRole('combobox'), ['Issues']);
await user.click(dialog.getByRole('textbox', { name: 'quality_gates.conditions.value' }));
await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.new_code' }).get());

await selectEvent.select(dialog.byRole('combobox').get(), 'Issues');
await user.click(
await dialog.byRole('textbox', { name: 'quality_gates.conditions.value' }).find(),
);
await user.keyboard('12');
await user.click(dialog.getByRole('button', { name: 'quality_gates.add_condition' }));
const newConditions = within(await screen.findByTestId('quality-gates__conditions-new'));
expect(await newConditions.findByRole('cell', { name: 'Issues' })).toBeInTheDocument();
expect(await newConditions.findByRole('cell', { name: '12' })).toBeInTheDocument();
await user.click(dialog.byRole('button', { name: 'quality_gates.add_condition' }).get());
const newConditions = byTestId('quality-gates__conditions-new');
expect(await newConditions.byRole('cell', { name: 'Issues' }).find()).toBeInTheDocument();
expect(await newConditions.byRole('cell', { name: '12' }).find()).toBeInTheDocument();

// On overall code
await user.click(await screen.findByText('quality_gates.add_condition'));

dialog = within(screen.getByRole('dialog'));
await selectEvent.select(dialog.getByRole('combobox'), ['Info Issues']);
await user.click(dialog.getByRole('radio', { name: 'quality_gates.conditions.overall_code' }));
await user.click(dialog.getByLabelText('quality_gates.conditions.operator'));
await selectEvent.select(dialog.byRole('combobox').get(), ['Info Issues']);
await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.overall_code' }).get());
await user.click(dialog.byLabelText('quality_gates.conditions.operator').get());

await user.click(dialog.getByText('quality_gates.operator.LT'));
await user.click(dialog.getByRole('textbox', { name: 'quality_gates.conditions.value' }));
await user.click(dialog.byText('quality_gates.operator.LT').get());
await user.click(dialog.byRole('textbox', { name: 'quality_gates.conditions.value' }).get());
await user.keyboard('42');
await user.click(dialog.getByRole('button', { name: 'quality_gates.add_condition' }));
await user.click(dialog.byRole('button', { name: 'quality_gates.add_condition' }).get());

const overallConditions = within(await screen.findByTestId('quality-gates__conditions-overall'));
const overallConditions = byTestId('quality-gates__conditions-overall');

expect(await overallConditions.findByRole('cell', { name: 'Info Issues' })).toBeInTheDocument();
expect(await overallConditions.findByRole('cell', { name: '42' })).toBeInTheDocument();
expect(
await overallConditions.byRole('cell', { name: 'Info Issues' }).find(),
).toBeInTheDocument();
expect(await overallConditions.byRole('cell', { name: '42' }).find()).toBeInTheDocument();

// Select a rating
await user.click(await screen.findByText('quality_gates.add_condition'));

dialog = within(screen.getByRole('dialog'));
await user.click(dialog.getByRole('radio', { name: 'quality_gates.conditions.overall_code' }));
await selectEvent.select(dialog.getByRole('combobox'), ['Maintainability Rating']);
await user.click(dialog.getByLabelText('quality_gates.conditions.value'));
await user.click(dialog.getByText('B'));
await user.click(dialog.getByRole('button', { name: 'quality_gates.add_condition' }));
await user.click(dialog.byRole('radio', { name: 'quality_gates.conditions.overall_code' }).get());
await selectEvent.select(dialog.byRole('combobox').get(), ['Maintainability Rating']);
await user.click(dialog.byLabelText('quality_gates.conditions.value').get());
await user.click(dialog.byText('B').get());
await user.click(dialog.byRole('button', { name: 'quality_gates.add_condition' }).get());

expect(
await overallConditions.findByRole('cell', { name: 'Maintainability Rating' }),
await overallConditions.byRole('cell', { name: 'Maintainability Rating' }).find(),
).toBeInTheDocument();
expect(await overallConditions.findByRole('cell', { name: 'B' })).toBeInTheDocument();
expect(await overallConditions.byRole('cell', { name: 'B' }).find()).toBeInTheDocument();
});

it('should be able to edit a condition', async () => {

+ 9
- 1
server/sonar-web/src/main/js/helpers/qualityGates.ts View File

@@ -17,12 +17,20 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { MetricKey } from '../types/metrics';
import { MetricKey, MetricType } from '../types/metrics';
import {
QualityGateApplicationStatusChildProject,
QualityGateProjectStatus,
QualityGateStatusCondition,
} from '../types/quality-gates';
import { Metric } from '../types/types';
import { translate } from './l10n';

export function getOperatorLabel(op: string, metric: Metric) {
return metric.type === MetricType.Rating
? translate('quality_gates.operator', op, 'rating')
: translate('quality_gates.operator', op);
}

export function extractStatusConditionsFromProjectStatus(
projectStatus: QualityGateProjectStatus,

Loading…
Cancel
Save