From 7d7ed0de5f3e546063b81f6c38d7fd04f1d4d62a Mon Sep 17 00:00:00 2001 From: stanislavh Date: Wed, 6 Nov 2024 21:40:40 +0100 Subject: [PATCH] SONAR-23363 Implement issue severity change for both modes --- server/sonar-web/src/main/js/api/issues.ts | 14 +- .../main/js/api/mocks/IssuesServiceMock.ts | 23 +- .../src/main/js/app/utils/startReactApp.tsx | 15 +- .../apps/issues/__tests__/IssueHeader-it.tsx | 53 +++- .../js/apps/issues/components/IssueHeader.tsx | 50 +++- .../issues/components/IssueHeaderSide.tsx | 6 +- .../src/main/js/components/issue/Issue.tsx | 9 +- .../components/issue/__tests__/Issue-it.tsx | 89 ++++-- .../components/issue/components/IssueView.tsx | 255 +++++++++++------- .../js/components/shared/IssueTypePill.tsx | 91 +++++-- .../components/shared/SoftwareImpactPill.tsx | 107 ++++++-- .../shared/SoftwareImpactPillList.tsx | 12 +- .../main/js/helpers/testReactTestingUtils.tsx | 12 +- .../resources/org/sonar/l10n/core.properties | 14 +- 14 files changed, 564 insertions(+), 186 deletions(-) diff --git a/server/sonar-web/src/main/js/api/issues.ts b/server/sonar-web/src/main/js/api/issues.ts index b369019d2bd..68dc4421299 100644 --- a/server/sonar-web/src/main/js/api/issues.ts +++ b/server/sonar-web/src/main/js/api/issues.ts @@ -22,7 +22,13 @@ import { throwGlobalError } from '~sonar-aligned/helpers/error'; import { getJSON } from '~sonar-aligned/helpers/request'; import getCoverageStatus from '../components/SourceViewer/helpers/getCoverageStatus'; import { get, HttpStatus, parseJSON, post, postJSON, RequestData } from '../helpers/request'; -import { FacetName, IssueResponse, ListIssuesResponse, RawIssuesResponse } from '../types/issues'; +import { + FacetName, + IssueResponse, + IssueSeverity, + ListIssuesResponse, + RawIssuesResponse, +} from '../types/issues'; import { Dict, FacetValue, IssueChangelog, SnippetsByComponent, SourceLine } from '../types/types'; export function searchIssues(query: RequestData): Promise { @@ -99,7 +105,11 @@ export function setIssueAssignee(data: { return postJSON('/api/issues/assign', data); } -export function setIssueSeverity(data: { issue: string; severity: string }): Promise { +export function setIssueSeverity(data: { + impacts?: string; + issue: string; + severity?: IssueSeverity; +}): Promise { return postJSON('/api/issues/set_severity', data); } diff --git a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts index d6f19cfa92b..69b1c2281c9 100644 --- a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts @@ -520,8 +520,27 @@ export default class IssuesServiceMock { return this.getActionsResponse({ type: data.type }, data.issue); }; - handleSetIssueSeverity = (data: { issue: string; severity: string }) => { - return this.getActionsResponse({ severity: data.severity }, data.issue); + handleSetIssueSeverity = (data: { impacts?: string; issue: string; severity?: string }) => { + const issueDataSelected = this.list.find((l) => l.issue.key === data.issue); + + if (!issueDataSelected) { + throw new Error(`Coulnd't find issue for key ${data.issue}`); + } + + const parsedImpact = data.impacts?.split('='); + + return this.getActionsResponse( + data.impacts + ? { + impacts: issueDataSelected.issue.impacts.map((impact) => + impact.softwareQuality === parsedImpact?.[0] + ? { ...impact, severity: parsedImpact?.[1] as SoftwareImpactSeverity } + : impact, + ), + } + : { severity: data.severity }, + data.issue, + ); }; handleSetIssueAssignee = (data: { assignee?: string; issue: string }) => { diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx index e0dfffd5cdc..c2f47e62d92 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx @@ -26,6 +26,7 @@ import { createRoot } from 'react-dom/client'; import { Helmet, HelmetProvider } from 'react-helmet-async'; import { IntlShape, RawIntlProvider } from 'react-intl'; import { + Outlet, Route, RouterProvider, createBrowserRouter, @@ -190,7 +191,16 @@ const PluginRiskConsent = lazyLoadComponent(() => import('../components/PluginRi const router = createBrowserRouter( createRoutesFromElements( - <> + // Wrapper to pass toaster container under router context + // this way we can use router context in toast message, for example render links + + + + + } + > {renderRedirects()} } /> @@ -255,7 +265,7 @@ const router = createBrowserRouter( - , + , ), { basename: getBaseUrl() }, ); @@ -280,7 +290,6 @@ export default function startReactApp( - diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueHeader-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueHeader-it.tsx index 5d0fea5266f..6c4c665cffb 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueHeader-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueHeader-it.tsx @@ -18,19 +18,29 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import userEvent from '@testing-library/user-event'; import { byLabelText, byRole, byText } from '~sonar-aligned/helpers/testSelector'; +import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; import { WorkspaceContext } from '../../../components/workspace/context'; -import { mockIssue, mockRuleDetails } from '../../../helpers/testMocks'; +import { mockIssue, mockRawIssue, mockRuleDetails } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; +import { IssueActions, RawIssue } from '../../../types/issues'; import { SettingsKey } from '../../../types/settings'; import { Dict } from '../../../types/types'; import IssueHeader from '../components/IssueHeader'; +jest.mock('~design-system', () => ({ + ...jest.requireActual('~design-system'), + addGlobalSuccessMessage: jest.fn(), +})); + const settingsHandler = new SettingsServiceMock(); +const issuesHandler = new IssuesServiceMock(); beforeEach(() => { settingsHandler.reset(); + issuesHandler.reset(); }); it('renders correctly', async () => { @@ -130,10 +140,51 @@ it('renders correctly when some data is not provided', () => { expect(byText('eslint').query()).not.toBeInTheDocument(); }); +it('can update the severity in MQR mode', async () => { + const user = userEvent.setup(); + const onIssueChange = jest.fn(); + const issue = mockIssue(false, { actions: [IssueActions.SetSeverity], prioritizedRule: false }); + renderIssueHeader({ + onIssueChange, + issue, + }); + + expect(await byText(`software_quality.MAINTAINABILITY`).find()).toBeInTheDocument(); + await user.click(byText('software_quality.MAINTAINABILITY').get()); + await user.click(byText('severity_impact.BLOCKER').get()); + expect(onIssueChange).toHaveBeenCalledWith({ + ...issue, + impacts: [{ softwareQuality: 'MAINTAINABILITY', severity: 'BLOCKER' }], + }); +}); + +it('can update the severity in Standard mode', async () => { + settingsHandler.set(SettingsKey.MQRMode, 'false'); + const user = userEvent.setup(); + const onIssueChange = jest.fn(); + const issue = mockIssue(false, { actions: [IssueActions.SetSeverity], prioritizedRule: false }); + renderIssueHeader({ + onIssueChange, + issue, + }); + + expect(await byLabelText(`severity.${issue.severity}`).find()).toBeInTheDocument(); + await user.click(byLabelText(`severity.${issue.severity}`).get()); + await user.click(byLabelText('severity.BLOCKER').get()); + + expect(onIssueChange).toHaveBeenCalledWith({ + ...issue, + severity: 'BLOCKER', + }); +}); + function renderIssueHeader( props: Partial = {}, externalRules: Dict = {}, ) { + issuesHandler.setIssueList([ + { issue: mockRawIssue(false, props.issue as RawIssue), snippets: {} }, + ]); return renderComponent( { this.handleIssuePopupToggle('assign', false); }; + handleSeverityChange = ( + severity: IssueSeverity | SoftwareImpactSeverity, + quality?: SoftwareQuality, + ) => { + const { issue } = this.props; + + const data = quality + ? { issue: issue.key, impacts: `${quality}=${severity}` } + : { issue: issue.key, severity: severity as IssueSeverity }; + + const severityBefore = quality + ? issue.impacts.find((impact) => impact.softwareQuality === quality)?.severity + : issue.severity; + + return updateIssue( + this.props.onIssueChange, + setIssueSeverity(data).then((r) => { + addGlobalSuccessMessage( + , + ); + return r; + }), + ); + }; + handleKeyDown = (event: KeyboardEvent) => { if (isInput(event) || isShortcut(event) || !getKeyboardShortcutEnabled()) { return true; @@ -202,7 +237,12 @@ export default class IssueHeader extends React.PureComponent { showSonarLintBadge /> - + Promise) & + ((severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise); } -export default function IssueHeaderSide({ issue }: Readonly) { +export default function IssueHeaderSide({ issue, onSetSeverity }: Readonly) { const { data: isStandardMode, isLoading } = useStandardExperienceMode(); return ( @@ -44,6 +47,7 @@ export default function IssueHeaderSide({ issue }: Readonly) { title={isStandardMode ? translate('type') : translate('issue.software_qualities.label')} > ) { const { selected = false, issue, @@ -110,7 +109,7 @@ export default function Issue(props: Props) { [issue.actions, issue.key, togglePopup, handleAssignement, onCheck], ); - React.useEffect(() => { + useEffect(() => { if (selected) { document.addEventListener('keydown', handleKeyDown, { capture: true }); } @@ -133,3 +132,5 @@ export default function Issue(props: Props) { /> ); } + +export default memo(Issue); diff --git a/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx b/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx index 1556548d858..1e207f80caf 100644 --- a/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx +++ b/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx @@ -181,6 +181,77 @@ describe('updating', () => { await ui.addTag('android', ['accessibility']); expect(ui.updateTagsBtn(['accessibility', 'android']).get()).toBeInTheDocument(); }); + + it('should allow updating the severity in MQR mode', async () => { + const { ui, user } = getPageObject(); + const issue = mockRawIssue(false, { + impacts: [ + { softwareQuality: SoftwareQuality.Maintainability, severity: SoftwareImpactSeverity.Low }, + ], + actions: [IssueActions.SetSeverity], + }); + issuesHandler.setIssueList([{ issue, snippets: {} }]); + renderIssue({ + issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'impacts') }), + }); + expect(ui.softwareQuality(SoftwareQuality.Maintainability).get()).toBeInTheDocument(); + + await user.click(ui.softwareQuality(SoftwareQuality.Maintainability).get()); + await user.click(ui.softwareQualitySeverity(SoftwareImpactSeverity.Medium).get()); + expect(ui.softwareQualitySeverity(SoftwareImpactSeverity.Medium).get()).toBeInTheDocument(); + expect(byText(/issue.severity.updated_notification.link.mqr/).get()).toBeInTheDocument(); + }); + + it('cannot update the severity in MQR mode without permission', async () => { + const { ui, user } = getPageObject(); + const issue = mockRawIssue(false, { + impacts: [ + { softwareQuality: SoftwareQuality.Maintainability, severity: SoftwareImpactSeverity.Low }, + ], + }); + issuesHandler.setIssueList([{ issue, snippets: {} }]); + renderIssue({ + issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'impacts') }), + }); + expect(ui.softwareQuality(SoftwareQuality.Maintainability).get()).toBeInTheDocument(); + await user.click(ui.softwareQuality(SoftwareQuality.Maintainability).get()); + expect( + ui.softwareQualitySeverity(SoftwareImpactSeverity.Medium).query(), + ).not.toBeInTheDocument(); + // popover visible + expect(byRole('heading', { name: 'severity_impact.title' }).get()).toBeInTheDocument(); + }); + + it('should allow updating the severity in Standard experience', async () => { + const { ui, user } = getPageObject(); + const issue = mockRawIssue(false, { + actions: [IssueActions.SetSeverity], + }); + settingsHandler.set(SettingsKey.MQRMode, 'false'); + issuesHandler.setIssueList([{ issue, snippets: {} }]); + renderIssue({ + issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'severity') }), + }); + expect(await ui.standardSeverity(IssueSeverity.Major).find()).toBeInTheDocument(); + + await user.click(ui.standardSeverity(IssueSeverity.Major).get()); + await user.click(ui.standardSeverity(IssueSeverity.Info).get()); + expect(ui.standardSeverity(IssueSeverity.Info).get()).toBeInTheDocument(); + expect(byText(/issue.severity.updated_notification.link.standard/).get()).toBeInTheDocument(); + }); + + it('cannot update the severity in Standard mode without permission', async () => { + const { ui, user } = getPageObject(); + const issue = mockRawIssue(false); + issuesHandler.setIssueList([{ issue, snippets: {} }]); + settingsHandler.set(SettingsKey.MQRMode, 'false'); + renderIssue({ + issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'impacts') }), + }); + expect(await ui.standardSeverity(IssueSeverity.Major).find()).toBeInTheDocument(); + await user.click(ui.standardSeverity(IssueSeverity.Major).get()); + expect(ui.standardSeverity(IssueSeverity.Info).query()).not.toBeInTheDocument(); + }); }); it('should correctly handle keyboard shortcuts', async () => { @@ -280,16 +351,6 @@ function getPageObject() { commentDeleteBtn: byRole('button', { name: 'issue.comment.delete' }), commentConfirmDeleteBtn: byRole('button', { name: 'delete' }), - // Type - updateTypeBtn: (currentType: IssueType) => - byLabelText(`issue.type.type_x_click_to_change.issue.type.${currentType}`), - setTypeBtn: (type: IssueType) => byText(`issue.type.${type}`), - - // Severity - updateSeverityBtn: (currentSeverity: IssueSeverity) => - byLabelText(`issue.severity.severity_x_click_to_change.severity.${currentSeverity}`), - setSeverityBtn: (severity: IssueSeverity) => byText(`severity.${severity}`), - // Status updateStatusBtn: (currentStatus: IssueStatus) => byLabelText(`issue.transition.status_x_click_to_change.issue.issue_status.${currentStatus}`), @@ -326,14 +387,6 @@ function getPageObject() { await user.click(selectors.commentDeleteBtn.get()); await user.click(selectors.commentConfirmDeleteBtn.get()); }, - async updateType(currentType: IssueType, newType: IssueType) { - await user.click(selectors.updateTypeBtn(currentType).get()); - await user.click(selectors.setTypeBtn(newType).get()); - }, - async updateSeverity(currentSeverity: IssueSeverity, newSeverity: IssueSeverity) { - await user.click(selectors.updateSeverityBtn(currentSeverity).get()); - await user.click(selectors.setSeverityBtn(newSeverity).get()); - }, async updateStatus(currentStatus: IssueStatus, transition: IssueTransition) { await user.click(selectors.updateStatusBtn(currentStatus).get()); await user.click(selectors.setStatusBtn(transition).get()); diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx index 7331ad86e4b..7304d6f5455 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx @@ -19,13 +19,21 @@ */ import styled from '@emotion/styled'; -import { Checkbox } from '@sonarsource/echoes-react'; +import { Checkbox, Link, LinkHighlight } from '@sonarsource/echoes-react'; import classNames from 'classnames'; -import * as React from 'react'; -import { BasicSeparator, themeBorder } from '~design-system'; -import { deleteIssueComment, editIssueComment } from '../../../api/issues'; +import { useEffect, useRef } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { addGlobalSuccessMessage, BasicSeparator, themeBorder } from '~design-system'; +import { setIssueSeverity } from '../../../api/issues'; +import { useComponent } from '../../../app/components/componentContext/withComponentContext'; +import { areMyIssuesSelected, parseQuery, serializeQuery } from '../../../apps/issues/utils'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { getIssuesUrl } from '../../../helpers/urls'; +import { useLocation } from '../../../sonar-aligned/components/hoc/withRouter'; +import { getBranchLikeQuery } from '../../../sonar-aligned/helpers/branch-like'; +import { getComponentIssuesUrl } from '../../../sonar-aligned/helpers/urls'; import { BranchLike } from '../../../types/branch-like'; +import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-code-taxonomy'; import { IssueActions, IssueSeverity } from '../../../types/issues'; import { Issue } from '../../../types/types'; import SoftwareImpactPillList from '../../shared/SoftwareImpactPillList'; @@ -49,109 +57,164 @@ interface Props { togglePopup: (popup: string, show: boolean | void) => void; } -export default class IssueView extends React.PureComponent { - nodeRef: HTMLLIElement | null = null; - - componentDidMount() { - const { selected } = this.props; - if (this.nodeRef && selected) { - this.nodeRef.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); - } - } - - componentDidUpdate(prevProps: Props) { - const { selected } = this.props; - if (!prevProps.selected && selected && this.nodeRef) { - this.nodeRef.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); - } - } - - handleCheck = () => { - if (this.props.onCheck) { - this.props.onCheck(this.props.issue.key); +export default function IssueView(props: Readonly) { + const { + issue, + branchLike, + checked, + currentPopup, + displayWhyIsThisAnIssue, + onAssign, + onChange, + onSelect, + togglePopup, + selected, + onCheck, + } = props; + const intl = useIntl(); + const nodeRef = useRef(null); + const { component } = useComponent(); + const location = useLocation(); + const query = parseQuery(location.query); + + const hasCheckbox = onCheck != null; + const canSetTags = issue.actions.includes(IssueActions.SetTags); + const canSetSeverity = issue.actions.includes(IssueActions.SetSeverity); + + const handleCheck = () => { + if (onCheck) { + onCheck(issue.key); } }; - editComment = (comment: string, text: string) => { - updateIssue(this.props.onChange, editIssueComment({ comment, text })); - }; - - deleteComment = (comment: string) => { - updateIssue(this.props.onChange, deleteIssueComment({ comment })); + const setSeverity = ( + severity: IssueSeverity | SoftwareImpactSeverity, + quality?: SoftwareQuality, + ) => { + const { issue } = props; + + const data = quality + ? { issue: issue.key, impacts: `${quality}=${severity}` } + : { issue: issue.key, severity: severity as IssueSeverity }; + + const severityBefore = quality + ? issue.impacts.find((impact) => impact.softwareQuality === quality)?.severity + : issue.severity; + + const linkQuery = { + ...getBranchLikeQuery(branchLike), + ...serializeQuery(query), + myIssues: areMyIssuesSelected(location.query) ? 'true' : undefined, + open: issue.key, + }; + + return updateIssue( + onChange, + setIssueSeverity(data).then((r) => { + addGlobalSuccessMessage( + + {intl.formatMessage( + { + id: `issue.severity.updated_notification.link.${!quality ? 'standard' : 'mqr'}`, + }, + { + type: translate('issue.type', issue.type).toLowerCase(), + }, + )} + + ), + quality: quality ? translate('software_quality', quality) : undefined, + before: translate(quality ? 'severity_impact' : 'severity', severityBefore ?? ''), + after: translate(quality ? 'severity_impact' : 'severity', severity), + }} + />, + ); + + return r; + }), + ); }; - - render() { - const { issue, branchLike, checked, currentPopup, displayWhyIsThisAnIssue } = this.props; - - const hasCheckbox = this.props.onCheck != null; - const canSetTags = issue.actions.includes(IssueActions.SetTags); - - const issueClass = classNames('it__issue-item sw-p-3 sw-mb-4 sw-rounded-1 sw-bg-white', { - selected: this.props.selected, - }); - - return ( - this.props.onSelect(issue.key)} - className={issueClass} - role="region" - aria-label={issue.message} - ref={(node) => (this.nodeRef = node)} - > -
- {hasCheckbox && ( - - - - )} - -
- { + if (selected && nodeRef.current) { + nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + } + }, [selected]); + + return ( + onSelect(issue.key)} + className={classNames('it__issue-item sw-p-3 sw-mb-4 sw-rounded-1 sw-bg-white', { + selected, + })} + role="region" + aria-label={issue.message} + ref={nodeRef} + > +
+ {hasCheckbox && ( + + - -
- + )} + +
+ + +
+ +
+ -
- -
+
- + -
- - -
+
+ +
- - ); - } +
+ + ); } const IssueItem = styled.li` diff --git a/server/sonar-web/src/main/js/components/shared/IssueTypePill.tsx b/server/sonar-web/src/main/js/components/shared/IssueTypePill.tsx index 6d7265ac4aa..3cffb58f3af 100644 --- a/server/sonar-web/src/main/js/components/shared/IssueTypePill.tsx +++ b/server/sonar-web/src/main/js/components/shared/IssueTypePill.tsx @@ -18,8 +18,9 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Tooltip } from '@sonarsource/echoes-react'; +import { DropdownMenu, DropdownMenuAlign, Spinner, Tooltip } from '@sonarsource/echoes-react'; import classNames from 'classnames'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; import { Pill, PillVariant } from '~design-system'; import { IssueSeverity, IssueType } from '../../types/issues'; @@ -29,13 +30,14 @@ import SoftwareImpactSeverityIcon from '../icon-mappers/SoftwareImpactSeverityIc export interface Props { className?: string; issueType: string; + onSetSeverity?: (severity: IssueSeverity) => Promise; severity: IssueSeverity; } export default function IssueTypePill(props: Readonly) { - const { className, severity, issueType } = props; + const { className, severity, issueType, onSetSeverity } = props; const intl = useIntl(); - + const [updatingSeverity, setUpdatingSeverity] = useState(false); const variant = { [IssueSeverity.Blocker]: PillVariant.Critical, [IssueSeverity.Critical]: PillVariant.Danger, @@ -44,6 +46,67 @@ export default function IssueTypePill(props: Readonly) { [IssueSeverity.Info]: PillVariant.Info, }[severity]; + const renderPill = (notClickable = false) => ( + + + {intl.formatMessage({ id: `issue.type.${issueType}` })} + + {issueType !== IssueType.SecurityHotspot && ( + + )} + + + ); + + const handleSetSeverity = async (severity: IssueSeverity) => { + setUpdatingSeverity(true); + await onSetSeverity?.(severity); + setUpdatingSeverity(false); + }; + + if (onSetSeverity) { + return ( + ( + handleSetSeverity(severityItem)} + > +
+ + {intl.formatMessage({ id: `severity.${severityItem}` })} +
+
+ ))} + > + + {renderPill()} + +
+ ); + } + return ( ) { }, { severity: intl.formatMessage({ id: `severity.${severity}` }), - type: ( - - {intl.formatMessage({ id: `issue.type.${issueType}` })} - - ), }, ) } > - - - {intl.formatMessage({ id: `issue.type.${issueType}` })} - {issueType !== IssueType.SecurityHotspot && ( - - )} - + {renderPill(true)} ); } diff --git a/server/sonar-web/src/main/js/components/shared/SoftwareImpactPill.tsx b/server/sonar-web/src/main/js/components/shared/SoftwareImpactPill.tsx index 0c2892d202f..14e7b8f9e54 100644 --- a/server/sonar-web/src/main/js/components/shared/SoftwareImpactPill.tsx +++ b/server/sonar-web/src/main/js/components/shared/SoftwareImpactPill.tsx @@ -18,26 +18,38 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Popover } from '@sonarsource/echoes-react'; +import { + DropdownMenu, + DropdownMenuAlign, + Popover, + Spinner, + Tooltip, +} from '@sonarsource/echoes-react'; import classNames from 'classnames'; import { noop } from 'lodash'; -import { FormattedMessage } from 'react-intl'; +import { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; import { Pill, PillVariant } from '~design-system'; +import { IMPACT_SEVERITIES } from '../../helpers/constants'; import { DocLink } from '../../helpers/doc-links'; import { translate } from '../../helpers/l10n'; -import { SoftwareImpactSeverity } from '../../types/clean-code-taxonomy'; +import { SoftwareImpactSeverity, SoftwareQuality } from '../../types/clean-code-taxonomy'; import DocumentationLink from '../common/DocumentationLink'; import SoftwareImpactSeverityIcon from '../icon-mappers/SoftwareImpactSeverityIcon'; export interface Props { className?: string; - quality: string; + onSetSeverity?: (severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise; severity: SoftwareImpactSeverity; + softwareQuality: SoftwareQuality; type?: 'issue' | 'rule'; } export default function SoftwareImpactPill(props: Props) { - const { className, severity, quality, type = 'issue' } = props; + const { className, severity, softwareQuality, type = 'issue', onSetSeverity } = props; + const intl = useIntl(); + const quality = getQualityLabel(softwareQuality); + const [updatingSeverity, setUpdatingSeverity] = useState(false); const variant = { [SoftwareImpactSeverity.Blocker]: PillVariant.Critical, @@ -47,6 +59,64 @@ export default function SoftwareImpactPill(props: Props) { [SoftwareImpactSeverity.Info]: PillVariant.Info, }[severity]; + const pill = ( + + {quality} + + + + + ); + + const handleSetSeverity = async (severity: SoftwareImpactSeverity, quality: SoftwareQuality) => { + setUpdatingSeverity(true); + await onSetSeverity?.(severity, quality); + setUpdatingSeverity(false); + }; + + if (onSetSeverity && type === 'issue') { + return ( + ( + handleSetSeverity(impactSeverity, softwareQuality)} + > +
+ + {translate('severity_impact', impactSeverity)} +
+
+ ))} + > + + {pill} + +
+ ); + } + return (

{translate('severity_impact.help.line1')} - {translate('severity_impact.help.line2')} + {type === 'issue' && translate('severity_impact.help.line2')}

} @@ -71,19 +141,20 @@ export default function SoftwareImpactPill(props: Props) { } > - - {quality} - - + {pill} +
); } + +const getQualityLabel = (quality: SoftwareQuality) => translate('software_quality', quality); diff --git a/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx b/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx index 28857e63ac3..f2d8efcb08a 100644 --- a/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx +++ b/server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx @@ -35,6 +35,8 @@ interface SoftwareImpactPillListProps extends React.HTMLAttributes Promise) & + ((severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise); softwareImpacts: SoftwareImpact[]; type?: Parameters[0]['type']; } @@ -49,6 +51,7 @@ const severityMap = { export default function SoftwareImpactPillList({ softwareImpacts, + onSetSeverity, issueSeverity, issueType, type, @@ -73,8 +76,9 @@ export default function SoftwareImpactPillList({ .map(({ severity, softwareQuality }) => (
  • @@ -83,7 +87,11 @@ export default function SoftwareImpactPillList({ )} {isStandardMode && issueType && issueSeverity && ( - + )} ); diff --git a/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx index d3044078c47..e1ae1ff0c0a 100644 --- a/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx +++ b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx @@ -221,10 +221,17 @@ function renderRoutedApp( const router = createMemoryRouter( createRoutesFromElements( - <> + + + + + } + > {children} } /> - , + , ), { initialEntries: [path] }, ); @@ -239,7 +246,6 @@ function renderRoutedApp( - 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 8c9d4bb4903..13e2be23ea5 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -962,8 +962,9 @@ issue.rule_details=Rule Details issue.send_notifications=Send Notifications issue.why_this_issue=Why is this an issue? issue.why_this_issue.long=Why is this an issue? Open the rule's details at the bottom of the page. -issue.type.type_x_click_to_change=Type: {0}, click to change -issue.severity.severity_x_click_to_change=Severity: {0}, click to change +issue.severity.updated_notification.link.standard=This {type}'s +issue.severity.updated_notification.link.mqr=This issues's +issue.severity.updated_notification={issueLink, select, other { {issueLink} }} {quality, select, other { {quality} }} severity was changed from {before} to {after} issue.change_status=Change Status issue.transition.community_plug_link=SonarSource Community issue.transition.status_x_click_to_change=Issue status: {0}, click to change @@ -1021,8 +1022,8 @@ issue.type.SECURITY_HOTSPOT.plural=Security Hotspots issue.type.CODE_SMELL.plural=Code Smells issue.type.BUG.plural=Bugs issue.type.VULNERABILITY.plural=Vulnerabilities -issue.type.tooltip={severity} severity {type} - +issue.type.tooltip={severity} severity. +issue.type.tooltip_with_change={severity} severity. Click to change. issue.type.deprecation.title=Issue types are deprecated and can no longer be modified. issue.type.deprecation.filter_by=You can now filter issues by: issue.type.deprecation.documentation=Documentation @@ -3110,8 +3111,7 @@ severity_impact.LOW.description=Potential for moderate to minor impact. severity_impact.INFO=Info severity_impact.INFO.description=Neither a bug nor a quality flaw. Just a finding. severity_impact.help.line1=Severities are now directly tied to the software quality impacted. This means that one software quality impacted has one severity. -severity_impact.help.line2=There are three levels of severity: high, medium, and low. - +severity_impact.help.line2=They can be changed with sufficient permissions. #------------------------------------------------------------------------------ # @@ -5887,7 +5887,7 @@ guiding.issue_list.2.content.1=A software quality is a characteristic of softwar guiding.issue_list.2.content.2=You can now filter by these qualities to evaluate the areas in your software that are impacted by the introduction of code that isn't clean. guiding.issue_list.3.title=Severity and Software Qualities guiding.issue_list.3.content.1=Severities are now directly tied to the software quality impacted. This means that one software quality impacted has one severity. -guiding.issue_list.3.content.2=There are only 3 levels: high, medium, and low. +guiding.issue_list.3.content.2=There are five levels: blocker, high, medium, low and info. guiding.issue_list.4.title=Type and old severity deprecated guiding.issue_list.4.content.1=Issue types and the old severities are deprecated and can no longer be modified. guiding.issue_list.4.content.2=You can now filter issues by: -- 2.39.5