]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19345 Update selected issue's header section to new design system
authorAmbroise C <ambroise.christea@sonarsource.com>
Wed, 31 May 2023 15:36:03 +0000 (17:36 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 9 Jun 2023 20:03:09 +0000 (20:03 +0000)
server/sonar-web/design-system/src/components/IssueMessageHighlighting.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/IssueMessageHighlighting-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/__snapshots__/IssueMessageHighlighting-test.tsx.snap [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx
server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx
server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.tsx
server/sonar-web/src/main/js/components/issue/components/IssueBadges.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/components/IssueMessageTags.tsx [deleted file]

diff --git a/server/sonar-web/design-system/src/components/IssueMessageHighlighting.tsx b/server/sonar-web/design-system/src/components/IssueMessageHighlighting.tsx
new file mode 100644 (file)
index 0000000..f16621b
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import * as React from 'react';
+import { themeColor } from '../helpers';
+
+export interface MessageFormatting {
+  end: number;
+  start: number;
+  type: MessageFormattingType;
+}
+
+export enum MessageFormattingType {
+  CODE = 'CODE',
+}
+
+export interface IssueMessageHighlightingProps {
+  message?: string;
+  messageFormattings?: MessageFormatting[];
+}
+
+export function IssueMessageHighlighting(props: IssueMessageHighlightingProps) {
+  const { message, messageFormattings } = props;
+
+  if (!message) {
+    return null;
+  }
+
+  if (!(messageFormattings && messageFormattings.length > 0)) {
+    return <>{message}</>;
+  }
+
+  let previousEnd = 0;
+
+  const sanitizedFormattings = [...messageFormattings]
+    .sort((a, b) => a.start - b.start)
+    .reduce<typeof messageFormattings>((acc, messageFormatting) => {
+      const { type } = messageFormatting;
+
+      if (type !== MessageFormattingType.CODE) {
+        return acc;
+      }
+
+      const { start } = messageFormatting;
+      let { end } = messageFormatting;
+
+      end = Math.min(message.length, end);
+
+      if (start < 0 || end === start || end < start) {
+        return acc;
+      }
+
+      if (acc.length > 0) {
+        const { start: previousStart, end: previousEnd } = acc[acc.length - 1];
+
+        if (start <= previousEnd) {
+          acc[acc.length - 1] = {
+            start: previousStart,
+            end: Math.max(previousEnd, end),
+            type,
+          };
+
+          return acc;
+        }
+      }
+
+      acc.push({ start, end, type });
+
+      return acc;
+    }, []);
+
+  return (
+    <span>
+      {sanitizedFormattings.map(({ start, end, type }) => {
+        const beginning = previousEnd;
+        previousEnd = end;
+
+        return (
+          <React.Fragment key={`${message}-${start}-${end}`}>
+            {message.slice(beginning, start)}
+            {type === MessageFormattingType.CODE ? (
+              <SingleLineSnippet className="sw-code sw-rounded-1 sw-py-1/2 sw-px-1 sw-border sw-border-solid">
+                {message.slice(start, end)}
+              </SingleLineSnippet>
+            ) : (
+              <span>{message.slice(start, end)}</span>
+            )}
+          </React.Fragment>
+        );
+      })}
+
+      {message.slice(previousEnd)}
+    </span>
+  );
+}
+
+const SingleLineSnippet = styled.span`
+  background: ${themeColor('codeSnippetBackground')};
+  border-color: ${themeColor('codeSnippetBorder')};
+  color: ${themeColor('codeSnippetInline')};
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/IssueMessageHighlighting-test.tsx b/server/sonar-web/design-system/src/components/__tests__/IssueMessageHighlighting-test.tsx
new file mode 100644 (file)
index 0000000..70d2cdf
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { render } from '@testing-library/react';
+import {
+  IssueMessageHighlighting,
+  IssueMessageHighlightingProps,
+  MessageFormattingType,
+} from '../IssueMessageHighlighting';
+
+it.each([
+  [undefined, undefined],
+  ['message', undefined],
+  ['message', []],
+  ['message', [{ start: 1, end: 4, type: 'something else' as MessageFormattingType }]],
+  [
+    'message',
+    [
+      { start: 5, end: 6, type: MessageFormattingType.CODE },
+      { start: 1, end: 4, type: MessageFormattingType.CODE },
+    ],
+  ],
+  [
+    'a somewhat longer message with overlapping ranges',
+    [{ start: -1, end: 1, type: MessageFormattingType.CODE }],
+  ],
+  [
+    'a somewhat longer message with overlapping ranges',
+    [{ start: 48, end: 70, type: MessageFormattingType.CODE }],
+  ],
+  [
+    'a somewhat longer message with overlapping ranges',
+    [{ start: 0, end: 0, type: MessageFormattingType.CODE }],
+  ],
+  [
+    'a somewhat longer message with overlapping ranges',
+    [
+      { start: 11, end: 17, type: MessageFormattingType.CODE },
+      { start: 2, end: 25, type: MessageFormattingType.CODE },
+      { start: 25, end: 2, type: MessageFormattingType.CODE },
+    ],
+  ],
+  [
+    'a somewhat longer message with overlapping ranges',
+    [
+      { start: 18, end: 30, type: MessageFormattingType.CODE },
+      { start: 2, end: 25, type: MessageFormattingType.CODE },
+    ],
+  ],
+])('should format the string with highlights', (message, messageFormattings) => {
+  const { asFragment } = renderIssueMessageHighlighting({ message, messageFormattings });
+  expect(asFragment()).toMatchSnapshot();
+});
+
+function renderIssueMessageHighlighting(props: Partial<IssueMessageHighlightingProps> = {}) {
+  return render(<IssueMessageHighlighting {...props} />);
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/IssueMessageHighlighting-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/IssueMessageHighlighting-test.tsx.snap
new file mode 100644 (file)
index 0000000..008c32e
--- /dev/null
@@ -0,0 +1,124 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should format the string with highlights 1`] = `<DocumentFragment />`;
+
+exports[`should format the string with highlights 2`] = `
+<DocumentFragment>
+  message
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 3`] = `
+<DocumentFragment>
+  message
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 4`] = `
+<DocumentFragment>
+  <span>
+    message
+  </span>
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 5`] = `
+<DocumentFragment>
+  .emotion-0 {
+  background: rgb(252,252,253);
+  border-color: rgb(225,230,243);
+  color: rgb(62,67,87);
+}
+
+<span>
+    m
+    <span
+      class="sw-code sw-rounded-1 sw-py-1/2 sw-px-1 sw-border sw-border-solid emotion-0 emotion-1"
+    >
+      ess
+    </span>
+    a
+    <span
+      class="sw-code sw-rounded-1 sw-py-1/2 sw-px-1 sw-border sw-border-solid emotion-0 emotion-1"
+    >
+      g
+    </span>
+    e
+  </span>
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 6`] = `
+<DocumentFragment>
+  <span>
+    a somewhat longer message with overlapping ranges
+  </span>
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 7`] = `
+<DocumentFragment>
+  .emotion-0 {
+  background: rgb(252,252,253);
+  border-color: rgb(225,230,243);
+  color: rgb(62,67,87);
+}
+
+<span>
+    a somewhat longer message with overlapping range
+    <span
+      class="sw-code sw-rounded-1 sw-py-1/2 sw-px-1 sw-border sw-border-solid emotion-0 emotion-1"
+    >
+      s
+    </span>
+  </span>
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 8`] = `
+<DocumentFragment>
+  <span>
+    a somewhat longer message with overlapping ranges
+  </span>
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 9`] = `
+<DocumentFragment>
+  .emotion-0 {
+  background: rgb(252,252,253);
+  border-color: rgb(225,230,243);
+  color: rgb(62,67,87);
+}
+
+<span>
+    a 
+    <span
+      class="sw-code sw-rounded-1 sw-py-1/2 sw-px-1 sw-border sw-border-solid emotion-0 emotion-1"
+    >
+      somewhat longer message
+    </span>
+     with overlapping ranges
+  </span>
+</DocumentFragment>
+`;
+
+exports[`should format the string with highlights 10`] = `
+<DocumentFragment>
+  .emotion-0 {
+  background: rgb(252,252,253);
+  border-color: rgb(225,230,243);
+  color: rgb(62,67,87);
+}
+
+<span>
+    a 
+    <span
+      class="sw-code sw-rounded-1 sw-py-1/2 sw-px-1 sw-border sw-border-solid emotion-0 emotion-1"
+    >
+      somewhat longer message with
+    </span>
+     overlapping ranges
+  </span>
+</DocumentFragment>
+`;
index 5457b363900ccbb49041d22cead8a05f66606d81..2724f1199d8a360011c59243bbc59bb07bddae5f 100644 (file)
@@ -54,6 +54,7 @@ export * from './InputMultiSelect';
 export { InputSearch } from './InputSearch';
 export * from './InputSelect';
 export * from './InteractiveIcon';
