]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23363 Implement issue severity change for both modes
authorstanislavh <stanislav.honcharov@sonarsource.com>
Wed, 6 Nov 2024 20:40:40 +0000 (21:40 +0100)
committersonartech <sonartech@sonarsource.com>
Mon, 11 Nov 2024 20:02:44 +0000 (20:02 +0000)
14 files changed:
server/sonar-web/src/main/js/api/issues.ts
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/issues/__tests__/IssueHeader-it.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueHeaderSide.tsx
server/sonar-web/src/main/js/components/issue/Issue.tsx
server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx
server/sonar-web/src/main/js/components/issue/components/IssueView.tsx
server/sonar-web/src/main/js/components/shared/IssueTypePill.tsx
server/sonar-web/src/main/js/components/shared/SoftwareImpactPill.tsx
server/sonar-web/src/main/js/components/shared/SoftwareImpactPillList.tsx
server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index b369019d2bdb75b5d9ae8c1a458979379a650f1b..68dc4421299435f77427cb632d12a706c2f39eb1 100644 (file)
@@ -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<RawIssuesResponse> {
@@ -99,7 +105,11 @@ export function setIssueAssignee(data: {
   return postJSON('/api/issues/assign', data);
 }
 
-export function setIssueSeverity(data: { issue: string; severity: string }): Promise<any> {
+export function setIssueSeverity(data: {
+  impacts?: string;
+  issue: string;
+  severity?: IssueSeverity;
+}): Promise<IssueResponse> {
   return postJSON('/api/issues/set_severity', data);
 }
 
index d6f19cfa92b6208813a7722b1ac43fe7f39f208d..69b1c2281c9d57b2656077ba0066061e11df8874 100644 (file)
@@ -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 }) => {
index e0dfffd5cdc026f2e0f0d0ffb6f25431c07bf81c..c2f47e62d92cc55eb4145c2cdfd58f12d976ff48 100644 (file)
@@ -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
+    <Route
+      element={
+        <>
+          <ToastMessageContainer />
+          <Outlet />
+        </>
+      }
+    >
       {renderRedirects()}
 
       <Route path="formatting/help" element={<FormattingHelp />} />
@@ -255,7 +265,7 @@ const router = createBrowserRouter(
           </Route>
         </Route>
       </Route>
-    </>,
+    </Route>,
   ),
   { basename: getBaseUrl() },
 );
@@ -280,7 +290,6 @@ export default function startReactApp(
               <ThemeProvider theme={lightTheme}>
                 <QueryClientProvider client={queryClient}>
                   <GlobalStyles />
-                  <ToastMessageContainer />
                   <Helmet titleTemplate={translate('page_title.template.default')} />
                   <StackContext>
                     <EchoesProvider>
index 5d0fea5266f7eff859b69c40d8fc179f4c93133c..6c4c665cffbdc4695c3566328c8df8423b0f9918 100644 (file)
  * 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<IssueHeader['props']> = {},
   externalRules: Dict<string> = {},
 ) {
+  issuesHandler.setIssueList([
+    { issue: mockRawIssue(false, props.issue as RawIssue), snippets: {} },
+  ]);
   return renderComponent(
     <WorkspaceContext.Provider
       value={{ openComponent: jest.fn(), externalRulesRepoNames: externalRules }}
index c8bb64115ca38ca895775617f2d089324a356d1b..99b65666e916d5ea8283456ed6b066187b745d4b 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { IconLink } from '@sonarsource/echoes-react';
+import { IconLink, Link } from '@sonarsource/echoes-react';
 import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
 import {
+  addGlobalSuccessMessage,
   Badge,
   BasicSeparator,
   ClipboardIconButton,
   IssueMessageHighlighting,
-  Link,
   Note,
   PageContentFontWrapper,
 } from '~design-system';
 import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
 import { getComponentIssuesUrl } from '~sonar-aligned/helpers/urls';
-import { setIssueAssignee } from '../../../api/issues';
+import { setIssueAssignee, setIssueSeverity } from '../../../api/issues';
 import { updateIssue } from '../../../components/issue/actions';
 import IssueActionsBar from '../../../components/issue/components/IssueActionsBar';
 import { WorkspaceContext } from '../../../components/workspace/context';
@@ -41,7 +42,8 @@ import { translate } from '../../../helpers/l10n';
 import { getKeyboardShortcutEnabled } from '../../../helpers/preferences';
 import { getPathUrlAsString, getRuleUrl } from '../../../helpers/urls';
 import { BranchLike } from '../../../types/branch-like';
-import { IssueActions, IssueType } from '../../../types/issues';
+import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-code-taxonomy';
+import { IssueActions, IssueSeverity, IssueType } from '../../../types/issues';
 import { Issue, RuleDetails } from '../../../types/types';
 import IssueHeaderMeta from './IssueHeaderMeta';
 import IssueHeaderSide from './IssueHeaderSide';
@@ -99,6 +101,39 @@ export default class IssueHeader extends React.PureComponent<Props, State> {
     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(
+          <FormattedMessage
+            id="issue.severity.updated_notification"
+            values={{
+              issueLink: undefined,
+              quality: quality ? translate('software_quality', quality) : undefined,
+              before: translate(quality ? 'severity_impact' : 'severity', severityBefore ?? ''),
+              after: translate(quality ? 'severity_impact' : 'severity', severity),
+            }}
+          />,
+        );
+        return r;
+      }),
+    );
+  };
+
   handleKeyDown = (event: KeyboardEvent) => {
     if (isInput(event) || isShortcut(event) || !getKeyboardShortcutEnabled()) {
       return true;
@@ -202,7 +237,12 @@ export default class IssueHeader extends React.PureComponent<Props, State> {
             showSonarLintBadge
           />
         </div>
-        <IssueHeaderSide issue={issue} />
+        <IssueHeaderSide
+          issue={issue}
+          onSetSeverity={
+            issue.actions.includes(IssueActions.SetSeverity) ? this.handleSeverityChange : undefined
+          }
+        />
         <IssueNewStatusAndTransitionGuide
           run
           issues={[issue]}
index c6a34afb3326f03f5954a0b6a81ab039ba2aad58..b48d39c09827c7073a1572a8b84a67f2122d9308 100644 (file)
@@ -26,14 +26,17 @@ import { CleanCodeAttributePill } from '../../../components/shared/CleanCodeAttr
 import SoftwareImpactPillList from '../../../components/shared/SoftwareImpactPillList';
 import { translate } from '../../../helpers/l10n';
 import { useStandardExperienceMode } from '../../../queries/settings';
+import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-code-taxonomy';
 import { IssueSeverity } from '../../../types/issues';
 import { Issue } from '../../../types/types';
 
 interface Props {
   issue: Issue;
+  onSetSeverity?: ((severity: IssueSeverity) => Promise<void>) &
+    ((severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise<void>);
 }
 
-export default function IssueHeaderSide({ issue }: Readonly<Props>) {
+export default function IssueHeaderSide({ issue, onSetSeverity }: Readonly<Props>) {
   const { data: isStandardMode, isLoading } = useStandardExperienceMode();
   return (
     <StyledSection className="sw-flex sw-flex-col sw-pl-4 sw-max-w-[250px]">
@@ -44,6 +47,7 @@ export default function IssueHeaderSide({ issue }: Readonly<Props>) {
           title={isStandardMode ? translate('type') : translate('issue.software_qualities.label')}
         >
           <SoftwareImpactPillList
+            onSetSeverity={onSetSeverity}
             className="sw-flex-wrap"
             softwareImpacts={issue.impacts}
             issueSeverity={issue.severity as IssueSeverity}
index 33ab12234e3e0bcd889fa7a7bf007143ca166b77..db01bf69c8313f79772f6e840cdb0d20c0bf3215 100644 (file)
@@ -19,8 +19,7 @@
  */
 
 import { flow } from 'lodash';
-import * as React from 'react';
-import { useCallback } from 'react';
+import { memo, useCallback, useEffect } from 'react';
 import { setIssueAssignee } from '../../api/issues';
 import { useComponent } from '../../app/components/componentContext/withComponentContext';
 import { isInput, isShortcut } from '../../helpers/keyboardEventHelpers';
@@ -45,7 +44,7 @@ interface Props {
   selected: boolean;
 }
 
-export default function Issue(props: Props) {
+function Issue(props: Readonly<Props>) {
   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);
index 1556548d858317691ee5dc71f27e9238cafee557..1e207f80caf7c142d5281691344a7d6ac4cd3c63 100644 (file)
@@ -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());
index 7331ad86e4b34beb306172b34a99198eda14de44..7304d6f54551551d72dc1cf92a1f638f3ac2ac41 100644 (file)
  */
 
 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<Props> {
-  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<Props>) {
+  const {
+    issue,
+    branchLike,
+    checked,
+    currentPopup,
+    displayWhyIsThisAnIssue,
+    onAssign,
+    onChange,
+    onSelect,
+    togglePopup,
+    selected,
+    onCheck,
+  } = props;
+  const intl = useIntl();
+  const nodeRef = useRef<HTMLLIElement>(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(
+          <FormattedMessage
+            id="issue.severity.updated_notification"
+            values={{
+              issueLink: (
+                <Link
+                  highlight={LinkHighlight.Default}
+                  to={
+                    component
+                      ? getComponentIssuesUrl(component.key, linkQuery)
+                      : getIssuesUrl(linkQuery)
+                  }
+                >
+                  {intl.formatMessage(
+                    {
+                      id: `issue.severity.updated_notification.link.${!quality ? 'standard' : 'mqr'}`,
+                    },
+                    {
+                      type: translate('issue.type', issue.type).toLowerCase(),
+                    },
+                  )}
+                </Link>
+              ),
+              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 (
-      <IssueItem
-        onClick={() => this.props.onSelect(issue.key)}
-        className={issueClass}
-        role="region"
-        aria-label={issue.message}
-        ref={(node) => (this.nodeRef = node)}
-      >
-        <div className="sw-flex sw-gap-3">
-          {hasCheckbox && (
-            <span className="sw-mt-1/2 sw-ml-1 sw-self-start">
-              <Checkbox
-                ariaLabel={translateWithParameters('issues.action_select.label', issue.message)}
-                checked={checked ?? false}
-                onCheck={this.handleCheck}
-                title={translate('issues.action_select')}
-              />
-            </span>
-          )}
-
-          <div className="sw-flex sw-flex-col sw-grow sw-gap-3 sw-min-w-0">
-            <IssueTitleBar
-              branchLike={branchLike}
-              displayWhyIsThisAnIssue={displayWhyIsThisAnIssue}
-              issue={issue}
+  useEffect(() => {
+    if (selected && nodeRef.current) {
+      nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
+    }
+  }, [selected]);
+
+  return (
+    <IssueItem
+      onClick={() => 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}
+    >
+      <div className="sw-flex sw-gap-3">
+        {hasCheckbox && (
+          <span className="sw-mt-1/2 sw-ml-1 sw-self-start">
+            <Checkbox
+              ariaLabel={translateWithParameters('issues.action_select.label', issue.message)}
+              checked={checked ?? false}
+              onCheck={handleCheck}
+              title={translate('issues.action_select')}
             />
-
-            <div className="sw-mt-1 sw-flex sw-items-start sw-justify-between">
-              <SoftwareImpactPillList
-                data-guiding-id="issue-2"
-                softwareImpacts={issue.impacts}
-                issueSeverity={issue.severity as IssueSeverity}
-                issueType={issue.type}
+          </span>
+        )}
+
+        <div className="sw-flex sw-flex-col sw-grow sw-gap-3 sw-min-w-0">
+          <IssueTitleBar
+            branchLike={branchLike}
+            displayWhyIsThisAnIssue={displayWhyIsThisAnIssue}
+            issue={issue}
+          />
+
+          <div className="sw-mt-1 sw-flex sw-items-start sw-justify-between">
+            <SoftwareImpactPillList
+              data-guiding-id="issue-2"
+              softwareImpacts={issue.impacts}
+              onSetSeverity={canSetSeverity ? setSeverity : undefined}
+              issueSeverity={issue.severity as IssueSeverity}
+              issueType={issue.type}
+            />
+            <div className="sw-grow-0 sw-whitespace-nowrap">
+              <IssueTags
+                issue={issue}
+                onChange={onChange}
+                togglePopup={togglePopup}
+                canSetTags={canSetTags}
+                open={currentPopup === 'edit-tags' && canSetTags}
               />
-              <div className="sw-grow-0 sw-whitespace-nowrap">
-                <IssueTags
-                  issue={issue}
-                  onChange={this.props.onChange}
-                  togglePopup={this.props.togglePopup}
-                  canSetTags={canSetTags}
-                  open={currentPopup === 'edit-tags' && canSetTags}
-                />
-              </div>
             </div>
+          </div>
 
-            <BasicSeparator />
+          <BasicSeparator />
 
-            <div className="sw-flex sw-gap-2 sw-flex-nowrap sw-items-center sw-justify-between">
-              <IssueActionsBar
-                currentPopup={currentPopup}
-                issue={issue}
-                onAssign={this.props.onAssign}
-                onChange={this.props.onChange}
-                togglePopup={this.props.togglePopup}
-              />
-              <IssueMetaBar issue={issue} />
-            </div>
+          <div className="sw-flex sw-gap-2 sw-flex-nowrap sw-items-center sw-justify-between">
+            <IssueActionsBar
+              currentPopup={currentPopup}
+              issue={issue}
+              onAssign={onAssign}
+              onChange={onChange}
+              togglePopup={togglePopup}
+            />
+            <IssueMetaBar issue={issue} />
           </div>
         </div>
-      </IssueItem>
-    );
-  }
+      </div>
+    </IssueItem>
+  );
 }
 
 const IssueItem = styled.li`
index 6d7265ac4aa5ab2ac04c6f0fc0963920d17ae25c..3cffb58f3aff46c7671ea637a177eb2ddae4bb6d 100644 (file)
@@ -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<void>;
   severity: IssueSeverity;
 }
 
 export default function IssueTypePill(props: Readonly<Props>) {
-  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<Props>) {
     [IssueSeverity.Info]: PillVariant.Info,
   }[severity];
 
+  const renderPill = (notClickable = false) => (
+    <Pill
+      notClickable={notClickable}
+      className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
+      variant={issueType !== IssueType.SecurityHotspot ? variant : PillVariant.Accent}
+    >
+      <IssueTypeIcon type={issueType} />
+      {intl.formatMessage({ id: `issue.type.${issueType}` })}
+      <Spinner isLoading={updatingSeverity} className="sw-ml-1/2">
+        {issueType !== IssueType.SecurityHotspot && (
+          <SoftwareImpactSeverityIcon
+            width={14}
+            height={14}
+            severity={severity}
+            data-guiding-id="issue-3"
+          />
+        )}
+      </Spinner>
+    </Pill>
+  );
+
+  const handleSetSeverity = async (severity: IssueSeverity) => {
+    setUpdatingSeverity(true);
+    await onSetSeverity?.(severity);
+    setUpdatingSeverity(false);
+  };
+
+  if (onSetSeverity) {
+    return (
+      <DropdownMenu.Root
+        align={DropdownMenuAlign.Start}
+        items={Object.values(IssueSeverity).map((severityItem) => (
+          <DropdownMenu.ItemButtonCheckable
+            key={severityItem}
+            isDisabled={severityItem === severity}
+            isChecked={severityItem === severity}
+            onClick={() => handleSetSeverity(severityItem)}
+          >
+            <div className="sw-flex sw-items-center sw-gap-2">
+              <SoftwareImpactSeverityIcon width={14} height={14} severity={severityItem} />
+              {intl.formatMessage({ id: `severity.${severityItem}` })}
+            </div>
+          </DropdownMenu.ItemButtonCheckable>
+        ))}
+      >
+        <Tooltip
+          content={intl.formatMessage(
+            {
+              id: `issue.type.tooltip_with_change`,
+            },
+            {
+              severity: intl.formatMessage({ id: `severity.${severity}` }),
+            },
+          )}
+        >
+          {renderPill()}
+        </Tooltip>
+      </DropdownMenu.Root>
+    );
+  }
+
   return (
     <Tooltip
       content={
@@ -55,31 +118,11 @@ export default function IssueTypePill(props: Readonly<Props>) {
               },
               {
                 severity: intl.formatMessage({ id: `severity.${severity}` }),
-                type: (
-                  <span className="sw-lowercase">
-                    {intl.formatMessage({ id: `issue.type.${issueType}` })}
-                  </span>
-                ),
               },
             )
       }
     >
-      <Pill
-        notClickable
-        className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
-        variant={issueType !== IssueType.SecurityHotspot ? variant : PillVariant.Accent}
-      >
-        <IssueTypeIcon type={issueType} />
-        {intl.formatMessage({ id: `issue.type.${issueType}` })}
-        {issueType !== IssueType.SecurityHotspot && (
-          <SoftwareImpactSeverityIcon
-            width={14}
-            height={14}
-            severity={severity}
-            data-guiding-id="issue-3"
-          />
-        )}
-      </Pill>
+      {renderPill(true)}
     </Tooltip>
   );
 }
index 0c2892d202f25e00d04aa999163ea871ed197b4e..14e7b8f9e5400b15e9c9972a9d7f1f72d5487aa1 100644 (file)
  * 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<void>;
   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 = (
+    <Pill
+      className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
+      onClick={noop}
+      variant={variant}
+    >
+      {quality}
+      <Spinner isLoading={updatingSeverity} className="sw-ml-1/2">
+        <SoftwareImpactSeverityIcon
+          width={14}
+          height={14}
+          severity={severity}
+          data-guiding-id="issue-3"
+        />
+      </Spinner>
+    </Pill>
+  );
+
+  const handleSetSeverity = async (severity: SoftwareImpactSeverity, quality: SoftwareQuality) => {
+    setUpdatingSeverity(true);
+    await onSetSeverity?.(severity, quality);
+    setUpdatingSeverity(false);
+  };
+
+  if (onSetSeverity && type === 'issue') {
+    return (
+      <DropdownMenu.Root
+        align={DropdownMenuAlign.Start}
+        items={IMPACT_SEVERITIES.map((impactSeverity) => (
+          <DropdownMenu.ItemButtonCheckable
+            key={impactSeverity}
+            isDisabled={impactSeverity === severity}
+            isChecked={impactSeverity === severity}
+            onClick={() => handleSetSeverity(impactSeverity, softwareQuality)}
+          >
+            <div className="sw-flex sw-items-center sw-gap-2">
+              <SoftwareImpactSeverityIcon width={14} height={14} severity={impactSeverity} />
+              {translate('severity_impact', impactSeverity)}
+            </div>
+          </DropdownMenu.ItemButtonCheckable>
+        ))}
+      >
+        <Tooltip
+          content={intl.formatMessage(
+            {
+              id: `issue.type.tooltip_with_change`,
+            },
+            {
+              severity: intl.formatMessage({ id: `severity_impact.${severity}` }),
+            },
+          )}
+        >
+          {pill}
+        </Tooltip>
+      </DropdownMenu.Root>
+    );
+  }
+
   return (
     <Popover
       title={translate('severity_impact.title')}
@@ -61,7 +131,7 @@ export default function SoftwareImpactPill(props: Props) {
           />
           <p className="sw-mt-2">
             <span className="sw-mr-1">{translate('severity_impact.help.line1')}</span>
-            {translate('severity_impact.help.line2')}
+            {type === 'issue' && translate('severity_impact.help.line2')}
           </p>
         </>
       }
@@ -71,19 +141,20 @@ export default function SoftwareImpactPill(props: Props) {
         </DocumentationLink>
       }
     >
-      <Pill
-        className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
-        onClick={noop}
-        variant={variant}
+      <Tooltip
+        content={intl.formatMessage(
+          {
+            id: `issue.type.tooltip`,
+          },
+          {
+            severity: intl.formatMessage({ id: `severity_impact.${severity}` }),
+          },
+        )}
       >
-        {quality}
-        <SoftwareImpactSeverityIcon
-          width={14}
-          height={14}
-          severity={severity}
-          data-guiding-id="issue-3"
-        />
-      </Pill>
+        {pill}
+      </Tooltip>
     </Popover>
   );
 }
+
+const getQualityLabel = (quality: SoftwareQuality) => translate('software_quality', quality);
index 28857e63ac34ae0f3dd6b67fb77bfdb440312c30..f2d8efcb08a98076af3fdf8cdc448fc02bf3050c 100644 (file)
@@ -35,6 +35,8 @@ interface SoftwareImpactPillListProps extends React.HTMLAttributes<HTMLUListElem
   className?: string;
   issueSeverity?: IssueSeverity;
   issueType?: string;
+  onSetSeverity?: ((severity: IssueSeverity) => Promise<void>) &
+    ((severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise<void>);
   softwareImpacts: SoftwareImpact[];
   type?: Parameters<typeof SoftwareImpactPill>[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 }) => (
             <li key={softwareQuality}>
               <SoftwareImpactPill
+                onSetSeverity={onSetSeverity}
                 severity={severity}
-                quality={getQualityLabel(softwareQuality)}
+                softwareQuality={softwareQuality}
                 type={type}
               />
             </li>
@@ -83,7 +87,11 @@ export default function SoftwareImpactPillList({
         <IssueTypePill severity={issueSeverity ?? IssueSeverity.Info} issueType={issueType} />
       )}
       {isStandardMode && issueType && issueSeverity && (
-        <IssueTypePill severity={issueSeverity} issueType={issueType} />
+        <IssueTypePill
+          onSetSeverity={onSetSeverity}
+          severity={issueSeverity}
+          issueType={issueType}
+        />
       )}
     </ul>
   );
index d3044078c474f0315904928a2317f1850d2ba0ee..e1ae1ff0c0a37eea1ea61615ea165f5052826b2a 100644 (file)
@@ -221,10 +221,17 @@ function renderRoutedApp(
 
   const router = createMemoryRouter(
     createRoutesFromElements(
-      <>
+      <Route
+        element={
+          <>
+            <Outlet />
+            <ToastMessageContainer />
+          </>
+        }
+      >
         {children}
         <Route path="*" element={<CatchAll />} />
-      </>,
+      </Route>,
     ),
     { initialEntries: [path] },
   );
@@ -239,7 +246,6 @@ function renderRoutedApp(
                 <AppStateContextProvider appState={appState}>
                   <IndexationContextProvider>
                     <QueryClientProvider client={queryClient}>
-                      <ToastMessageContainer />
                       <EchoesProvider tooltipsDelayDuration={0}>
                         <RouterProvider router={router} />
                       </EchoesProvider>
index 8c9d4bb4903c2a78eec9f298fc0552e4bfccae91..13e2be23ea5c4df08a49734426e2f24178ff3e45 100644 (file)
@@ -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: