]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16303 Display rule description header and tabs for issues
authorPhilippe Perrin <philippe.perrin@sonarsource.com>
Wed, 11 May 2022 14:52:26 +0000 (16:52 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 24 May 2022 20:10:14 +0000 (20:10 +0000)
server/sonar-web/src/main/js/apps/issues/components/IssueRuleHeader.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/IssuesApp-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap
server/sonar-web/src/main/js/types/types.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueRuleHeader.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueRuleHeader.tsx
new file mode 100644 (file)
index 0000000..74584d2
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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';
+import { getRuleUrl } from '../../../helpers/urls';
+import { Issue, RuleDetails } from '../../../types/types';
+
+interface IssueRuleHeaderProps {
+  ruleDetails: RuleDetails;
+  issue: Issue;
+}
+
+export default function IssueRuleHeader(props: IssueRuleHeaderProps) {
+  const {
+    ruleDetails: { name, key },
+    issue: { message }
+  } = props;
+
+  return (
+    <>
+      <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>
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueTabViewer.tsx
new file mode 100644 (file)
index 0000000..f0db6bb
--- /dev/null
@@ -0,0 +1,122 @@
+/*
+ * 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 BoxedTabs from '../../../components/controls/BoxedTabs';
+import { translate } from '../../../helpers/l10n';
+import { sanitizeString } from '../../../helpers/sanitize';
+import { RuleDescriptionSections, RuleDetails } from '../../../types/types';
+
+interface Props {
+  codeTabContent: React.ReactNode;
+  ruleDetails: RuleDetails;
+}
+
+interface State {
+  currentTabKey: TabKeys;
+  tabs: Tab[];
+}
+
+interface Tab {
+  key: TabKeys;
+  label: React.ReactNode;
+  content: string;
+}
+
+export enum TabKeys {
+  Code = 'code',
+  WhyIsThisAnIssue = 'why',
+  HowToFixIt = 'how',
+  Resources = 'resources'
+}
+
+export default class IssueViewerTabs extends React.PureComponent<Props, State> {
+  constructor(props: Props) {
+    super(props);
+    const tabs = this.computeTabs();
+    this.state = {
+      currentTabKey: tabs[0].key,
+      tabs
+    };
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.ruleDetails !== this.props.ruleDetails) {
+      const tabs = this.computeTabs();
+      this.setState({
+        currentTabKey: tabs[0].key,
+        tabs
+      });
+    }
+  }
+
+  handleSelectTabs = (currentTabKey: TabKeys) => {
+    this.setState({ currentTabKey });
+  };
+
+  computeTabs() {
+    const { ruleDetails } = this.props;
+
+    return [
+      {
+        key: TabKeys.Code,
+        label: translate('issue.tabs.code'),
+        content: ''
+      },
+      {
+        key: TabKeys.WhyIsThisAnIssue,
+        label: translate('issue.tabs.why'),
+        content:
+          ruleDetails.descriptionSections?.find(
+            section => section.key === RuleDescriptionSections.DEFAULT
+          )?.content ?? ''
+      }
+    ];
+  }
+
+  render() {
+    const { codeTabContent } = this.props;
+    const { tabs, currentTabKey } = this.state;
+
+    return (
+      <>
+        <BoxedTabs onSelect={this.handleSelectTabs} selected={currentTabKey} tabs={tabs} />
+        <div className="bordered huge-spacer-bottom">
+          <div
+            className={classNames('padded', {
+              hidden: currentTabKey !== TabKeys.Code
+            })}>
+            {codeTabContent}
+          </div>
+          {tabs.slice(1).map(tab => (
+            <div
+              key={tab.key}
+              className={classNames('markdown big-padded', {
+                hidden: currentTabKey !== tab.key
+              })}
+              // eslint-disable-next-line react/no-danger
+              dangerouslySetInnerHTML={{ __html: sanitizeString(tab.content) }}
+            />
+          ))}
+        </div>
+      </>
+    );
+  }
+}
index cf030276b8a1bdaa39a384797bad9c052e7aabec..e3c5384f700bcd501f313a1b1b8f3d01186102c0 100644 (file)
@@ -23,6 +23,7 @@ import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import { FormattedMessage } from 'react-intl';
 import { searchIssues } from '../../../api/issues';
+import { getRuleDetails } from '../../../api/rules';
 import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
 import EmptySearch from '../../../components/common/EmptySearch';
 import FiltersHeader from '../../../components/common/FiltersHeader';
@@ -63,7 +64,7 @@ import {
   ReferencedRule
 } from '../../../types/issues';
 import { SecurityStandard } from '../../../types/security';
-import { Component, Dict, Issue, Paging, RawQuery } from '../../../types/types';
+import { Component, Dict, Issue, Paging, RawQuery, RuleDetails } from '../../../types/types';
 import { CurrentUser, UserBase } from '../../../types/users';
 import * as actions from '../actions';
 import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList';
@@ -87,8 +88,10 @@ import {
   STANDARDS
 } from '../utils';
 import BulkChangeModal, { MAX_PAGE_SIZE } from './BulkChangeModal';
+import IssueRuleHeader from './IssueRuleHeader';
 import IssuesList from './IssuesList';
 import IssuesSourceViewer from './IssuesSourceViewer';
+import IssueTabViewer from './IssueTabViewer';
 import MyIssuesFilter from './MyIssuesFilter';
 import NoIssues from './NoIssues';
 import NoMyIssues from './NoMyIssues';
@@ -112,6 +115,7 @@ export interface State {
   facets: Dict<Facet>;
   issues: Issue[];
   loading: boolean;
+  loadingRule: boolean;
   loadingFacets: Dict<boolean>;
   loadingMore: boolean;
   locationsNavigator: boolean;
@@ -119,6 +123,7 @@ export interface State {
   openFacets: Dict<boolean>;
   openIssue?: Issue;
   openPopup?: { issue: string; name: string };
+  openRuleDetails?: RuleDetails;
   paging?: Paging;
   query: Query;
   referencedComponentsById: Dict<ReferencedComponent>;
@@ -148,6 +153,7 @@ export default class App extends React.PureComponent<Props, State> {
       issues: [],
       loading: true,
       loadingFacets: {},
+      loadingRule: false,
       loadingMore: false,
       locationsNavigator: false,
       myIssues: areMyIssuesSelected(props.location.query),
@@ -229,6 +235,9 @@ export default class App extends React.PureComponent<Props, State> {
         selectedLocationIndex: undefined
       });
     }
+    if (this.state.openIssue && this.state.openIssue.key !== prevState.openIssue?.key) {
+      this.loadRule();
+    }
   }
 
   componentWillUnmount() {
@@ -328,6 +337,20 @@ export default class App extends React.PureComponent<Props, State> {
     }
   };
 
+  async loadRule() {
+    const { openIssue } = this.state;
+    if (openIssue === undefined) {
+      return;
+    }
+    this.setState({ loadingRule: true });
+    const openRuleDetails = await getRuleDetails({ key: openIssue.rule })
+      .then(response => response.rule)
+      .catch(() => undefined);
+    if (this.mounted) {
+      this.setState({ loadingRule: false, openRuleDetails });
+    }
+  }
+
   selectPreviousIssue = () => {
     const { issues } = this.state;
     const selectedIndex = this.getSelectedIndex();
@@ -1086,44 +1109,63 @@ export default class App extends React.PureComponent<Props, State> {
   }
 
   renderPage() {
-    const { cannotShowOpenIssue, checkAll, issues, loading, openIssue, paging } = this.state;
+    const {
+      cannotShowOpenIssue,
+      openRuleDetails,
+      checkAll,
+      issues,
+      loading,
+      openIssue,
+      paging,
+      loadingRule
+    } = this.state;
     return (
       <div className="layout-page-main-inner">
-        {openIssue ? (
-          <IssuesSourceViewer
-            branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
-            issues={issues}
-            loadIssues={this.fetchIssuesForComponent}
-            locationsNavigator={this.state.locationsNavigator}
-            onIssueChange={this.handleIssueChange}
-            onIssueSelect={this.openIssue}
-            onLocationSelect={this.selectLocation}
-            openIssue={openIssue}
-            selectedFlowIndex={this.state.selectedFlowIndex}
-            selectedLocationIndex={this.state.selectedLocationIndex}
-          />
-        ) : (
-          <DeferredSpinner loading={loading}>
-            {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
-              <Alert className="big-spacer-bottom" variant="warning">
-                <FormattedMessage
-                  defaultMessage={translate('issue_bulk_change.max_issues_reached')}
-                  id="issue_bulk_change.max_issues_reached"
-                  values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
-                />
-              </Alert>
-            )}
-            {cannotShowOpenIssue && (!paging || paging.total > 0) && (
-              <Alert className="big-spacer-bottom" variant="warning">
-                {translateWithParameters(
-                  'issues.cannot_open_issue_max_initial_X_fetched',
-                  MAX_INITAL_FETCH
-                )}
-              </Alert>
-            )}
-            {this.renderList()}
-          </DeferredSpinner>
-        )}
+        <DeferredSpinner loading={loadingRule}>
+          {openIssue && openRuleDetails ? (
+            <>
+              <IssueRuleHeader ruleDetails={openRuleDetails} issue={openIssue} />
+              <IssueTabViewer
+                codeTabContent={
+                  <IssuesSourceViewer
+                    branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
+                    issues={issues}
+                    loadIssues={this.fetchIssuesForComponent}
+                    locationsNavigator={this.state.locationsNavigator}
+                    onIssueChange={this.handleIssueChange}
+                    onIssueSelect={this.openIssue}
+                    onLocationSelect={this.selectLocation}
+                    openIssue={openIssue}
+                    selectedFlowIndex={this.state.selectedFlowIndex}
+                    selectedLocationIndex={this.state.selectedLocationIndex}
+                  />
+                }
+                ruleDetails={openRuleDetails}
+              />
+            </>
+          ) : (
+            <DeferredSpinner loading={loading}>
+              {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
+                <Alert className="big-spacer-bottom" variant="warning">
+                  <FormattedMessage
+                    defaultMessage={translate('issue_bulk_change.max_issues_reached')}
+                    id="issue_bulk_change.max_issues_reached"
+                    values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
+                  />
+                </Alert>
+              )}
+              {cannotShowOpenIssue && (!paging || paging.total > 0) && (
+                <Alert className="big-spacer-bottom" variant="warning">
+                  {translateWithParameters(
+                    'issues.cannot_open_issue_max_initial_X_fetched',
+                    MAX_INITAL_FETCH
+                  )}
+                </Alert>
+              )}
+              {this.renderList()}
+            </DeferredSpinner>
+          )}
+        </DeferredSpinner>
       </div>
     );
   }
index 69bd17793da8be34e1ba89280073e115f26ec71d..b1f3ebc1418b25cd8225a70ea67a37757be2a3f0 100644 (file)
@@ -20,6 +20,7 @@
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import { searchIssues } from '../../../../api/issues';
+import { getRuleDetails } from '../../../../api/rules';
 import handleRequiredAuthentication from '../../../../helpers/handleRequiredAuthentication';
 import { KeyboardCodes, KeyboardKeys } from '../../../../helpers/keycodes';
 import { mockPullRequest } from '../../../../helpers/mocks/branch-like';
@@ -54,6 +55,7 @@ import {
 import BulkChangeModal from '../BulkChangeModal';
 import App from '../IssuesApp';
 import IssuesSourceViewer from '../IssuesSourceViewer';
+import IssueViewerTabs from '../IssueTabViewer';
 
 jest.mock('../../../../helpers/pages', () => ({
   addSideBarClass: jest.fn(),
@@ -68,6 +70,10 @@ jest.mock('../../../../api/issues', () => ({
   searchIssues: jest.fn().mockResolvedValue({ facets: [], issues: [] })
 }));
 
+jest.mock('../../../../api/rules', () => ({
+  getRuleDetails: jest.fn()
+}));
+
 const RAW_ISSUES = [
   mockRawIssue(false, { key: 'foo' }),
   mockRawIssue(false, { key: 'bar' }),
@@ -96,10 +102,12 @@ beforeEach(() => {
     rules: [],
     users: []
   });
+
+  (getRuleDetails as jest.Mock).mockResolvedValue({ rule: { test: 'test' } });
 });
 
 afterEach(() => {
-  jest.clearAllMocks();
+  // jest.clearAllMocks();
   (searchIssues as jest.Mock).mockReset();
 });
 
@@ -195,11 +203,17 @@ it('should open standard facets for vulnerabilities and hotspots', () => {
 it('should switch to source view if an issue is selected', async () => {
   const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
-  expect(wrapper.find(IssuesSourceViewer).exists()).toBe(false);
+  expect(wrapper.find(IssueViewerTabs).exists()).toBe(false);
 
   wrapper.setProps({ location: mockLocation({ query: { open: 'third' } }) });
   await waitAndUpdate(wrapper);
-  expect(wrapper.find(IssuesSourceViewer).exists()).toBe(true);
+  expect(
+    wrapper
+      .find(IssueViewerTabs)
+      .dive()
+      .find(IssuesSourceViewer)
+      .exists()
+  ).toBe(true);
 });
 
 it('should correctly bind key events for issue navigation', async () => {
@@ -505,6 +519,7 @@ it('should update the open issue when it is changed', async () => {
   await waitAndUpdate(wrapper);
 
   const issue = wrapper.state().issues[0];
+
   wrapper.setProps({ location: mockLocation({ query: { open: issue.key } }) });
   await waitAndUpdate(wrapper);
 
index 4d335618427fdb71d741c6487d0edae1b944542a..4adab7cdcb545ca0eb864f928e5c2d823b0333bd 100644 (file)
@@ -176,7 +176,7 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
                 hidden: currentTab.key !== tab.key
               })}
               // eslint-disable-next-line react/no-danger
-              dangerouslySetInnerHTML={{ __html: sanitizeString(currentTab.content) }}
+              dangerouslySetInnerHTML={{ __html: sanitizeString(tab.content) }}
             />
           ))}
         </div>
index ae95c4feab2ac11a11027edcb27314b2b870a926..8fe81cb36647cf87acdcca2e4e37027c48d686c8 100644 (file)
@@ -44,7 +44,7 @@ exports[`should render correctly: fix 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
+          "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
         }
       }
       key="risk"
@@ -53,7 +53,7 @@ exports[`should render correctly: fix 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
+          "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
         }
       }
       key="vulnerability"
@@ -115,7 +115,7 @@ exports[`should render correctly: risk 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "",
+          "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
         }
       }
       key="risk"
@@ -124,7 +124,7 @@ exports[`should render correctly: risk 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "",
+          "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
         }
       }
       key="vulnerability"
@@ -133,7 +133,7 @@ exports[`should render correctly: risk 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "",
+          "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
         }
       }
       key="fix"
@@ -186,7 +186,7 @@ exports[`should render correctly: vulnerability 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+          "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
         }
       }
       key="risk"
@@ -204,7 +204,7 @@ exports[`should render correctly: vulnerability 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+          "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
         }
       }
       key="fix"
@@ -257,7 +257,7 @@ exports[`should render correctly: with comments or changelog element 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "",
+          "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
         }
       }
       key="risk"
@@ -266,7 +266,7 @@ exports[`should render correctly: with comments or changelog element 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "",
+          "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
         }
       }
       key="vulnerability"
@@ -275,7 +275,7 @@ exports[`should render correctly: with comments or changelog element 1`] = `
       className="markdown big-padded hidden"
       dangerouslySetInnerHTML={
         Object {
-          "__html": "",
+          "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
         }
       }
       key="fix"
index 80a7c7eaa28e7c523e7c403f0694133efa2e1d8e..890237e2040b069180f0ba0e842a7fc8be5583aa 100644 (file)
@@ -573,7 +573,6 @@ export enum RuleDescriptionSections {
   HOW_TO_FIX = 'how_to_fix',
   RESOURCES = 'resources'
 }
-
 export interface RuleDescriptionSection {
   key: RuleDescriptionSections;
   content: string;
@@ -603,8 +602,8 @@ export interface RuleDetails extends Rule {
   defaultDebtRemFnType?: string;
   defaultRemFnBaseEffort?: string;
   defaultRemFnType?: string;
-  effortToFixDescription?: string;
   descriptionSections?: RuleDescriptionSection[];
+  effortToFixDescription?: string;
   htmlDesc?: string;
   htmlNote?: string;
   internalKey?: string;
index 2c67289ef8be4a4e94573c2ca01580bb2a09d8e2..6adedd92aaeaafdd408c5f1cb40fe0d2f8a1d8eb 100644 (file)
@@ -851,6 +851,11 @@ issue.transition.resolveasreviewed=Resolve as Reviewed
 issue.transition.resolveasreviewed.description=There is no Vulnerability in the code
 issue.transition.resetastoreview=Reset as To Review
 issue.transition.resetastoreview.description=The Security Hotspot should be analyzed again
+issue.tabs.code=Where is the issue?
+issue.tabs.why=Why is this an issue?
+issue.tabs.how=How to fix it?
+issue.tabs.resources=Resources
+
 vulnerability.transition.resetastoreview=Reset as To Review
 vulnerability.transition.resetastoreview.description=The vulnerability can't be fixed as is and needs more details. The security hotspot needs to be reviewed again
 vulnerability.transition.resolveasreviewed=Resolve as Reviewed