+export * from './IssueMessageHighlighting';
 export * from './KeyboardHint';
 export * from './Link';
 export { StandoutLink as Link } from './Link';
index 8bcf538a86ba177323e21138b2564bf438ab59c9..336135bf760c94904fbcd8eb0969bf7f53e900c9 100644 (file)
@@ -24,6 +24,7 @@ import selectEvent from 'react-select-event';
 import { TabKeys } from '../../../components/rules/RuleTabViewer';
 import { renderOwaspTop102021Category } from '../../../helpers/security-standard';
 import { mockLoggedInUser, mockRawIssue } from '../../../helpers/testMocks';
+import { findTooltipWithContent } from '../../../helpers/testReactTestingUtils';
 import { ComponentQualifier } from '../../../types/component';
 import { IssueType } from '../../../types/issues';
 import {
@@ -775,17 +776,16 @@ describe('issues item', () => {
 
     // open tags popup on key press 't'
 
-    // needs to be fixed with the new header from ambroise!
-    // await user.keyboard('t');
-    // expect(screen.getByRole('searchbox', { name: 'search.search_for_tags' })).toBeInTheDocument();
-    // expect(screen.getByText('android')).toBeInTheDocument();
-    // expect(screen.getByText('accessibility')).toBeInTheDocument();
-    // // closing tags popup
-    // await user.click(screen.getByText('issue.no_tag'));
-
-    // // open assign popup on key press 'a'
-    // await user.keyboard('a');
-    // expect(screen.getByRole('searchbox', { name: 'search.search_for_tags' })).toBeInTheDocument();
+    await user.keyboard('t');
+    expect(screen.getByRole('searchbox', { name: 'search.search_for_tags' })).toBeInTheDocument();
+    expect(screen.getByText('android')).toBeInTheDocument();
+    expect(screen.getByText('accessibility')).toBeInTheDocument();
+    // closing tags popup
+    await user.click(screen.getByText('issue.no_tag'));
+
+    // open assign popup on key press 'a'
+    await user.keyboard('a');
+    expect(screen.getByRole('searchbox', { name: 'search.search_for_tags' })).toBeInTheDocument();
   });
 
   it('should not open the actions popup using keyboard shortcut when keyboard shortcut flag is disabled', async () => {
@@ -864,8 +864,11 @@ describe('issues item', () => {
     await user.click(await ui.issueItemAction7.find());
 
     expect(
-      screen.getByRole('heading', {
-        name: 'Issue with tags issue.quick_fix_available_with_sonarlint_no_link issue.resolution.badge.DEPRECATED',
+      findTooltipWithContent('issue.quick_fix_available_with_sonarlint_no_link')
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('status', {
+        name: 'issue.resolution.badge.DEPRECATED',
       })
     ).toBeInTheDocument();
   });
index 1ee28169b96165f71ba3af6a5fb3b64e86d981e2..91a9824f9ac199481b595497e1f0a134191af230 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import {
+  ClipboardIconButton,
+  IssueMessageHighlighting,
+  Link,
+  LinkIcon,
+  Note,
+  PageContentFontWrapper,
+} from 'design-system';
 import * as React from 'react';
 import { setIssueAssignee } from '../../../api/issues';
-import Link from '../../../components/common/Link';
-import LinkIcon from '../../../components/icons/LinkIcon';
-import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting';
 import { updateIssue } from '../../../components/issue/actions';
 import IssueActionsBar from '../../../components/issue/components/IssueActionsBar';
-import IssueChangelog from '../../../components/issue/components/IssueChangelog';
-import IssueMessageTags from '../../../components/issue/components/IssueMessageTags';
+import IssueTags from '../../../components/issue/components/IssueTags';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
 import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardKeys } from '../../../helpers/keycodes';
 import { translate } from '../../../helpers/l10n';
 import { getKeyboardShortcutEnabled } from '../../../helpers/preferences';
-import { getComponentIssuesUrl, getRuleUrl } from '../../../helpers/urls';
+import { getComponentIssuesUrl, getPathUrlAsString, getRuleUrl } from '../../../helpers/urls';
 import { BranchLike } from '../../../types/branch-like';
-import { IssueType } from '../../../types/issues';
-import { RuleStatus } from '../../../types/rules';
+import { IssueActions, IssueType } from '../../../types/issues';
 import { Issue, RuleDetails } from '../../../types/types';
 
 interface Props {
@@ -131,65 +134,43 @@ export default class IssueHeader extends React.PureComponent<Props, State> {
       open: issue.key,
       types: issue.type === IssueType.SecurityHotspot ? issue.type : undefined,
     });
-    const ruleStatus = issue.ruleStatus as RuleStatus | undefined;
-    const { quickFixAvailable } = issue;
+    const canSetTags = issue.actions.includes(IssueActions.SetTags);
 
     return (
-      <>
-        <div className="display-flex-center display-flex-space-between big-padded-top">
-          <h1 className="text-bold spacer-right">
-            <span className="spacer-right issue-header" aria-label={issue.message}>
-              <IssueMessageHighlighting
-                message={issue.message}
-                messageFormattings={issue.messageFormattings}
-              />
-            </span>
-            <IssueMessageTags
-              engine={issue.externalRuleEngine}
-              quickFixAvailable={quickFixAvailable}
-              ruleStatus={ruleStatus}
+      <header className="sw-flex sw-flex-col sw-gap-3 sw-my-6">
+        <div className="sw-flex sw-items-center">
+          <PageContentFontWrapper className="sw-body-md-highlight" as="h1">
+            <IssueMessageHighlighting
+              message={issue.message}
+              messageFormattings={issue.messageFormattings}
             />
-          </h1>
-          <div className="issue-meta issue-get-perma-link">
-            <Link
-              className="js-issue-permalink link-no-underline"
-              target="_blank"
-              title={translate('permalink')}
-              to={issueUrl}
-            >
-              {translate('issue.action.permalink')}
-              <LinkIcon />
-            </Link>
-          </div>
+          </PageContentFontWrapper>
+          <ClipboardIconButton
+            Icon={LinkIcon}
+            aria-label={translate('permalink')}
+            className="sw-ml-1 sw-align-bottom"
+            copyValue={getPathUrlAsString(issueUrl, false)}
+            discreet={true}
+          />
         </div>
-        <div className="display-flex-center display-flex-space-between spacer-top big-spacer-bottom">
-          <div>
-            <span className="note padded-right">{name}</span>
+        <div className="sw-flex sw-items-center sw-justify-between">
+          <Note>
+            <span className="sw-pr-1">{name}</span>
             {isExternal ? (
-              <span className="note small">({key})</span>
+              <span>({key})</span>
             ) : (
-              <Link className="small" to={getRuleUrl(key)} target="_blank">
+              <Link to={getRuleUrl(key)} target="_blank">
                 {key}
               </Link>
             )}
-          </div>
-          <div className="issue-meta-list">
-            <div className="issue-meta">
-              <IssueChangelog
-                creationDate={issue.creationDate}
-                isOpen={issuePopupName === 'changelog'}
-                issue={issue}
-                togglePopup={this.handleIssuePopupToggle}
-              />
-            </div>
-            {issue.textRange != null && (
-              <div className="issue-meta">
-                <span className="issue-meta-label" title={translate('line_number')}>
-                  L{issue.textRange.endLine}
-                </span>
-              </div>
-            )}
-          </div>
+          </Note>
+          <IssueTags
+            canSetTags={canSetTags}
+            issue={issue}
+            onChange={this.props.onIssueChange}
+            open={issuePopupName === 'edit-tags' && canSetTags}
+            togglePopup={this.handleIssuePopupToggle}
+          />
         </div>
         <IssueActionsBar
           className="issue-header-actions"
@@ -200,7 +181,7 @@ export default class IssueHeader extends React.PureComponent<Props, State> {
           togglePopup={this.handleIssuePopupToggle}
           showCommentsInPopup
         />
-      </>
+      </header>
     );
   }
 }
index 4be902198dbf3d146d4de94c76c443037c503b75..dc3166bab0f645e192763eb7844c1bf98b10524e 100644 (file)
@@ -154,21 +154,20 @@ describe('updating', () => {
     expect(ui.updateAssigneeBtn('luke').get()).toBeInTheDocument();
   });
 
-  // Should be re-enabled when tags are re-enabled with ambroise code
   // eslint-disable-next-line jest/no-commented-out-tests
-  // it('should allow updating the tags', async () => {
-  //   const { ui } = getPageObject();
-  //   const issue = mockRawIssue(false, {
-  //     tags: [],
-  //     actions: [IssueActions.SetTags],
-  //   });
-  //   issuesHandler.setIssueList([{ issue, snippets: {} }]);
-  //   renderIssue({ issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'tags') }) });
-
-  //   await ui.addTag('accessibility');
-  //   await ui.addTag('android', ['accessibility']);
-  //   expect(ui.updateTagsBtn(['accessibility', 'android']).get()).toBeInTheDocument();
-  // });
+  it('should allow updating the tags', async () => {
+    const { ui } = getPageObject();
+    const issue = mockRawIssue(false, {
+      tags: [],
+      actions: [IssueActions.SetTags],
+    });
+    issuesHandler.setIssueList([{ issue, snippets: {} }]);
+    renderIssue({ issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'tags') }) });
+
+    await ui.addTag('accessibility');
+    await ui.addTag('android', ['accessibility']);
+    expect(ui.updateTagsBtn(['accessibility', 'android']).get()).toBeInTheDocument();
+  });
 });
 
 it('should correctly handle keyboard shortcuts', async () => {
@@ -297,7 +296,7 @@ function getPageObject() {
     // Tags
     tagsSearchInput: byRole('searchbox'),
     updateTagsBtn: (currentTags?: string[]) =>
-      byText(`tags_list_x.${currentTags ? currentTags.join(', ') : 'issue.no_tag'}`),
+      byRole('button', { name: `${currentTags ? currentTags.join(', ') : 'issue.no_tag'} +` }),
     toggleTagCheckbox: (name: string) => byRole('checkbox', { name }),
   };
 
index 28005887c65e8561528cd0218f9a7f7acc27b285..4716deb8354532b0384699f2ce694b31ddbacb80 100644 (file)
@@ -33,10 +33,11 @@ import { RuleStatus } from '../../../types/rules';
 import { Issue, RawQuery } from '../../../types/types';
 import Tooltip from '../../controls/Tooltip';
 import DateFromNow from '../../intl/DateFromNow';
+import { WorkspaceContext } from '../../workspace/context';
 import { updateIssue } from '../actions';
 import IssueAssign from './IssueAssign';
+import IssueBadges from './IssueBadges';
 import IssueCommentAction from './IssueCommentAction';
-import IssueMessageTags from './IssueMessageTags';
 import IssueSeverity from './IssueSeverity';
 import IssueTransition from './IssueTransition';
 import IssueType from './IssueType';
@@ -58,138 +59,138 @@ interface State {
   commentPlaceholder: string;
 }
 
-export default class IssueActionsBar extends React.PureComponent<Props, State> {
-  state: State = {
+export default function IssueActionsBar(props: Props) {
+  const {
+    issue,
+    currentPopup,
+    onAssign,
+    onChange,
+    togglePopup,
+    className,
+    showComments,
+    showCommentsInPopup,
+    showLine,
+  } = props;
+  const [commentState, setCommentState] = React.useState<State>({
     commentAutoTriggered: false,
     commentPlaceholder: '',
-  };
+  });
 
-  setIssueProperty = (
+  const setIssueProperty = (
     property: keyof Issue,
     popup: string,
     apiCall: (query: RawQuery) => Promise<IssueResponse>,
     value: string
   ) => {
-    const { issue } = this.props;
     if (issue[property] !== value) {
       const newIssue = { ...issue, [property]: value };
-      updateIssue(
-        this.props.onChange,
-        apiCall({ issue: issue.key, [property]: value }),
-        issue,
-        newIssue
-      );
+      updateIssue(onChange, apiCall({ issue: issue.key, [property]: value }), issue, newIssue);
     }
-    this.props.togglePopup(popup, false);
+    togglePopup(popup, false);
   };
 
-  toggleComment = (open: boolean | undefined, placeholder = '', autoTriggered = false) => {
-    this.setState({
+  const toggleComment = (open: boolean | undefined, placeholder = '', autoTriggered = false) => {
+    setCommentState({
       commentPlaceholder: placeholder,
       commentAutoTriggered: autoTriggered,
     });
-    this.props.togglePopup('comment', open);
+    togglePopup('comment', open);
   };
 
-  handleTransition = (issue: Issue) => {
-    this.props.onChange(issue);
+  const handleTransition = (issue: Issue) => {
+    onChange(issue);
     if (
       issue.resolution === IssueResolution.FalsePositive ||
       (issue.resolution === IssueResolution.WontFix && issue.type !== IssueTypeEnum.SecurityHotspot)
     ) {
-      this.toggleComment(true, translate('issue.comment.explain_why'), true);
+      toggleComment(true, translate('issue.comment.explain_why'), true);
     }
   };
 
-  render() {
-    const { issue, className, showComments, showCommentsInPopup, showLine } = this.props;
-    const canAssign = issue.actions.includes(IssueActions.Assign);
-    const canComment = issue.actions.includes(IssueActions.Comment);
-    const canSetSeverity = issue.actions.includes(IssueActions.SetSeverity);
-    const canSetType = issue.actions.includes(IssueActions.SetType);
-    const hasTransitions = issue.transitions.length > 0;
-    const hasComments = issue.comments && issue.comments.length > 0;
-    const IssueMetaLiClass = classNames(
-      className,
-      'sw-body-sm sw-overflow-hidden sw-whitespace-nowrap sw-max-w-abs-150'
-    );
+  const { externalRulesRepoNames } = React.useContext(WorkspaceContext);
+  const ruleEngine =
+    (issue.externalRuleEngine && externalRulesRepoNames[issue.externalRuleEngine]) ||
+    issue.externalRuleEngine;
+  const canAssign = issue.actions.includes(IssueActions.Assign);
+  const canComment = issue.actions.includes(IssueActions.Comment);
+  const canSetSeverity = issue.actions.includes(IssueActions.SetSeverity);
+  const canSetType = issue.actions.includes(IssueActions.SetType);
+  const hasTransitions = issue.transitions.length > 0;
+  const hasComments = !!issue.comments?.length;
+  const issueMetaListItemClassNames = classNames(
+    className,
+    'sw-body-sm sw-overflow-hidden sw-whitespace-nowrap sw-max-w-abs-150'
+  );
 
-    return (
-      <div className="sw-flex sw-flex-wrap sw-items-center sw-justify-between">
-        <ul className="sw-flex sw-items-center sw-gap-3 sw-body-sm">
-          <li>
-            <IssueType
-              canSetType={canSetType}
-              issue={issue}
-              setIssueProperty={this.setIssueProperty}
-            />
-          </li>
-          <li>
-            <IssueSeverity
-              isOpen={this.props.currentPopup === 'set-severity'}
-              togglePopup={this.props.togglePopup}
-              canSetSeverity={canSetSeverity}
-              issue={issue}
-              setIssueProperty={this.setIssueProperty}
-            />
-          </li>
-          <li>
-            <IssueTransition
-              isOpen={this.props.currentPopup === 'transition'}
-              togglePopup={this.props.togglePopup}
-              hasTransitions={hasTransitions}
-              issue={issue}
-              onChange={this.handleTransition}
-            />
-          </li>
-          <li>
-            <IssueAssign
-              isOpen={this.props.currentPopup === 'assign'}
-              togglePopup={this.props.togglePopup}
-              canAssign={canAssign}
-              issue={issue}
-              onAssign={this.props.onAssign}
-            />
-          </li>
-        </ul>
-        {(canComment || showCommentsInPopup) && (
-          <IssueCommentAction
-            commentAutoTriggered={this.state.commentAutoTriggered}
-            commentPlaceholder={this.state.commentPlaceholder}
-            currentPopup={this.props.currentPopup}
-            issueKey={issue.key}
-            onChange={this.props.onChange}
-            toggleComment={this.toggleComment}
-            comments={issue.comments}
-            canComment={canComment}
-            showCommentsInPopup={showCommentsInPopup}
+  return (
+    <div className="sw-flex sw-flex-wrap sw-items-center sw-justify-between">
+      <ul className="sw-flex sw-items-center sw-gap-3 sw-body-sm">
+        <li>
+          <IssueType canSetType={canSetType} issue={issue} setIssueProperty={setIssueProperty} />
+        </li>
+        <li>
+          <IssueSeverity
+            isOpen={currentPopup === 'set-severity'}
+            togglePopup={togglePopup}
+            canSetSeverity={canSetSeverity}
+            issue={issue}
+            setIssueProperty={setIssueProperty}
           />
-        )}
+        </li>
+        <li>
+          <IssueTransition
+            isOpen={currentPopup === 'transition'}
+            togglePopup={togglePopup}
+            hasTransitions={hasTransitions}
+            issue={issue}
+            onChange={handleTransition}
+          />
+        </li>
+        <li>
+          <IssueAssign
+            isOpen={currentPopup === 'assign'}
+            togglePopup={togglePopup}
+            canAssign={canAssign}
+            issue={issue}
+            onAssign={onAssign}
+          />
+        </li>
+      </ul>
+      {(canComment || showCommentsInPopup) && (
+        <IssueCommentAction
+          commentAutoTriggered={commentState.commentAutoTriggered}
+          commentPlaceholder={commentState.commentPlaceholder}
+          currentPopup={currentPopup}
+          issueKey={issue.key}
+          onChange={onChange}
+          toggleComment={toggleComment}
+          comments={issue.comments}
+          canComment={canComment}
+          showCommentsInPopup={showCommentsInPopup}
+        />
+      )}
 
-        <ul className="sw-flex sw-items-center sw-gap-2 sw-body-sm">
-          <li className={IssueMetaLiClass}>
-            <IssueMessageTags
-              engine={issue.externalRuleEngine}
-              quickFixAvailable={issue.quickFixAvailable}
-              ruleStatus={issue.ruleStatus as RuleStatus | undefined}
-            />
-          </li>
+      <ul className="sw-flex sw-items-center sw-gap-2 sw-body-sm">
+        <li className={issueMetaListItemClassNames}>
+          <IssueBadges
+            quickFixAvailable={issue.quickFixAvailable}
+            ruleStatus={issue.ruleStatus as RuleStatus | undefined}
+          />
+        </li>
 
-          {issue.externalRuleEngine && (
-            <li className={IssueMetaLiClass}>
-              <Tooltip
-                overlay={translateWithParameters(
-                  'issue.from_external_rule_engine',
-                  issue.externalRuleEngine
-                )}
-              >
-                <Badge>{issue.externalRuleEngine}</Badge>
-              </Tooltip>
-            </li>
-          )}
+        {ruleEngine && (
+          <li className={issueMetaListItemClassNames}>
+            <Tooltip
+              overlay={translateWithParameters('issue.from_external_rule_engine', ruleEngine)}
+            >
+              <Badge>{ruleEngine}</Badge>
+            </Tooltip>
+          </li>
+        )}
 
-          {issue.codeVariants && issue.codeVariants.length > 0 && (
-            <IssueMetaLi>
+        {!!issue.codeVariants?.length && (
+          <>
+            <IssueMetaListItem>
               <Tooltip overlay={issue.codeVariants.join(', ')}>
                 <>
                   {issue.codeVariants.length > 1
@@ -197,46 +198,46 @@ export default class IssueActionsBar extends React.PureComponent<Props, State> {
                     : translate('issue.1_code_variant')}
                 </>
               </Tooltip>
-              <SeparatorCircleIcon aria-hidden={true} as="li" />
-            </IssueMetaLi>
-          )}
+            </IssueMetaListItem>
+            <SeparatorCircleIcon aria-hidden={true} as="li" />
+          </>
+        )}
 
-          {showComments && hasComments && (
-            <>
-              <IssueMetaLi className={IssueMetaLiClass}>
-                <CommentIcon aria-label={translate('issue.comment.formlink')} />
-                {issue.comments?.length}
-              </IssueMetaLi>
-              <SeparatorCircleIcon aria-hidden={true} as="li" />
-            </>
-          )}
-          {showLine && isDefined(issue.textRange) && (
-            <>
-              <Tooltip overlay={translate('line_number')}>
-                <IssueMetaLi className={IssueMetaLiClass}>
-                  {translateWithParameters('issue.ncloc_x.short', issue.textRange.endLine)}
-                </IssueMetaLi>
-              </Tooltip>
-              <SeparatorCircleIcon aria-hidden={true} as="li" />
-            </>
-          )}
-          {issue.effort && (
-            <>
-              <IssueMetaLi className={IssueMetaLiClass}>
-                {translateWithParameters('issue.x_effort', issue.effort)}
-              </IssueMetaLi>
-              <SeparatorCircleIcon aria-hidden={true} as="li" />
-            </>
-          )}
-          <IssueMetaLi className={IssueMetaLiClass}>
-            <DateFromNow date={issue.creationDate} />
-          </IssueMetaLi>
-        </ul>
-      </div>
-    );
-  }
+        {showComments && hasComments && (
+          <>
+            <IssueMetaListItem className={issueMetaListItemClassNames}>
+              <CommentIcon aria-label={translate('issue.comment.formlink')} />
+              {issue.comments?.length}
+            </IssueMetaListItem>
+            <SeparatorCircleIcon aria-hidden={true} as="li" />
+          </>
+        )}
+        {showLine && isDefined(issue.textRange) && (
+          <>
+            <Tooltip overlay={translate('line_number')}>
+              <IssueMetaListItem className={issueMetaListItemClassNames}>
+                {translateWithParameters('issue.ncloc_x.short', issue.textRange.endLine)}
+              </IssueMetaListItem>
+            </Tooltip>
+            <SeparatorCircleIcon aria-hidden={true} as="li" />
+          </>
+        )}
+        {issue.effort && (
+          <>
+            <IssueMetaListItem className={issueMetaListItemClassNames}>
+              {translateWithParameters('issue.x_effort', issue.effort)}
+            </IssueMetaListItem>
+            <SeparatorCircleIcon aria-hidden={true} as="li" />
+          </>
+        )}
+        <IssueMetaListItem className={issueMetaListItemClassNames}>
+          <DateFromNow date={issue.creationDate} />
+        </IssueMetaListItem>
+      </ul>
+    </div>
+  );
 }
 
-const IssueMetaLi = styled.li`
+const IssueMetaListItem = styled.li`
   color: ${themeColor('pageContentLight')};
 `;
diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueBadges.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueBadges.tsx
new file mode 100644 (file)
index 0000000..6fc6ff4
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { Badge } from 'design-system';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { RuleStatus } from '../../../types/rules';
+import DocumentationTooltip from '../../common/DocumentationTooltip';
+import Link from '../../common/Link';
+import Tooltip from '../../controls/Tooltip';
+import SonarLintIcon from '../../icons/SonarLintIcon';
+
+export interface IssueBadgesProps {
+  quickFixAvailable?: boolean;
+  ruleStatus?: RuleStatus;
+}
+
+export default function IssueBadges(props: IssueBadgesProps) {
+  const { quickFixAvailable, ruleStatus } = props;
+
+  return (
+    <div className="sw-flex">
+      {quickFixAvailable && (
+        <Tooltip
+          overlay={
+            <FormattedMessage
+              id="issue.quick_fix_available_with_sonarlint"
+              defaultMessage={translate('issue.quick_fix_available_with_sonarlint')}
+              values={{
+                link: (
+                  <Link
+                    to="https://www.sonarqube.org/sonarlint/?referrer=sonarqube-quick-fix"
+                    target="_blank"
+                  >
+                    SonarLint
+                  </Link>
+                ),
+              }}
+            />
+          }
+          mouseLeaveDelay={0.5}
+        >
+          <div className="sw-flex sw-items-center">
+            <SonarLintIcon
+              className="it__issues-sonarlint-quick-fix"
+              size={15}
+              description={translate('issue.quick_fix_available_with_sonarlint_no_link')}
+            />
+          </div>
+        </Tooltip>
+      )}
+      {ruleStatus &&
+        (ruleStatus === RuleStatus.Deprecated || ruleStatus === RuleStatus.Removed) && (
+          <DocumentationTooltip
+            className="sw-ml-2"
+            content={translate('rules.status', ruleStatus, 'help')}
+            links={[
+              {
+                href: '/user-guide/rules/overview/',
+                label: translateWithParameters('see_x', translate('rules')),
+              },
+            ]}
+          >
+            <Badge variant="deleted">{translate('issue.resolution.badge', ruleStatus)}</Badge>
+          </DocumentationTooltip>
+        )}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueMessageTags.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueMessageTags.tsx
deleted file mode 100644 (file)
index 5bf6636..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Tooltip from '../../../components/controls/Tooltip';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { RuleStatus } from '../../../types/rules';
-import DocumentationTooltip from '../../common/DocumentationTooltip';
-import Link from '../../common/Link';
-import SonarLintIcon from '../../icons/SonarLintIcon';
-import { WorkspaceContext } from '../../workspace/context';
-
-export interface IssueMessageTagsProps {
-  engine?: string;
-  quickFixAvailable?: boolean;
-  ruleStatus?: RuleStatus;
-}
-
-export default function IssueMessageTags(props: IssueMessageTagsProps) {
-  const { engine, quickFixAvailable, ruleStatus } = props;
-
-  const { externalRulesRepoNames } = React.useContext(WorkspaceContext);
-  const ruleEngine = (engine && externalRulesRepoNames[engine]) || engine;
-
-  return (
-    <>
-      {quickFixAvailable && (
-        <Tooltip
-          overlay={
-            <FormattedMessage
-              id="issue.quick_fix_available_with_sonarlint"
-              defaultMessage={translate('issue.quick_fix_available_with_sonarlint')}
-              values={{
-                link: (
-                  <Link
-                    to="https://www.sonarqube.org/sonarlint/?referrer=sonarqube-quick-fix"
-                    target="_blank"
-                  >
-                    SonarLint
-                  </Link>
-                ),
-              }}
-            />
-          }
-          mouseLeaveDelay={0.5}
-        >
-          <SonarLintIcon
-            className="it__issues-sonarlint-quick-fix spacer-right"
-            size={15}
-            description={translate('issue.quick_fix_available_with_sonarlint_no_link')}
-          />
-        </Tooltip>
-      )}
-      {ruleStatus &&
-        (ruleStatus === RuleStatus.Deprecated || ruleStatus === RuleStatus.Removed) && (
-          <DocumentationTooltip
-            className="spacer-left"
-            content={translate('rules.status', ruleStatus, 'help')}
-            links={[
-              {
-                href: '/user-guide/rules/overview/',
-                label: translateWithParameters('see_x', translate('rules')),
-              },
-            ]}
-          >
-            <span className="spacer-right badge badge-error">
-              {translate('issue.resolution.badge', ruleStatus)}
-            </span>
-          </DocumentationTooltip>
-        )}
-      {ruleEngine && (
-        <Tooltip overlay={translateWithParameters('issue.from_external_rule_engine', ruleEngine)}>
-          <div className="badge spacer-right text-baseline">{ruleEngine}</div>
-        </Tooltip>
-      )}
-    </>
-  );
-}