]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16538 Move issue attributes and actions to the issue header
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Thu, 28 Jul 2022 10:22:49 +0000 (12:22 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 3 Aug 2022 20:03:24 +0000 (20:03 +0000)
23 files changed:
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
server/sonar-web/src/main/js/app/theme.js
server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesSourceViewer-test.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/IssuesSourceViewer-test.tsx.snap
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetGroupViewer-test.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewer-test.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewer-test.tsx.snap
server/sonar-web/src/main/js/components/SourceViewer/components/Line.css
server/sonar-web/src/main/js/components/issue/Issue.css
server/sonar-web/src/main/js/components/issue/IssueMessageBox.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/IssueView.tsx
server/sonar-web/src/main/js/components/issue/SecondaryIssue.tsx [deleted file]
server/sonar-web/src/main/js/components/issue/__tests__/__snapshots__/IssueView-test.tsx.snap
server/sonar-web/src/main/js/components/issue/components/IssueActionsBar.tsx
server/sonar-web/src/main/js/types/issues.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index e2da3d0c9235b0b28e2a5a5fc731c7c7f4e148f9..e42cdf6eef8e05bb5d632c97b9c0abb3eb9fc06d 100644 (file)
@@ -28,12 +28,19 @@ import { RequestData } from '../../helpers/request';
 import { getStandards } from '../../helpers/security-standard';
 import {
   mockCurrentUser,
+  mockLoggedInUser,
   mockPaging,
   mockRawIssue,
   mockRuleDetails
 } from '../../helpers/testMocks';
 import { BranchParameters } from '../../types/branch-like';
-import { RawFacet, RawIssue, RawIssuesResponse, ReferencedComponent } from '../../types/issues';
+import {
+  IssueType,
+  RawFacet,
+  RawIssue,
+  RawIssuesResponse,
+  ReferencedComponent
+} from '../../types/issues';
 import { Standards } from '../../types/security';
 import {
   Dict,
@@ -44,9 +51,18 @@ import {
 } from '../../types/types';
 import { NoticeType } from '../../types/users';
 import { getComponentForSourceViewer, getSources } from '../components';
-import { getIssueFlowSnippets, searchIssues } from '../issues';
+import {
+  getIssueFlowSnippets,
+  searchIssues,
+  searchIssueTags,
+  setIssueAssignee,
+  setIssueSeverity,
+  setIssueTags,
+  setIssueTransition,
+  setIssueType
+} from '../issues';
 import { getRuleDetails } from '../rules';
-import { dismissNotice, getCurrentUser } from '../users';
+import { dismissNotice, getCurrentUser, searchUsers } from '../users';
 
 function mockReferenceComponent(override?: Partial<ReferencedComponent>) {
   return {
@@ -144,6 +160,8 @@ export default class IssuesServiceMock {
       },
       {
         issue: mockRawIssue(false, {
+          actions: ['set_type', 'set_tags', 'comment', 'set_severity', 'assign'],
+          transitions: ['confirm', 'resolve', 'falsepositive', 'wontfix'],
           key: 'issue2',
           component: 'project:file.bar',
           message: 'Fix that',
@@ -201,6 +219,13 @@ export default class IssuesServiceMock {
     );
     (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
     (dismissNotice as jest.Mock).mockImplementation(this.handleDismissNotification);
+    (setIssueType as jest.Mock).mockImplementation(this.handleSetIssueType);
+    (setIssueAssignee as jest.Mock).mockImplementation(this.handleSetIssueAssignee);
+    (setIssueSeverity as jest.Mock).mockImplementation(this.handleSetIssueSeverity);
+    (setIssueTransition as jest.Mock).mockImplementation(this.handleSetIssueTransition);
+    (setIssueTags as jest.Mock).mockImplementation(this.handleSetIssueTags);
+    (searchUsers as jest.Mock).mockImplementation(this.handleSearchUsers);
+    (searchIssueTags as jest.Mock).mockImplementation(this.handleSearchIssueTags);
   }
 
   async getStandards(): Promise<Standards> {
@@ -336,6 +361,52 @@ export default class IssuesServiceMock {
     return Promise.reject();
   };
 
+  handleSetIssueType = (data: { issue: string; type: IssueType }) => {
+    return this.getActionsResponse({ type: data.type }, data.issue);
+  };
+
+  handleSetIssueSeverity = (data: { issue: string; severity: string }) => {
+    return this.getActionsResponse({ severity: data.severity }, data.issue);
+  };
+
+  handleSetIssueAssignee = (data: { issue: string; assignee?: string }) => {
+    return this.getActionsResponse({ assignee: data.assignee }, data.issue);
+  };
+
+  handleSetIssueTransition = (data: { issue: string; transition: string }) => {
+    const statusMap: { [key: string]: string } = {
+      confirm: 'CONFIRMED',
+      unconfirm: 'REOPENED',
+      resolve: 'RESOLVED'
+    };
+    return this.getActionsResponse({ status: statusMap[data.transition] }, data.issue);
+  };
+
+  handleSetIssueTags = (data: { issue: string; tags: string }) => {
+    const tagsArr = data.tags.split(',');
+    return this.getActionsResponse({ tags: tagsArr }, data.issue);
+  };
+
+  handleSearchUsers = () => {
+    return this.reply({ users: [mockLoggedInUser()] });
+  };
+
+  handleSearchIssueTags = () => {
+    return this.reply(['accessibility', 'android']);
+  };
+
+  getActionsResponse = (overrides: Partial<RawIssue>, issueKey: string) => {
+    const issueDataSelected = this.list.find(l => l.issue.key === issueKey)!;
+
+    issueDataSelected.issue = {
+      ...issueDataSelected?.issue,
+      ...overrides
+    };
+    return this.reply({
+      issue: issueDataSelected.issue
+    });
+  };
+
   reply<T>(response: T): Promise<T> {
     return Promise.resolve(cloneDeep(response));
   }
index c84f3db195dc06c140e5263cbd391184b97a68a8..7a4011d30d323de642f74ba81e616b1877e6ac50 100644 (file)
@@ -186,7 +186,7 @@ module.exports = {
 
     // ui elements
     pageMainZIndex: '50',
-    pageSideZIndex: '51',
+    pageSideZIndex: '50',
     pageHeaderZIndex: '55',
 
     globalBannerZIndex: '60',
index 858ff36453d978d3482802eed38248ffb41ac912..204bb4be7ffb2e63ea4b0e4bb6da2263bf2b2bea 100644 (file)
@@ -59,6 +59,7 @@ it('should open issue and navigate', async () => {
   // Select an issue with an advanced rule
   expect(await screen.findByRole('region', { name: 'Fix that' })).toBeInTheDocument();
   await user.click(screen.getByRole('region', { name: 'Fix that' }));
+  expect(screen.getByRole('button', { name: 'issue.tabs.code' })).toBeInTheDocument();
 
   // Are rule headers present?
   expect(screen.getByRole('heading', { level: 1, name: 'Fix that' })).toBeInTheDocument();
@@ -172,6 +173,172 @@ it('should support OWASP Top 10 version 2021', async () => {
   );
 });
 
+it('should be able to perform action on issues', async () => {
+  const user = userEvent.setup();
+  handler.setIsAdmin(true);
+  renderIssueApp();
+
+  // Select an issue with an advanced rule
+  await user.click(await screen.findByRole('region', { name: 'Fix that' }));
+
+  // changing issue type
+  expect(
+    screen.getByRole('button', {
+      name: `issue.type.type_x_click_to_change.issue.type.CODE_SMELL`
+    })
+  ).toBeInTheDocument();
+  await user.click(
+    screen.getByRole('button', {
+      name: `issue.type.type_x_click_to_change.issue.type.CODE_SMELL`
+    })
+  );
+  expect(screen.getByText('issue.type.BUG')).toBeInTheDocument();
+  expect(screen.getByText('issue.type.VULNERABILITY')).toBeInTheDocument();
+
+  await user.click(screen.getByText('issue.type.VULNERABILITY'));
+  expect(
+    screen.getByRole('button', {
+      name: `issue.type.type_x_click_to_change.issue.type.VULNERABILITY`
+    })
+  ).toBeInTheDocument();
+
+  // changing issue severity
+  expect(screen.getByText('severity.MAJOR')).toBeInTheDocument();
+
+  await user.click(
+    screen.getByRole('button', {
+      name: `issue.severity.severity_x_click_to_change.severity.MAJOR`
+    })
+  );
+  expect(screen.getByText('severity.MINOR')).toBeInTheDocument();
+  expect(screen.getByText('severity.INFO')).toBeInTheDocument();
+  await user.click(screen.getByText('severity.MINOR'));
+  expect(
+    screen.getByRole('button', {
+      name: `issue.severity.severity_x_click_to_change.severity.MINOR`
+    })
+  ).toBeInTheDocument();
+
+  // changing issue status
+  expect(screen.getByText('issue.status.OPEN')).toBeInTheDocument();
+
+  await user.click(screen.getByText('issue.status.OPEN'));
+  expect(screen.getByText('issue.transition.confirm')).toBeInTheDocument();
+  expect(screen.getByText('issue.transition.resolve')).toBeInTheDocument();
+
+  await user.click(screen.getByText('issue.transition.confirm'));
+  expect(
+    screen.getByRole('button', {
+      name: `issue.transition.status_x_click_to_change.issue.status.CONFIRMED`
+    })
+  ).toBeInTheDocument();
+  await user.keyboard('{Escape}');
+
+  // assigning issue to a different user
+  expect(
+    screen.getByRole('button', {
+      name: `issue.assign.unassigned_click_to_assign`
+    })
+  ).toBeInTheDocument();
+
+  await user.click(
+    screen.getByRole('button', {
+      name: `issue.assign.unassigned_click_to_assign`
+    })
+  );
+  expect(screen.getByRole('searchbox', { name: 'search_verb' })).toBeInTheDocument();
+
+  await user.click(screen.getByRole('searchbox', { name: 'search_verb' }));
+  await user.keyboard('luke');
+  expect(screen.getByText('Skywalker')).toBeInTheDocument();
+  await user.keyboard('{ArrowUp}{enter}');
+  expect(screen.getByText('luke')).toBeInTheDocument();
+
+  // changing tags
+  expect(screen.getByText('issue.no_tag')).toBeInTheDocument();
+  await user.click(screen.getByText('issue.no_tag'));
+  expect(screen.getByRole('searchbox', { name: 'search_verb' })).toBeInTheDocument();
+  expect(screen.getByText('android')).toBeInTheDocument();
+  expect(screen.getByText('accessibility')).toBeInTheDocument();
+
+  await user.click(screen.getByText('accessibility'));
+  expect(screen.getAllByText('accessibility')).toHaveLength(2); // one in the list of selector and one selected
+
+  await user.click(screen.getByRole('searchbox', { name: 'search_verb' }));
+  await user.keyboard('addNewTag');
+  expect(screen.getByText('+')).toBeInTheDocument();
+  expect(screen.getByText('addnewtag')).toBeInTheDocument();
+});
+
+it('should not allow performing actions when user does not have permission', async () => {
+  const user = userEvent.setup();
+  renderIssueApp();
+
+  await user.click(await screen.findByRole('region', { name: 'Fix this' }));
+
+  expect(
+    screen.queryByRole('button', {
+      name: `issue.assign.unassigned_click_to_assign`
+    })
+  ).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', {
+      name: `issue.type.type_x_click_to_change.issue.type.CODE_SMELL`
+    })
+  ).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', {
+      name: `issue.comment.add_comment`
+    })
+  ).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', {
+      name: `issue.transition.status_x_click_to_change.issue.status.OPEN`
+    })
+  ).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', {
+      name: `issue.severity.severity_x_click_to_change.severity.MAJOR`
+    })
+  ).not.toBeInTheDocument();
+});
+
+it('should open the actions popup using keyboard shortcut', async () => {
+  const user = userEvent.setup();
+  handler.setIsAdmin(true);
+  renderIssueApp();
+
+  // Select an issue with an advanced rule
+  await user.click(await screen.findByRole('region', { name: 'Fix that' }));
+
+  // open severity popup on key press 'i'
+  await user.keyboard('i');
+  expect(screen.getByText('severity.MINOR')).toBeInTheDocument();
+  expect(screen.getByText('severity.INFO')).toBeInTheDocument();
+
+  // open status popup on key press 'f'
+  await user.keyboard('f');
+  expect(screen.getByText('issue.transition.confirm')).toBeInTheDocument();
+  expect(screen.getByText('issue.transition.resolve')).toBeInTheDocument();
+
+  // open comment popup on key press 'c'
+  await user.keyboard('c');
+  expect(screen.getByText('issue.comment.submit')).toBeInTheDocument();
+  await user.click(screen.getByText('cancel'));
+
+  // open tags popup on key press 't'
+  await user.keyboard('t');
+  expect(screen.getByRole('searchbox', { name: 'search_verb' })).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_verb' })).toBeInTheDocument();
+});
+
 describe('redirects', () => {
   it('should work for hotspots', () => {
     renderProjectIssuesApp(`project/issues?types=${IssueType.SecurityHotspot}`);
diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueHeader.tsx
new file mode 100644 (file)
index 0000000..2fe66f7
--- /dev/null
@@ -0,0 +1,167 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 { Link } from 'react-router-dom';
+import { setIssueAssignee } from '../../../api/issues';
+import LinkIcon from '../../../components/icons/LinkIcon';
+import { updateIssue } from '../../../components/issue/actions';
+import IssueActionsBar from '../../../components/issue/components/IssueActionsBar';
+import IssueChangelog from '../../../components/issue/components/IssueChangelog';
+import { getBranchLikeQuery } from '../../../helpers/branch-like';
+import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
+import { KeyboardKeys } from '../../../helpers/keycodes';
+import { translate } from '../../../helpers/l10n';
+import { getComponentIssuesUrl, getRuleUrl } from '../../../helpers/urls';
+import { BranchLike } from '../../../types/branch-like';
+import { Issue, RuleDetails } from '../../../types/types';
+
+interface Props {
+  issue: Issue;
+  ruleDetails: RuleDetails;
+  branchLike?: BranchLike;
+  onIssueChange: (issue: Issue) => void;
+}
+
+interface State {
+  issuePopupName?: string;
+}
+
+export default class IssueHeader extends React.PureComponent<Props, State> {
+  state = { issuePopupName: undefined };
+
+  componentDidMount() {
+    document.addEventListener('keydown', this.handleKeyDown, { capture: true });
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.issue.key !== this.props.issue.key) {
+      this.setState({ issuePopupName: undefined });
+    }
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('keydown', this.handleKeyDown, { capture: true });
+  }
+
+  handleIssuePopupToggle = (popupName: string, open = true) => {
+    const name = open ? popupName : undefined;
+    this.setState({ issuePopupName: name });
+  };
+
+  handleAssignement = (login: string) => {
+    const { issue } = this.props;
+    if (issue.assignee !== login) {
+      updateIssue(
+        this.props.onIssueChange,
+        setIssueAssignee({ issue: issue.key, assignee: login })
+      );
+    }
+    this.handleIssuePopupToggle('assign', false);
+  };
+
+  handleKeyDown = (event: KeyboardEvent) => {
+    if (isInput(event) || isShortcut(event)) {
+      return true;
+    } else if (event.key === KeyboardKeys.KeyF) {
+      event.preventDefault();
+      return this.handleIssuePopupToggle('transition');
+    } else if (event.key === KeyboardKeys.KeyA) {
+      event.preventDefault();
+      return this.handleIssuePopupToggle('assign');
+    } else if (event.key === KeyboardKeys.KeyM && this.props.issue.actions.includes('assign')) {
+      event.preventDefault();
+      return this.handleAssignement('_me');
+    } else if (event.key === KeyboardKeys.KeyI) {
+      event.preventDefault();
+      return this.handleIssuePopupToggle('set-severity');
+    } else if (event.key === KeyboardKeys.KeyC) {
+      event.preventDefault();
+      return this.handleIssuePopupToggle('comment');
+    } else if (event.key === KeyboardKeys.KeyT) {
+      event.preventDefault();
+      return this.handleIssuePopupToggle('edit-tags');
+    }
+    return true;
+  };
+
+  render() {
+    const {
+      issue,
+      ruleDetails: { key, name },
+      branchLike
+    } = this.props;
+    const { issuePopupName } = this.state;
+    const issueUrl = getComponentIssuesUrl(issue.project, {
+      ...getBranchLikeQuery(branchLike),
+      issues: issue.key,
+      open: issue.key,
+      types: issue.type === 'SECURITY_HOTSPOT' ? issue.type : undefined
+    });
+    return (
+      <>
+        <div className="display-flex-center display-flex-space-between big-padded-top">
+          <h1 className="text-bold">{issue.message}</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>
+        </div>
+        <div className="display-flex-center display-flex-space-between spacer-top big-spacer-bottom">
+          <div>
+            <span className="note padded-right">{name}</span>
+            <Link className="small" 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>
+        </div>
+        <IssueActionsBar
+          currentPopup={issuePopupName}
+          issue={issue}
+          onAssign={this.handleAssignement}
+          onChange={this.props.onIssueChange}
+          togglePopup={this.handleIssuePopupToggle}
+        />
+      </>
+    );
+  }
+}
index 95b4a3a3cca5562cd8e19df3cabc0c3665ab54de..303e2339ad192e22a8e68711fba0fbe4a9cf9b39 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { Link } from 'react-router-dom';
 import TabViewer from '../../../components/rules/TabViewer';
-import { getRuleUrl } from '../../../helpers/urls';
+import { BranchLike } from '../../../types/branch-like';
 import { Issue, RuleDetails } from '../../../types/types';
+import IssueHeader from './IssueHeader';
 
 interface IssueViewerTabsProps {
+  branchLike?: BranchLike;
   issue: Issue;
   codeTabContent: React.ReactNode;
   ruleDetails: RuleDetails;
+  onIssueChange: (issue: Issue) => void;
 }
 
 export default function IssueViewerTabs(props: IssueViewerTabsProps) {
-  const {
-    ruleDetails,
-    codeTabContent,
-    ruleDetails: { name, key },
-    issue: { ruleDescriptionContextKey, message }
-  } = props;
+  const { ruleDetails, issue, codeTabContent, branchLike } = props;
   return (
     <>
-      <div className="big-padded-top">
-        <h1 className="text-bold">{message}</h1>
-        <div className="spacer-top big-spacer-bottom">
-          <span className="note padded-right">{name}</span>
-          <Link className="small" to={getRuleUrl(key)} target="_blank">
-            {key}
-          </Link>
-        </div>
-      </div>
+      <IssueHeader
+        issue={issue}
+        ruleDetails={ruleDetails}
+        branchLike={branchLike}
+        onIssueChange={props.onIssueChange}
+      />
       <TabViewer
         ruleDetails={ruleDetails}
         extendedDescription={ruleDetails.htmlNote}
-        ruleDescriptionContextKey={ruleDescriptionContextKey}
+        ruleDescriptionContextKey={issue.ruleDescriptionContextKey}
         codeTabContent={codeTabContent}
         scrollInTab={true}
       />
index e2095c58daa211505b91f964aab80ba4c164c984..2ec1d67ee9a19fa9e5a95d2f0b1b69a8d6153afc 100644 (file)
@@ -1099,7 +1099,6 @@ export class App extends React.PureComponent<Props, State> {
                   branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
                   issues={issues}
                   locationsNavigator={this.state.locationsNavigator}
-                  onIssueChange={this.handleIssueChange}
                   onIssueSelect={this.openIssue}
                   onLocationSelect={this.selectLocation}
                   openIssue={openIssue}
@@ -1109,6 +1108,8 @@ export class App extends React.PureComponent<Props, State> {
               }
               issue={openIssue}
               ruleDetails={openRuleDetails}
+              branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
+              onIssueChange={this.handleIssueChange}
             />
           ) : (
             <DeferredSpinner loading={loading}>
index 9bbdaa767cf9256077c956d443e44a90264de5d0..d6038b1bea34fb9ced40ff133c905e0273564e7b 100644 (file)
@@ -27,7 +27,6 @@ interface Props {
   branchLike: BranchLike | undefined;
   issues: Issue[];
   locationsNavigator: boolean;
-  onIssueChange: (issue: Issue) => void;
   onIssueSelect: (issueKey: string) => void;
   onLocationSelect: (index: number) => void;
   openIssue: Issue;
@@ -91,7 +90,6 @@ export default class IssuesSourceViewer extends React.PureComponent<Props> {
           issue={openIssue}
           issues={this.props.issues}
           locations={locations}
-          onIssueChange={this.props.onIssueChange}
           onIssueSelect={this.props.onIssueSelect}
           onLoaded={this.handleLoaded}
           onLocationSelect={this.props.onLocationSelect}
index 3f7941cf59126c5ebd12c429d0c4f3d094cd73fc..e43a9a96465fec8bc0843b6f3f133f369fd2afb4 100644 (file)
@@ -70,7 +70,6 @@ function shallowRender(props: Partial<IssuesSourceViewer['props']> = {}) {
       branchLike={mockMainBranch()}
       issues={[mockIssue()]}
       locationsNavigator={true}
-      onIssueChange={jest.fn()}
       onIssueSelect={jest.fn()}
       onLocationSelect={jest.fn()}
       openIssue={mockIssue()}
index f4a5d01a214a2012e044b00827004ab5e25ad2d5..b1b99daff70f0e0a6fe8f56cc8a615b046e6f0b7 100644 (file)
@@ -210,7 +210,6 @@ exports[`should render CrossComponentSourceViewer correctly 1`] = `
         },
       ]
     }
-    onIssueChange={[MockFunction]}
     onIssueSelect={[MockFunction]}
     onLoaded={[Function]}
     onLocationSelect={[MockFunction]}
@@ -448,7 +447,6 @@ exports[`should render SourceViewer correctly: all secondary locations on same l
         },
       ]
     }
-    onIssueChange={[MockFunction]}
     onIssueSelect={[MockFunction]}
     onLoaded={[Function]}
     onLocationSelect={[MockFunction]}
@@ -532,7 +530,6 @@ exports[`should render SourceViewer correctly: default 1`] = `
       ]
     }
     locations={Array []}
-    onIssueChange={[MockFunction]}
     onIssueSelect={[MockFunction]}
     onLoaded={[Function]}
     onLocationSelect={[MockFunction]}
@@ -730,7 +727,6 @@ exports[`should render SourceViewer correctly: single secondary location 1`] = `
         },
       ]
     }
-    onIssueChange={[MockFunction]}
     onIssueSelect={[MockFunction]}
     onLoaded={[Function]}
     onLocationSelect={[MockFunction]}
index bbdff07c952dc9915827945ef1cba9f29a5e2641..6f1068173166bfa5225d341d1c84b062a3948686 100644 (file)
@@ -19,8 +19,7 @@
  */
 import * as React from 'react';
 import { getSources } from '../../../api/components';
-import Issue from '../../../components/issue/Issue';
-import SecondaryIssue from '../../../components/issue/SecondaryIssue';
+import IssueMessageBox from '../../../components/issue/IssueMessageBox';
 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
@@ -56,14 +55,11 @@ interface Props {
   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
   isLastOccurenceOfPrimaryComponent: boolean;
   issue: TypeIssue;
-  issuePopup?: { issue: string; name: string };
   issuesByLine: IssuesByLine;
   lastSnippetGroup: boolean;
   loadDuplications: (component: string, line: SourceLine) => void;
   locations: FlowLocation[];
-  onIssueChange: (issue: TypeIssue) => void;
   onIssueSelect: (issueKey: string) => void;
-  onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
   onLocationSelect: (index: number) => void;
   renderDuplicationPopup: (
     component: SourceViewerFile,
@@ -209,13 +205,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
   };
 
   renderIssuesList = (line: SourceLine) => {
-    const {
-      isLastOccurenceOfPrimaryComponent,
-      issue,
-      issuesByLine,
-      snippetGroup,
-      branchLike
-    } = this.props;
+    const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
     const locations =
       issue.component === snippetGroup.component.key && issue.textRange !== undefined
         ? locationsByLine([issue])
@@ -228,34 +218,15 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
 
     return (
       issuesForLine.length > 0 && (
-        <div className="issue-list">
-          {issuesForLine.map(issueToDisplay => {
-            if (issueToDisplay.key === issue.key && issueLocations && issueLocations.length) {
-              return (
-                <Issue
-                  branchLike={branchLike}
-                  displayWhyIsThisAnIssue={false}
-                  issue={issueToDisplay}
-                  key={issueToDisplay.key}
-                  onChange={this.props.onIssueChange}
-                  onPopupToggle={this.props.onIssuePopupToggle}
-                  openPopup={
-                    this.props.issuePopup && this.props.issuePopup.issue === issueToDisplay.key
-                      ? this.props.issuePopup.name
-                      : undefined
-                  }
-                  selected={issue.key === issueToDisplay.key}
-                />
-              );
-            }
-            return (
-              <SecondaryIssue
-                key={issueToDisplay.key}
-                issue={issueToDisplay}
-                onClick={this.props.onIssueSelect}
-              />
-            );
-          })}
+        <div>
+          {issuesForLine.map(issueToDisplay => (
+            <IssueMessageBox
+              selected={!!(issueToDisplay.key === issue.key && issueLocations.length > 0)}
+              key={issueToDisplay.key}
+              issue={issueToDisplay}
+              onClick={this.props.onIssueSelect}
+            />
+          ))}
         </div>
       )
     );
@@ -266,7 +237,6 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
       branchLike,
       isLastOccurenceOfPrimaryComponent,
       issue,
-      issuePopup,
       lastSnippetGroup,
       snippetGroup
     } = this.props;
@@ -301,13 +271,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
         />
 
         {issue.component === snippetGroup.component.key && issue.textRange === undefined && (
-          <Issue
-            issue={issue}
-            onChange={this.props.onIssueChange}
-            onPopupToggle={this.props.onIssuePopupToggle}
-            openPopup={issuePopup && issuePopup.issue === issue.key ? issuePopup.name : undefined}
-            selected={true}
-          />
+          <IssueMessageBox selected={true} issue={issue} onClick={this.props.onIssueSelect} />
         )}
         {snippetLines.map((snippet, index) => (
           <SnippetViewer
index 85ab58c38127cf90e80f677f67c0472fbff472dd..b1ef51e80a0381b40f8f3ebaebbc6169374211c3 100644 (file)
@@ -59,7 +59,6 @@ interface Props {
   issue: Issue;
   issues: Issue[];
   locations: FlowLocation[];
-  onIssueChange: (issue: Issue) => void;
   onIssueSelect: (issueKey: string) => void;
   onLoaded?: () => void;
   onLocationSelect: (index: number) => void;
@@ -71,7 +70,6 @@ interface State {
   duplicatedFiles?: Dict<DuplicatedFile>;
   duplications?: Duplication[];
   duplicationsByLine: { [line: number]: number[] };
-  issuePopup?: { issue: string; name: string };
   loading: boolean;
   notAccessible: boolean;
 }
@@ -145,7 +143,6 @@ export default class CrossComponentSourceViewer extends React.PureComponent<Prop
       if (this.mounted) {
         this.setState({
           components,
-          issuePopup: undefined,
           loading: false
         });
         if (this.props.onLoaded) {
@@ -163,19 +160,6 @@ export default class CrossComponentSourceViewer extends React.PureComponent<Prop
     }
   }
 
-  handleIssuePopupToggle = (issue: string, popupName: string, open?: boolean) => {
-    this.setState((state: State) => {
-      const samePopup =
-        state.issuePopup && state.issuePopup.name === popupName && state.issuePopup.issue === issue;
-      if (open !== false && !samePopup) {
-        return { issuePopup: { issue, name: popupName } };
-      } else if (open !== true && samePopup) {
-        return { issuePopup: undefined };
-      }
-      return null;
-    });
-  };
-
   renderDuplicationPopup = (component: SourceViewerFile, index: number, line: number) => {
     const { duplicatedFiles, duplications } = this.state;
 
@@ -247,15 +231,12 @@ export default class CrossComponentSourceViewer extends React.PureComponent<Prop
                 duplicationsByLine={duplicationsByLine}
                 highlightedLocationMessage={this.props.highlightedLocationMessage}
                 issue={issue}
-                issuePopup={this.state.issuePopup}
                 issuesByLine={issuesByComponent[snippetGroup.component.key] || {}}
                 isLastOccurenceOfPrimaryComponent={i === lastOccurenceOfPrimaryComponent}
                 lastSnippetGroup={i === locationsByComponent.length - 1}
                 loadDuplications={this.fetchDuplications}
                 locations={snippetGroup.locations || []}
-                onIssueChange={this.props.onIssueChange}
                 onIssueSelect={this.props.onIssueSelect}
-                onIssuePopupToggle={this.handleIssuePopupToggle}
                 onLocationSelect={this.props.onLocationSelect}
                 renderDuplicationPopup={this.renderDuplicationPopup}
                 snippetGroup={snippetGroup}
index 0d92fa6b62295192258fe7a5f0d68b96c63f8e2b..30ca9f59af4151052917dbe7c4c847bbbf1de121 100644 (file)
@@ -21,7 +21,7 @@ import { shallow } from 'enzyme';
 import { range, times } from 'lodash';
 import * as React from 'react';
 import { getSources } from '../../../../api/components';
-import Issue from '../../../../components/issue/Issue';
+import IssueMessageBox from '../../../../components/issue/IssueMessageBox';
 import { mockBranch, mockMainBranch } from '../../../../helpers/mocks/branch-like';
 import {
   mockSnippetsByComponent,
@@ -148,7 +148,7 @@ it('should render file-level issue correctly', () => {
     }
   });
 
-  expect(wrapper.find(Issue).exists()).toBe(true);
+  expect(wrapper.find(IssueMessageBox).exists()).toBe(true);
 });
 
 it('should expand block', async () => {
@@ -300,9 +300,7 @@ function shallowRender(props: Partial<ComponentSourceSnippetGroupViewer['props']
       lastSnippetGroup={false}
       loadDuplications={jest.fn()}
       locations={[]}
-      onIssueChange={jest.fn()}
       onIssueSelect={jest.fn()}
-      onIssuePopupToggle={jest.fn()}
       onLocationSelect={jest.fn()}
       renderDuplicationPopup={jest.fn()}
       snippetGroup={snippetGroup}
index 1425e69291cb05827aa59a0a7793948156337efe..11d711d642240ee816614e5ba4a883cf511574d3 100644 (file)
@@ -84,17 +84,6 @@ it('Should handle no access rights', async () => {
   expect(wrapper).toMatchSnapshot();
 });
 
-it('should handle issue popup', () => {
-  const wrapper = shallowRender();
-  // open
-  wrapper.instance().handleIssuePopupToggle('1', 'popup1');
-  expect(wrapper.state('issuePopup')).toEqual({ issue: '1', name: 'popup1' });
-
-  // close
-  wrapper.instance().handleIssuePopupToggle('1', 'popup1');
-  expect(wrapper.state('issuePopup')).toBeUndefined();
-});
-
 it('should handle duplication popup', async () => {
   const files = { b: { key: 'b', name: 'B.tsx', project: 'foo', projectName: 'Foo' } };
   const duplications = [{ blocks: [{ _ref: '1', from: 1, size: 2 }] }];
@@ -134,7 +123,6 @@ function shallowRender(props: Partial<CrossComponentSourceViewer['props']> = {})
       })}
       issues={[]}
       locations={[mockFlowLocation({ component: 'project:main.js' })]}
-      onIssueChange={jest.fn()}
       onLoaded={jest.fn()}
       onIssueSelect={jest.fn()}
       onLocationSelect={jest.fn()}
index 489fd62f4f09ea5d76b8f4fdf537c977a9e679c8..a8911290159868c10ab07dd698e12d4ad08638c7 100644 (file)
@@ -152,8 +152,6 @@ exports[`should render correctly 2`] = `
           },
         ]
       }
-      onIssueChange={[MockFunction]}
-      onIssuePopupToggle={[Function]}
       onIssueSelect={[MockFunction]}
       onLocationSelect={[MockFunction]}
       renderDuplicationPopup={[Function]}
@@ -298,8 +296,6 @@ exports[`should render correctly: no component found 1`] = `
       lastSnippetGroup={false}
       loadDuplications={[Function]}
       locations={Array []}
-      onIssueChange={[MockFunction]}
-      onIssuePopupToggle={[Function]}
       onIssueSelect={[MockFunction]}
       onLocationSelect={[MockFunction]}
       renderDuplicationPopup={[Function]}
@@ -441,8 +437,6 @@ exports[`should render correctly: no component found 1`] = `
           },
         ]
       }
-      onIssueChange={[MockFunction]}
-      onIssuePopupToggle={[Function]}
       onIssueSelect={[MockFunction]}
       onLocationSelect={[MockFunction]}
       renderDuplicationPopup={[Function]}
index 47c14b9c70c5f9a8e6388c1eb302305a58943235..24016edd24df5435b043a79c9d45861db884ba8c 100644 (file)
@@ -88,7 +88,6 @@
 
 .source-line-code {
   position: relative;
-  padding: 0 10px;
 }
 
 .source-line-code pre {
   white-space: pre-wrap;
 }
 
-.source-line-code .issue-list {
-  margin-left: -10px;
-  margin-right: -10px;
-}
-
 .source-line-code-inner {
   min-height: 18px;
+  padding: 0 10px;
 }
 
 .source-line-code-inner:before,
index f881849a491f9092605c64e99b566d9fd4d53e2b..7567b6de9aecb2dcb11a260cddea3104f2f30efa 100644 (file)
   cursor: initial;
 }
 
-.issue.secondary-issue {
-  background-color: var(--secondIssueBgColor);
-}
-
 .issue.hotspot {
   background-color: var(--hotspotBgColor);
 }
 
-.issue.selected {
+.issue.selected,
+.issue-message-box.selected {
   box-shadow: none;
   outline: none;
   border: 2px solid var(--blue) !important;
@@ -80,7 +77,6 @@
   justify-content: space-between;
   flex-wrap: wrap;
   align-items: center;
-  padding-left: var(--gridSize);
 }
 
 .issue-meta-list {
 .issue .badge-error {
   background-color: var(--badgeRedBackgroundOnIssue);
 }
+
+.issue-message-box {
+  background-color: var(--issueBgColor);
+  border: 2px solid transparent;
+  margin: 10px 0px;
+}
+
+.issue-message-box.secondary-issue {
+  background-color: var(--secondIssueBgColor);
+}
+
+.issue-message-box.secondary-issue:hover {
+  border: 2px dashed var(--blue);
+  outline: 0;
+  cursor: pointer;
+}
+
+.issue-get-perma-link {
+  flex-shrink: 0;
+}
diff --git a/server/sonar-web/src/main/js/components/issue/IssueMessageBox.tsx b/server/sonar-web/src/main/js/components/issue/IssueMessageBox.tsx
new file mode 100644 (file)
index 0000000..a24c0bf
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 classNames from 'classnames';
+import * as React from 'react';
+import { colors } from '../../app/theme';
+import { Issue } from '../../types/types';
+import IssueTypeIcon from '../icons/IssueTypeIcon';
+import './Issue.css';
+
+export interface IssueMessageBoxProps {
+  selected: boolean;
+  issue: Issue;
+  onClick: (issueKey: string) => void;
+}
+
+export default function IssueMessageBox(props: IssueMessageBoxProps) {
+  const { issue, selected } = props;
+  return (
+    <div
+      className={classNames('issue-message-box display-flex-row display-flex-center padded-right', {
+        'selected big-padded-top big-padded-bottom': selected,
+        'secondary-issue padded-top padded-bottom': !selected
+      })}
+      key={issue.key}
+      onClick={() => props.onClick(issue.key)}
+      role="region"
+      aria-label={issue.message}>
+      <IssueTypeIcon
+        className="big-spacer-right spacer-left"
+        fill={colors.baseFontColor}
+        query={issue.type}
+      />
+      {issue.message}
+    </div>
+  );
+}
index 3b8997144f42cae3722794faff93f4be2afec3a8..71224db95a3d74aba2932ad77b66d77563b8ad71 100644 (file)
@@ -106,6 +106,7 @@ export default class IssueView extends React.PureComponent<Props> {
           togglePopup={this.props.togglePopup}
         />
         <IssueActionsBar
+          className="padded-left"
           currentPopup={currentPopup}
           issue={issue}
           onAssign={this.props.onAssign}
diff --git a/server/sonar-web/src/main/js/components/issue/SecondaryIssue.tsx b/server/sonar-web/src/main/js/components/issue/SecondaryIssue.tsx
deleted file mode 100644 (file)
index 8976390..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2022 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 { colors } from '../../app/theme';
-import { Issue as TypeIssue } from '../../types/types';
-import IssueTypeIcon from '../icons/IssueTypeIcon';
-import './Issue.css';
-
-export interface SecondaryIssueProps {
-  issue: TypeIssue;
-  onClick: (issueKey: string) => void;
-}
-
-export default function SecondaryIssue(props: SecondaryIssueProps) {
-  const { issue } = props;
-  return (
-    <div
-      className="issue display-flex-row display-flex-center padded-right secondary-issue"
-      key={issue.key}
-      onClick={() => props.onClick(issue.key)}
-      role="region"
-      aria-label={issue.message}>
-      <IssueTypeIcon
-        className="big-spacer-right spacer-left"
-        fill={colors.baseFontColor}
-        query={issue.type}
-      />
-      {issue.message}
-    </div>
-  );
-}
index 2a6454178971556f7c622a175565e02bc3429f81..e4ed6754c065e0004990e7b76028fbc8f552fdbc 100644 (file)
@@ -43,6 +43,7 @@ exports[`should render hotspots correctly 1`] = `
     togglePopup={[MockFunction]}
   />
   <IssueActionsBar
+    className="padded-left"
     issue={
       Object {
         "actions": Array [],
@@ -138,6 +139,7 @@ exports[`should render issues correctly 1`] = `
     togglePopup={[MockFunction]}
   />
   <IssueActionsBar
+    className="padded-left"
     issue={
       Object {
         "actions": Array [],
index 6f533cb76aaed55c9ac834f5313e69b150062108..e50dc6a900e9907d892495d2dc358033c50f5da5 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import classNames from 'classnames';
 import * as React from 'react';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { IssueResponse } from '../../../types/issues';
@@ -35,6 +36,7 @@ interface Props {
   onAssign: (login: string) => void;
   onChange: (issue: Issue) => void;
   togglePopup: (popup: string, show?: boolean) => void;
+  className?: string;
 }
 
 interface State {
@@ -86,7 +88,7 @@ export default class IssueActionsBar extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { issue } = this.props;
+    const { issue, className } = this.props;
     const canAssign = issue.actions.includes('assign');
     const canComment = issue.actions.includes('comment');
     const canSetSeverity = issue.actions.includes('set_severity');
@@ -96,7 +98,7 @@ export default class IssueActionsBar extends React.PureComponent<Props, State> {
     const isSecurityHotspot = issue.type === 'SECURITY_HOTSPOT';
 
     return (
-      <div className="issue-actions">
+      <div className={classNames(className, 'issue-actions')}>
         <div className="issue-meta-list">
           <div className="issue-meta">
             <IssueType
index eca49843e045985ca21b94c32ba7354044624aec..33e71519a3596424621444c0b0cc57441f5d8d7f 100644 (file)
@@ -43,6 +43,8 @@ interface Comment {
 
 export interface RawIssue {
   actions: string[];
+  transitions?: string[];
+  tags?: string[];
   assignee?: string;
   author?: string;
   comments?: Array<Comment>;
index 69ce21fc96eed05a3e91266b0cc82d04ffaabfd8..308aad4f448f5f7b063d077f8729de629f5a3a48 100644 (file)
@@ -904,6 +904,7 @@ issue.resolution.REMOVED.rule_removed=Rule removed
 issue.resolution.DEPRECATED.rule_deprecated=Rule deprecated
 issue.unresolved.description=Unresolved issues have not been addressed in any way.
 
+issue.action.permalink=Get permalink
 issue.effort=Effort:
 issue.x_effort={0} effort
 issue.filter_similar_issues=Filter Similar Issues