]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12720 Review tab displays the changelog of the hotspot
authorPhilippe Perrin <philippe.perrin@sonarsource.com>
Tue, 17 Dec 2019 17:49:19 +0000 (18:49 +0100)
committerSonarTech <sonartech@sonarsource.com>
Mon, 13 Jan 2020 19:46:32 +0000 (20:46 +0100)
29 files changed:
server/sonar-web/src/main/js/api/security-hotspots.ts
server/sonar-web/src/main/js/app/styles/components/badges.css
server/sonar-web/src/main/js/app/theme.js
server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.css
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainer.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap
server/sonar-web/src/main/js/apps/securityHotspots/utils.ts
server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts
server/sonar-web/src/main/js/types/security-hotspots.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 5714f9834b03bd37eaca251704efc3d1a2eda6ae..cd4bd24150a43c0735984f8329ba81e019bcde63 100644 (file)
@@ -21,7 +21,7 @@ import { getJSON, post } from 'sonar-ui-common/helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
 import { BranchParameters } from '../types/branch-like';
 import {
-  DetailedHotspot,
+  Hotspot,
   HotspotAssignRequest,
   HotspotResolution,
   HotspotSearchResponse,
@@ -58,6 +58,6 @@ export function getSecurityHotspots(
   return getJSON('/api/hotspots/search', data).catch(throwGlobalError);
 }
 
-export function getSecurityHotspotDetails(securityHotspotKey: string): Promise<DetailedHotspot> {
+export function getSecurityHotspotDetails(securityHotspotKey: string): Promise<Hotspot> {
   return getJSON('/api/hotspots/show', { hotspot: securityHotspotKey }).catch(throwGlobalError);
 }
index 807cfa0a910f9ded3e542ace636de319148ecba5..58dfd9b7a293a4af28f39dc549f7f26d52588f7c 100644 (file)
@@ -69,3 +69,14 @@ a.badge {
   background-color: var(--alertBackgroundError);
   color: var(--alertTextError);
 }
+
+.counter-badge {
+  color: var(--badgeBlueColor);
+  background-color: var(--badgeBlueBackground);
+  padding: calc(var(--gridSize) / 2) var(--gridSize);
+  border-radius: 1em;
+}
+
+.counter-badge:empty {
+  display: none;
+}
index 8b2de50ca238d71939a2f7bcb497a8cd4e646b98..48b05a128106b76a9b3ea67335505c6d27e58d0f 100644 (file)
@@ -103,6 +103,10 @@ module.exports = {
     alertTextInfo: '#0e516f',
     alertIconInfo: '#0271b9',
 
+    // badge
+    badgeBlueBackground: '#2E7CB5',
+    badgeBlueColor: '#FFFFFF',
+
     // alm
     azure: '#0078d7',
     bitbucket: '#0052CC',
index 3d4fa35952482b8df3f951830b6a5eaacae7502d..cf0ec56d0d7b98fb3bbc0206e23699a52b896b28 100644 (file)
@@ -17,9 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { mockRawHotspot } from '../../../helpers/mocks/security-hotspots';
-import { RiskExposure } from '../../../types/security-hotspots';
-import { groupByCategory, mapRules, sortHotspots } from '../utils';
+import { mockHotspot, mockRawHotspot } from '../../../helpers/mocks/security-hotspots';
+import { ReviewHistoryType, RiskExposure } from '../../../types/security-hotspots';
+import { getHotspotReviewHistory, groupByCategory, mapRules, sortHotspots } from '../utils';
 
 const hotspots = [
   mockRawHotspot({
@@ -142,3 +142,51 @@ describe('mapRules', () => {
     });
   });
 });
+
+describe('getHotspotReviewHistory', () => {
+  it('should properly create the review history', () => {
+    const changelogElement: T.IssueChangelog = {
+      creationDate: '2018-10-01',
+      isUserActive: true,
+      user: 'me',
+      userName: 'me-name',
+      diffs: [
+        {
+          key: 'assign',
+          newValue: 'me',
+          oldValue: 'him'
+        }
+      ]
+    };
+    const hotspot = mockHotspot({
+      creationDate: '2018-09-01',
+      changelog: [changelogElement]
+    });
+    const history = getHotspotReviewHistory(hotspot);
+
+    expect(history.length).toBe(2);
+    expect(history[0]).toEqual(
+      expect.objectContaining({
+        type: ReviewHistoryType.Creation,
+        date: hotspot.creationDate,
+        user: {
+          avatar: hotspot.author.avatar,
+          name: hotspot.author.name,
+          active: hotspot.author.active
+        }
+      })
+    );
+    expect(history[1]).toEqual(
+      expect.objectContaining({
+        type: ReviewHistoryType.Diff,
+        date: changelogElement.creationDate,
+        user: {
+          avatar: changelogElement.avatar,
+          name: changelogElement.userName,
+          active: changelogElement.isUserActive
+        },
+        diffs: changelogElement.diffs
+      })
+    );
+  });
+});
index 783129cf176e1dbb0f0647aa5fdce91e22344a3d..bca775c9ad78b0c8ecd144800249446190f5b853 100644 (file)
@@ -24,11 +24,11 @@ import OutsideClickHandler from 'sonar-ui-common/components/controls/OutsideClic
 import DropdownIcon from 'sonar-ui-common/components/icons/DropdownIcon';
 import { PopupPlacement } from 'sonar-ui-common/components/ui/popups';
 import { translate } from 'sonar-ui-common/helpers/l10n';
-import { DetailedHotspot, HotspotUpdateFields } from '../../../types/security-hotspots';
+import { Hotspot, HotspotUpdateFields } from '../../../types/security-hotspots';
 import HotspotActionsForm from './HotspotActionsForm';
 
 export interface HotspotActionsProps {
-  hotspot: DetailedHotspot;
+  hotspot: Hotspot;
   onSubmit: (hotspot: HotspotUpdateFields) => void;
 }
 
index e4c32574ac23efa2e553b44bc138452dde7d0e4e..91b142503e47413f928667d638dcedbbb13bfdd4 100644 (file)
@@ -53,7 +53,7 @@ export default function HotspotCategory(props: HotspotCategoryProps) {
         onClick={() => setExpanded(!expanded)}>
         <strong className="flex-1">{category.title}</strong>
         <span>
-          <span className="hotspot-counter">{hotspots.length}</span>
+          <span className="counter-badge">{hotspots.length}</span>
           {expanded ? (
             <ChevronUpIcon className="big-spacer-left" />
           ) : (
index e8f6972ca4330df2623fff4737bb10d0379882ad..242baa85c3027725b27e9c2061164b847583354b 100644 (file)
   cursor: unset;
 }
 
-.hotspot-counter {
-  color: var(--baseFontColor);
-  background-color: var(--gray94);
-  border-radius: 50%;
-  padding: calc(var(--gridSize) / 2) var(--gridSize);
-}
-
 .hotspot-risk-badge {
   color: white;
   text-transform: uppercase;
index 5974f278b7fb3d98f69f600714c0539e9a0244d0..c9afaceffa53a9973e1a6264a5f10a272fea943f 100644 (file)
@@ -22,13 +22,13 @@ import { getSources } from '../../../api/components';
 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
 import { BranchLike } from '../../../types/branch-like';
-import { DetailedHotspot } from '../../../types/security-hotspots';
+import { Hotspot } from '../../../types/security-hotspots';
 import { constructSourceViewerFile } from '../utils';
 import HotspotSnippetContainerRenderer from './HotspotSnippetContainerRenderer';
 
 interface Props {
   branchLike?: BranchLike;
-  hotspot: DetailedHotspot;
+  hotspot: Hotspot;
 }
 
 interface State {
index d8dd52cabeaf4a29c37177cae15fa561c8a06b61..03f708f70ac27805c43a6396a2618301da5d7035 100644 (file)
@@ -22,13 +22,13 @@ import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
 import { SourceViewerContext } from '../../../components/SourceViewer/SourceViewerContext';
 import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
 import { BranchLike } from '../../../types/branch-like';
-import { DetailedHotspot } from '../../../types/security-hotspots';
+import { Hotspot } from '../../../types/security-hotspots';
 import SnippetViewer from '../../issues/crossComponentSourceViewer/SnippetViewer';
 
 export interface HotspotSnippetContainerRendererProps {
   branchLike?: BranchLike;
   highlightedSymbols: string[];
-  hotspot: DetailedHotspot;
+  hotspot: Hotspot;
   lastLine?: number;
   loading: boolean;
   locations: { [line: number]: T.LinearIssueLocation[] };
index 1b66d70803f5a968e8440964a26b748e4f236c64..4adcd768d32496f48518c17177dca2931093d841 100644 (file)
 import * as React from 'react';
 import { getSecurityHotspotDetails } from '../../../api/security-hotspots';
 import { BranchLike } from '../../../types/branch-like';
-import {
-  DetailedHotspot,
-  HotspotUpdate,
-  HotspotUpdateFields
-} from '../../../types/security-hotspots';
+import { Hotspot, HotspotUpdate, HotspotUpdateFields } from '../../../types/security-hotspots';
 import HotspotViewerRenderer from './HotspotViewerRenderer';
 
 interface Props {
@@ -36,14 +32,15 @@ interface Props {
 }
 
 interface State {
-  hotspot?: DetailedHotspot;
+  hotspot?: Hotspot;
   loading: boolean;
 }
 
 export default class HotspotViewer extends React.PureComponent<Props, State> {
   mounted = false;
+  state: State = { loading: false };
 
-  componentWillMount() {
+  componentDidMount() {
     this.mounted = true;
     this.fetchHotspot();
   }
@@ -61,8 +58,8 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
   fetchHotspot() {
     this.setState({ loading: true });
     return getSecurityHotspotDetails(this.props.hotspotKey)
-      .then(hotspot => this.mounted && this.setState({ hotspot }))
-      .finally(() => this.mounted && this.setState({ loading: false }));
+      .then(hotspot => this.mounted && this.setState({ hotspot, loading: false }))
+      .catch(() => this.mounted && this.setState({ loading: false }));
   }
 
   handleHotspotUpdate = (data: HotspotUpdateFields) => {
index 8ce753caee8435d71ad93065c0d59a143dd400d7..cf2b54a7e6e4bd011b1f83b03f51fb6f98be6f46 100644 (file)
@@ -23,7 +23,7 @@ import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n
 import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
 import { isLoggedIn } from '../../../helpers/users';
 import { BranchLike } from '../../../types/branch-like';
-import { DetailedHotspot, HotspotUpdateFields } from '../../../types/security-hotspots';
+import { Hotspot, HotspotUpdateFields } from '../../../types/security-hotspots';
 import HotspotActions from './HotspotActions';
 import HotspotSnippetContainer from './HotspotSnippetContainer';
 import HotspotViewerTabs from './HotspotViewerTabs';
@@ -31,7 +31,7 @@ import HotspotViewerTabs from './HotspotViewerTabs';
 export interface HotspotViewerRendererProps {
   branchLike?: BranchLike;
   currentUser: T.CurrentUser;
-  hotspot?: DetailedHotspot;
+  hotspot?: Hotspot;
   loading: boolean;
   onUpdateHotspot: (hotspot: HotspotUpdateFields) => void;
   securityCategories: T.StandardSecurityCategories;
@@ -52,20 +52,20 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
               )}
             </div>
             <div className="text-muted">
-              <span>{translate('hotspot.category')}</span>
+              <span>{translate('category')}:</span>
               <span className="little-spacer-left">
                 {securityCategories[hotspot.rule.securityCategory].title}
               </span>
             </div>
           </div>
           <div className="huge-spacer-bottom">
-            <span>{translate('hotspot.status')}</span>
+            <span>{translate('status')}:</span>
             <span className="badge little-spacer-left">
               {translate('hotspot.status', hotspot.resolution || hotspot.status)}
             </span>
             {hotspot.assignee && hotspot.assignee.name && (
               <>
-                <span className="huge-spacer-left">{translate('hotspot.assigned_to')}</span>
+                <span className="huge-spacer-left">{translate('assigned_to')}:</span>
                 <strong className="little-spacer-left">
                   {hotspot.assignee.active
                     ? hotspot.assignee.name
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx
new file mode 100644 (file)
index 0000000..de1fee9
--- /dev/null
@@ -0,0 +1,80 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
+import IssueChangelogDiff from '../../../components/issue/components/IssueChangelogDiff';
+import Avatar from '../../../components/ui/Avatar';
+import { ReviewHistoryElement, ReviewHistoryType } from '../../../types/security-hotspots';
+
+export interface HotspotViewerReviewHistoryTabProps {
+  history: ReviewHistoryElement[];
+}
+
+export default function HotspotViewerReviewHistoryTab(props: HotspotViewerReviewHistoryTabProps) {
+  const { history } = props;
+
+  return (
+    <>
+      {history.map((elt, i) => (
+        <React.Fragment key={`${elt.user.name}-${elt.date}`}>
+          {i > 0 && <hr />}
+          <div>
+            <div className="display-flex-center">
+              {elt.user.name && (
+                <>
+                  <Avatar
+                    className="little-spacer-right"
+                    hash={elt.user.avatar}
+                    name={elt.user.name}
+                    size={20}
+                  />
+                  <strong>
+                    {elt.user.active
+                      ? elt.user.name
+                      : translateWithParameters('user.x_deleted', elt.user.name)}
+                  </strong>
+                  {elt.type === ReviewHistoryType.Creation && (
+                    <span className="little-spacer-left">
+                      {translate('hotspots.tabs.review_history.created')}
+                    </span>
+                  )}
+                  <span className="little-spacer-left little-spacer-right">-</span>
+                </>
+              )}
+              <DateTimeFormatter date={elt.date} />
+            </div>
+
+            {elt.type === ReviewHistoryType.Diff && elt.diffs && (
+              <div className="spacer-top">
+                {elt.diffs.map(diff => (
+                  <IssueChangelogDiff
+                    diff={diff}
+                    key={`${diff.key}-${diff.oldValue}-${diff.newValue}`}
+                  />
+                ))}
+              </div>
+            )}
+          </div>
+        </React.Fragment>
+      ))}
+    </>
+  );
+}
index e02c11b164d8baa90984a6d16b0d5f2031ca9416..de17ec8ffad1d0f945fc00875b2f354181489a68 100644 (file)
@@ -21,56 +21,79 @@ import { sanitize } from 'dompurify';
 import * as React from 'react';
 import BoxedTabs from 'sonar-ui-common/components/controls/BoxedTabs';
 import { translate } from 'sonar-ui-common/helpers/l10n';
-import { DetailedHotspot } from '../../../types/security-hotspots';
+import { Hotspot } from '../../../types/security-hotspots';
+import { getHotspotReviewHistory } from '../utils';
+import HotspotViewerReviewHistoryTab from './HotspotViewerReviewHistoryTab';
 
 export interface HotspotViewerTabsProps {
-  hotspot: DetailedHotspot;
+  hotspot: Hotspot;
 }
 
 export enum Tabs {
   RiskDescription = 'risk',
   VulnerabilityDescription = 'vulnerability',
-  FixRecommendation = 'fix'
+  FixRecommendation = 'fix',
+  ReviewHistory = 'review'
 }
 
 export default function HotspotViewerTabs(props: HotspotViewerTabsProps) {
   const { hotspot } = props;
-  const [currentTab, setCurrentTab] = React.useState(Tabs.RiskDescription);
+  const [currentTabKey, setCurrentTabKey] = React.useState(Tabs.RiskDescription);
+  const hotspotReviewHistory = React.useMemo(() => getHotspotReviewHistory(hotspot), [hotspot]);
 
-  const tabs = {
-    [Tabs.RiskDescription]: {
-      title: translate('hotspot.tabs.risk_description'),
+  const tabs = [
+    {
+      key: Tabs.RiskDescription,
+      label: translate('hotspots.tabs.risk_description'),
       content: hotspot.rule.riskDescription || ''
     },
-    [Tabs.VulnerabilityDescription]: {
-      title: translate('hotspot.tabs.vulnerability_description'),
+    {
+      key: Tabs.VulnerabilityDescription,
+      label: translate('hotspots.tabs.vulnerability_description'),
       content: hotspot.rule.vulnerabilityDescription || ''
     },
-    [Tabs.FixRecommendation]: {
-      title: translate('hotspot.tabs.fix_recommendations'),
+    {
+      key: Tabs.FixRecommendation,
+      label: translate('hotspots.tabs.fix_recommendations'),
       content: hotspot.rule.fixRecommendations || ''
+    },
+    {
+      key: Tabs.ReviewHistory,
+      label: (
+        <>
+          <span>{translate('hotspots.tabs.review_history')}</span>
+          <span className="counter-badge spacer-left">{hotspotReviewHistory.length}</span>
+        </>
+      ),
+      content: hotspotReviewHistory.length > 0 && (
+        <HotspotViewerReviewHistoryTab history={hotspotReviewHistory} />
+      )
     }
-  };
-
-  const tabsToDisplay = Object.values(Tabs)
-    .filter(tab => Boolean(tabs[tab].content))
-    .map(tab => ({ key: tab, label: tabs[tab].title }));
+  ].filter(tab => Boolean(tab.content));
 
-  if (tabsToDisplay.length === 0) {
+  if (tabs.length === 0) {
     return null;
   }
 
-  if (!tabsToDisplay.find(tab => tab.key === currentTab)) {
-    setCurrentTab(tabsToDisplay[0].key);
-  }
+  const currentTab = tabs.find(tab => tab.key === currentTabKey) || tabs[0];
 
   return (
     <>
-      <BoxedTabs onSelect={tab => setCurrentTab(tab)} selected={currentTab} tabs={tabsToDisplay} />
-      <div
-        className="boxed-group markdown big-padded"
-        dangerouslySetInnerHTML={{ __html: sanitize(tabs[currentTab].content) }}
+      <BoxedTabs
+        onSelect={tabKey => setCurrentTabKey(tabKey)}
+        selected={currentTabKey}
+        tabs={tabs}
       />
+      <div className="boxed-group big-padded">
+        {typeof currentTab.content === 'string' ? (
+          <div
+            className="markdown"
+            dangerouslySetInnerHTML={{ __html: sanitize(currentTab.content) }}
+          />
+        ) : (
+          <>{currentTab.content}</>
+        )}
+      </div>
     </>
   );
 }
index 2dca00aa32e465619466b75e6876b552d75d6cf9..1f67a35002f4c79690afda8c4b6c98e1a68c29c5 100644 (file)
@@ -21,7 +21,7 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import { Button } from 'sonar-ui-common/components/controls/buttons';
 import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
-import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots';
+import { mockHotspot } from '../../../../helpers/mocks/security-hotspots';
 import { HotspotStatus } from '../../../../types/security-hotspots';
 import HotspotActions, { HotspotActionsProps } from '../HotspotActions';
 
@@ -70,7 +70,7 @@ it('should register an eventlistener', () => {
 function shallowRender(props: Partial<HotspotActionsProps> = {}) {
   return shallow(
     <HotspotActions
-      hotspot={mockDetailledHotspot({ key: 'key', status: HotspotStatus.TO_REVIEW })}
+      hotspot={mockHotspot({ key: 'key', status: HotspotStatus.TO_REVIEW })}
       onSubmit={jest.fn()}
       {...props}
     />
index 622840289f8b0ee0b3992b288059c41ec04511d0..23b4bad0dc904ad224535b6348c67094425657bb 100644 (file)
@@ -23,7 +23,7 @@ import * as React from 'react';
 import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
 import { getSources } from '../../../../api/components';
 import { mockBranch } from '../../../../helpers/mocks/branch-like';
-import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots';
+import { mockHotspot } from '../../../../helpers/mocks/security-hotspots';
 import { mockSourceLine } from '../../../../helpers/testMocks';
 import HotspotSnippetContainer from '../HotspotSnippetContainer';
 import HotspotSnippetContainerRenderer from '../HotspotSnippetContainerRenderer';
@@ -43,7 +43,7 @@ it('should load sources on mount', async () => {
     range(5, 18).map(line => mockSourceLine({ line }))
   );
 
-  const hotspot = mockDetailledHotspot({
+  const hotspot = mockHotspot({
     textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 }
   });
 
@@ -65,7 +65,7 @@ it('should handle end-of-file on mount', async () => {
     range(5, 15).map(line => mockSourceLine({ line }))
   );
 
-  const hotspot = mockDetailledHotspot({
+  const hotspot = mockHotspot({
     textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 }
   });
 
@@ -85,7 +85,7 @@ describe('Expansion', () => {
     );
   });
 
-  const hotspot = mockDetailledHotspot({
+  const hotspot = mockHotspot({
     textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 }
   });
 
@@ -193,6 +193,6 @@ it('should handle symbol click', () => {
 
 function shallowRender(props?: Partial<HotspotSnippetContainer['props']>) {
   return shallow<HotspotSnippetContainer>(
-    <HotspotSnippetContainer branchLike={branch} hotspot={mockDetailledHotspot()} {...props} />
+    <HotspotSnippetContainer branchLike={branch} hotspot={mockHotspot()} {...props} />
   );
 }
index 9fd30ab5216225d0fbe214a514dc377e84f8697b..064ef4457344cc96eb7757d3883c9aedbd006d31 100644 (file)
@@ -20,7 +20,7 @@
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import { mockMainBranch } from '../../../../helpers/mocks/branch-like';
-import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots';
+import { mockHotspot } from '../../../../helpers/mocks/security-hotspots';
 import { mockSourceLine, mockSourceViewerFile } from '../../../../helpers/testMocks';
 import HotspotSnippetContainerRenderer, {
   HotspotSnippetContainerRendererProps
@@ -36,7 +36,7 @@ function shallowRender(props?: Partial<HotspotSnippetContainerRendererProps>) {
     <HotspotSnippetContainerRenderer
       branchLike={mockMainBranch()}
       highlightedSymbols={[]}
-      hotspot={mockDetailledHotspot()}
+      hotspot={mockHotspot()}
       lastLine={undefined}
       linePopup={undefined}
       loading={false}
index 84025073a894b76ecf0a73ec3896e83c86107e65..7de5b90f26ef1d2645a4dda0e0ba99d5417df6ff 100644 (file)
@@ -19,7 +19,7 @@
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
-import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots';
+import { mockHotspot } from '../../../../helpers/mocks/security-hotspots';
 import { mockCurrentUser, mockLoggedInUser, mockUser } from '../../../../helpers/testMocks';
 import { HotspotViewerRenderer, HotspotViewerRendererProps } from '../HotspotViewerRenderer';
 
@@ -27,9 +27,19 @@ it('should render correctly', () => {
   const wrapper = shallowRender();
   expect(wrapper).toMatchSnapshot();
   expect(shallowRender({ hotspot: undefined })).toMatchSnapshot('no hotspot');
+  expect(shallowRender({ hotspot: mockHotspot({ assignee: undefined }) })).toMatchSnapshot(
+    'unassigned'
+  );
   expect(
-    shallowRender({ hotspot: mockDetailledHotspot({ assignee: mockUser({ active: false }) }) })
+    shallowRender({ hotspot: mockHotspot({ assignee: mockUser({ active: false }) }) })
   ).toMatchSnapshot('deleted assignee');
+  expect(
+    shallowRender({
+      hotspot: mockHotspot({
+        assignee: mockUser({ name: undefined, login: 'assignee_login' })
+      })
+    })
+  ).toMatchSnapshot('assignee without name');
   expect(shallowRender()).toMatchSnapshot('anonymous user');
   expect(shallowRender({ currentUser: mockLoggedInUser() })).toMatchSnapshot('user logged in');
 });
@@ -38,7 +48,7 @@ function shallowRender(props?: Partial<HotspotViewerRendererProps>) {
   return shallow(
     <HotspotViewerRenderer
       currentUser={mockCurrentUser()}
-      hotspot={mockDetailledHotspot()}
+      hotspot={mockHotspot()}
       loading={false}
       onUpdateHotspot={jest.fn()}
       securityCategories={{ 'sql-injection': { title: 'SQL injection' } }}
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx
new file mode 100644 (file)
index 0000000..e73571c
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockHotspotReviewHistoryElement } from '../../../../helpers/mocks/security-hotspots';
+import { mockUser } from '../../../../helpers/testMocks';
+import { ReviewHistoryType } from '../../../../types/security-hotspots';
+import HotspotViewerReviewHistoryTab, {
+  HotspotViewerReviewHistoryTabProps
+} from '../HotspotViewerReviewHistoryTab';
+
+it('should render correctly', () => {
+  const wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot();
+});
+
+function shallowRender(props?: Partial<HotspotViewerReviewHistoryTabProps>) {
+  return shallow(
+    <HotspotViewerReviewHistoryTab
+      history={[
+        mockHotspotReviewHistoryElement({ user: mockUser({ avatar: 'with-avatar' }) }),
+        mockHotspotReviewHistoryElement({ user: mockUser({ active: false }) }),
+        mockHotspotReviewHistoryElement({ user: mockUser({ login: undefined, name: undefined }) }),
+        mockHotspotReviewHistoryElement({
+          type: ReviewHistoryType.Diff,
+          diffs: [
+            { key: 'test', oldValue: 'old', newValue: 'new' },
+            { key: 'test-1', oldValue: 'old-1', newValue: 'new-1' }
+          ]
+        })
+      ]}
+      {...props}
+    />
+  );
+}
index 12fa76200b919b6c5a7eca5cef53ca1f6c6c0a86..a28be0a541ba7ad4a9bdefece01b61b46f2f9ac6 100644 (file)
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import BoxedTabs from 'sonar-ui-common/components/controls/BoxedTabs';
-import {
-  mockDetailledHotspot,
-  mockDetailledHotspotRule
-} from '../../../../helpers/mocks/security-hotspots';
+import { mockHotspot, mockHotspotRule } from '../../../../helpers/mocks/security-hotspots';
 import HotspotViewerTabs, { HotspotViewerTabsProps, Tabs } from '../HotspotViewerTabs';
 
 it('should render correctly', () => {
@@ -40,20 +37,24 @@ it('should render correctly', () => {
 
     onSelect(Tabs.FixRecommendation);
     expect(wrapper).toMatchSnapshot('fix');
+
+    onSelect(Tabs.ReviewHistory);
+    expect(wrapper).toMatchSnapshot('review');
   }
 
   expect(
     shallowRender({
-      hotspot: mockDetailledHotspot({
-        rule: mockDetailledHotspotRule({ riskDescription: undefined })
+      hotspot: mockHotspot({
+        rule: mockHotspotRule({ riskDescription: undefined })
       })
     })
   ).toMatchSnapshot('empty tab');
 
   expect(
     shallowRender({
-      hotspot: mockDetailledHotspot({
-        rule: mockDetailledHotspotRule({
+      hotspot: mockHotspot({
+        creationDate: undefined,
+        rule: mockHotspotRule({
           riskDescription: undefined,
           fixRecommendations: undefined,
           vulnerabilityDescription: undefined
@@ -64,5 +65,5 @@ it('should render correctly', () => {
 });
 
 function shallowRender(props?: Partial<HotspotViewerTabsProps>) {
-  return shallow(<HotspotViewerTabs hotspot={mockDetailledHotspot()} {...props} />);
+  return shallow(<HotspotViewerTabs hotspot={mockHotspot()} {...props} />);
 }
index 9bf2e5f400d736b81237d4d8d1829e8e08d62c95..66a40e18e94dac08e6edbf0c4eb7dcf6b17086ff 100644 (file)
@@ -16,7 +16,7 @@ exports[`should handle collapse and expand 1`] = `
     </strong>
     <span>
       <span
-        className="hotspot-counter"
+        className="counter-badge"
       >
         1
       </span>
@@ -44,7 +44,7 @@ exports[`should handle collapse and expand 2`] = `
     </strong>
     <span>
       <span
-        className="hotspot-counter"
+        className="counter-badge"
       >
         1
       </span>
@@ -101,7 +101,7 @@ exports[`should render correctly with hotspots 1`] = `
     </strong>
     <span>
       <span
-        className="hotspot-counter"
+        className="counter-badge"
       >
         2
       </span>
index f24c9b4ed5aac8bb0abdd7a7771efed2cf0168f5..adcc51f65c57c4ea18455330871117151bb6b492 100644 (file)
@@ -25,6 +25,7 @@ exports[`should render correctly 1`] = `
         "login": "john.doe",
         "name": "John Doe",
       },
+      "changelog": Array [],
       "component": Object {
         "breadcrumbs": Array [],
         "key": "my-project",
index df32f19c2b8b0639944fec0744a297c90f374248..e4fe72741f27a3d64850ccf838e79e2641105e33 100644 (file)
@@ -149,6 +149,7 @@ exports[`should render correctly: with sourcelines 1`] = `
               "login": "john.doe",
               "name": "John Doe",
             },
+            "changelog": Array [],
             "component": Object {
               "breadcrumbs": Array [],
               "key": "my-project",
index 716dfc0dca719ec4818afc325611a537f5895a3e..51794f6d6878a165429905376333ad476c9eee98 100644 (file)
@@ -22,7 +22,8 @@ exports[`should render correctly 1`] = `
         className="text-muted"
       >
         <span>
-          hotspot.category
+          category
+          :
         </span>
         <span
           className="little-spacer-left"
@@ -35,7 +36,8 @@ exports[`should render correctly 1`] = `
       className="huge-spacer-bottom"
     >
       <span>
-        hotspot.status
+        status
+        :
       </span>
       <span
         className="badge little-spacer-left"
@@ -45,7 +47,8 @@ exports[`should render correctly 1`] = `
       <span
         className="huge-spacer-left"
       >
-        hotspot.assigned_to
+        assigned_to
+        :
       </span>
       <strong
         className="little-spacer-left"
@@ -68,6 +71,7 @@ exports[`should render correctly 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
@@ -150,6 +154,7 @@ exports[`should render correctly 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
@@ -243,7 +248,8 @@ exports[`should render correctly: anonymous user 1`] = `
         className="text-muted"
       >
         <span>
-          hotspot.category
+          category
+          :
         </span>
         <span
           className="little-spacer-left"
@@ -256,7 +262,8 @@ exports[`should render correctly: anonymous user 1`] = `
       className="huge-spacer-bottom"
     >
       <span>
-        hotspot.status
+        status
+        :
       </span>
       <span
         className="badge little-spacer-left"
@@ -266,7 +273,8 @@ exports[`should render correctly: anonymous user 1`] = `
       <span
         className="huge-spacer-left"
       >
-        hotspot.assigned_to
+        assigned_to
+        :
       </span>
       <strong
         className="little-spacer-left"
@@ -289,6 +297,7 @@ exports[`should render correctly: anonymous user 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
@@ -371,6 +380,222 @@ exports[`should render correctly: anonymous user 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
+          "component": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "FIL",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "creationDate": "2013-05-13T17:55:41+0200",
+          "key": "01fc972e-2a3c-433e-bcae-0bd7f88f5123",
+          "line": 142,
+          "message": "'3' is a magic number.",
+          "project": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "TRK",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "resolution": "FIXED",
+          "rule": Object {
+            "fixRecommendations": "<p>This a <strong>strong</strong> message about fixing !</p>",
+            "key": "squid:S2077",
+            "name": "That rule",
+            "riskDescription": "<p>This a <strong>strong</strong> message about risk !</p>",
+            "securityCategory": "sql-injection",
+            "vulnerabilityDescription": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+            "vulnerabilityProbability": "HIGH",
+          },
+          "status": "REVIEWED",
+          "textRange": Object {
+            "endLine": 142,
+            "endOffset": 83,
+            "startLine": 142,
+            "startOffset": 26,
+          },
+          "updateDate": "2013-05-13T17:55:42+0200",
+        }
+      }
+    />
+  </div>
+</DeferredSpinner>
+`;
+
+exports[`should render correctly: assignee without name 1`] = `
+<DeferredSpinner
+  loading={false}
+  timeout={100}
+>
+  <div
+    className="big-padded"
+  >
+    <div
+      className="big-spacer-bottom"
+    >
+      <div
+        className="display-flex-space-between"
+      >
+        <h1>
+          '3' is a magic number.
+        </h1>
+      </div>
+      <div
+        className="text-muted"
+      >
+        <span>
+          category
+          :
+        </span>
+        <span
+          className="little-spacer-left"
+        >
+          SQL injection
+        </span>
+      </div>
+    </div>
+    <div
+      className="huge-spacer-bottom"
+    >
+      <span>
+        status
+        :
+      </span>
+      <span
+        className="badge little-spacer-left"
+      >
+        hotspot.status.FIXED
+      </span>
+    </div>
+    <HotspotSnippetContainer
+      hotspot={
+        Object {
+          "assignee": Object {
+            "active": true,
+            "local": true,
+            "login": "assignee_login",
+            "name": undefined,
+          },
+          "author": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "changelog": Array [],
+          "component": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "FIL",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "creationDate": "2013-05-13T17:55:41+0200",
+          "key": "01fc972e-2a3c-433e-bcae-0bd7f88f5123",
+          "line": 142,
+          "message": "'3' is a magic number.",
+          "project": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "TRK",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "resolution": "FIXED",
+          "rule": Object {
+            "fixRecommendations": "<p>This a <strong>strong</strong> message about fixing !</p>",
+            "key": "squid:S2077",
+            "name": "That rule",
+            "riskDescription": "<p>This a <strong>strong</strong> message about risk !</p>",
+            "securityCategory": "sql-injection",
+            "vulnerabilityDescription": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+            "vulnerabilityProbability": "HIGH",
+          },
+          "status": "REVIEWED",
+          "textRange": Object {
+            "endLine": 142,
+            "endOffset": 83,
+            "startLine": 142,
+            "startOffset": 26,
+          },
+          "updateDate": "2013-05-13T17:55:42+0200",
+        }
+      }
+    />
+    <HotspotViewerTabs
+      hotspot={
+        Object {
+          "assignee": Object {
+            "active": true,
+            "local": true,
+            "login": "assignee_login",
+            "name": undefined,
+          },
+          "author": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
@@ -464,7 +689,8 @@ exports[`should render correctly: deleted assignee 1`] = `
         className="text-muted"
       >
         <span>
-          hotspot.category
+          category
+          :
         </span>
         <span
           className="little-spacer-left"
@@ -477,7 +703,8 @@ exports[`should render correctly: deleted assignee 1`] = `
       className="huge-spacer-bottom"
     >
       <span>
-        hotspot.status
+        status
+        :
       </span>
       <span
         className="badge little-spacer-left"
@@ -487,7 +714,8 @@ exports[`should render correctly: deleted assignee 1`] = `
       <span
         className="huge-spacer-left"
       >
-        hotspot.assigned_to
+        assigned_to
+        :
       </span>
       <strong
         className="little-spacer-left"
@@ -510,6 +738,7 @@ exports[`should render correctly: deleted assignee 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
@@ -592,6 +821,7 @@ exports[`should render correctly: deleted assignee 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
@@ -670,6 +900,211 @@ exports[`should render correctly: no hotspot 1`] = `
 />
 `;
 
+exports[`should render correctly: unassigned 1`] = `
+<DeferredSpinner
+  loading={false}
+  timeout={100}
+>
+  <div
+    className="big-padded"
+  >
+    <div
+      className="big-spacer-bottom"
+    >
+      <div
+        className="display-flex-space-between"
+      >
+        <h1>
+          '3' is a magic number.
+        </h1>
+      </div>
+      <div
+        className="text-muted"
+      >
+        <span>
+          category
+          :
+        </span>
+        <span
+          className="little-spacer-left"
+        >
+          SQL injection
+        </span>
+      </div>
+    </div>
+    <div
+      className="huge-spacer-bottom"
+    >
+      <span>
+        status
+        :
+      </span>
+      <span
+        className="badge little-spacer-left"
+      >
+        hotspot.status.FIXED
+      </span>
+    </div>
+    <HotspotSnippetContainer
+      hotspot={
+        Object {
+          "assignee": undefined,
+          "author": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "changelog": Array [],
+          "component": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "FIL",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "creationDate": "2013-05-13T17:55:41+0200",
+          "key": "01fc972e-2a3c-433e-bcae-0bd7f88f5123",
+          "line": 142,
+          "message": "'3' is a magic number.",
+          "project": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "TRK",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "resolution": "FIXED",
+          "rule": Object {
+            "fixRecommendations": "<p>This a <strong>strong</strong> message about fixing !</p>",
+            "key": "squid:S2077",
+            "name": "That rule",
+            "riskDescription": "<p>This a <strong>strong</strong> message about risk !</p>",
+            "securityCategory": "sql-injection",
+            "vulnerabilityDescription": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+            "vulnerabilityProbability": "HIGH",
+          },
+          "status": "REVIEWED",
+          "textRange": Object {
+            "endLine": 142,
+            "endOffset": 83,
+            "startLine": 142,
+            "startOffset": 26,
+          },
+          "updateDate": "2013-05-13T17:55:42+0200",
+        }
+      }
+    />
+    <HotspotViewerTabs
+      hotspot={
+        Object {
+          "assignee": undefined,
+          "author": Object {
+            "active": true,
+            "local": true,
+            "login": "john.doe",
+            "name": "John Doe",
+          },
+          "changelog": Array [],
+          "component": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "FIL",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "creationDate": "2013-05-13T17:55:41+0200",
+          "key": "01fc972e-2a3c-433e-bcae-0bd7f88f5123",
+          "line": 142,
+          "message": "'3' is a magic number.",
+          "project": Object {
+            "breadcrumbs": Array [],
+            "key": "my-project",
+            "name": "MyProject",
+            "organization": "foo",
+            "qualifier": "TRK",
+            "qualityGate": Object {
+              "isDefault": true,
+              "key": "30",
+              "name": "Sonar way",
+            },
+            "qualityProfiles": Array [
+              Object {
+                "deleted": false,
+                "key": "my-qp",
+                "language": "ts",
+                "name": "Sonar way",
+              },
+            ],
+            "tags": Array [],
+          },
+          "resolution": "FIXED",
+          "rule": Object {
+            "fixRecommendations": "<p>This a <strong>strong</strong> message about fixing !</p>",
+            "key": "squid:S2077",
+            "name": "That rule",
+            "riskDescription": "<p>This a <strong>strong</strong> message about risk !</p>",
+            "securityCategory": "sql-injection",
+            "vulnerabilityDescription": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+            "vulnerabilityProbability": "HIGH",
+          },
+          "status": "REVIEWED",
+          "textRange": Object {
+            "endLine": 142,
+            "endOffset": 83,
+            "startLine": 142,
+            "startOffset": 26,
+          },
+          "updateDate": "2013-05-13T17:55:42+0200",
+        }
+      }
+    />
+  </div>
+</DeferredSpinner>
+`;
+
 exports[`should render correctly: user logged in 1`] = `
 <DeferredSpinner
   loading={false}
@@ -702,6 +1137,7 @@ exports[`should render correctly: user logged in 1`] = `
                 "login": "john.doe",
                 "name": "John Doe",
               },
+              "changelog": Array [],
               "component": Object {
                 "breadcrumbs": Array [],
                 "key": "my-project",
@@ -775,7 +1211,8 @@ exports[`should render correctly: user logged in 1`] = `
         className="text-muted"
       >
         <span>
-          hotspot.category
+          category
+          :
         </span>
         <span
           className="little-spacer-left"
@@ -788,7 +1225,8 @@ exports[`should render correctly: user logged in 1`] = `
       className="huge-spacer-bottom"
     >
       <span>
-        hotspot.status
+        status
+        :
       </span>
       <span
         className="badge little-spacer-left"
@@ -798,7 +1236,8 @@ exports[`should render correctly: user logged in 1`] = `
       <span
         className="huge-spacer-left"
       >
-        hotspot.assigned_to
+        assigned_to
+        :
       </span>
       <strong
         className="little-spacer-left"
@@ -821,6 +1260,7 @@ exports[`should render correctly: user logged in 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
@@ -903,6 +1343,7 @@ exports[`should render correctly: user logged in 1`] = `
             "login": "john.doe",
             "name": "John Doe",
           },
+          "changelog": Array [],
           "component": Object {
             "breadcrumbs": Array [],
             "key": "my-project",
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap
new file mode 100644 (file)
index 0000000..c51db02
--- /dev/null
@@ -0,0 +1,119 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Fragment>
+  <div>
+    <div
+      className="display-flex-center"
+    >
+      <Connect(Avatar)
+        className="little-spacer-right"
+        hash="with-avatar"
+        name="John Doe"
+        size={20}
+      />
+      <strong>
+        John Doe
+      </strong>
+      <span
+        className="little-spacer-left"
+      >
+        hotspots.tabs.review_history.created
+      </span>
+      <span
+        className="little-spacer-left little-spacer-right"
+      >
+        -
+      </span>
+      <DateTimeFormatter
+        date="2019-09-13T17:55:42+0200"
+      />
+    </div>
+  </div>
+  <hr />
+  <div>
+    <div
+      className="display-flex-center"
+    >
+      <Connect(Avatar)
+        className="little-spacer-right"
+        name="John Doe"
+        size={20}
+      />
+      <strong>
+        user.x_deleted.John Doe
+      </strong>
+      <span
+        className="little-spacer-left"
+      >
+        hotspots.tabs.review_history.created
+      </span>
+      <span
+        className="little-spacer-left little-spacer-right"
+      >
+        -
+      </span>
+      <DateTimeFormatter
+        date="2019-09-13T17:55:42+0200"
+      />
+    </div>
+  </div>
+  <hr />
+  <div>
+    <div
+      className="display-flex-center"
+    >
+      <DateTimeFormatter
+        date="2019-09-13T17:55:42+0200"
+      />
+    </div>
+  </div>
+  <hr />
+  <div>
+    <div
+      className="display-flex-center"
+    >
+      <Connect(Avatar)
+        className="little-spacer-right"
+        name="John Doe"
+        size={20}
+      />
+      <strong>
+        John Doe
+      </strong>
+      <span
+        className="little-spacer-left little-spacer-right"
+      >
+        -
+      </span>
+      <DateTimeFormatter
+        date="2019-09-13T17:55:42+0200"
+      />
+    </div>
+    <div
+      className="spacer-top"
+    >
+      <IssueChangelogDiff
+        diff={
+          Object {
+            "key": "test",
+            "newValue": "new",
+            "oldValue": "old",
+          }
+        }
+        key="test-old-new"
+      />
+      <IssueChangelogDiff
+        diff={
+          Object {
+            "key": "test-1",
+            "newValue": "new-1",
+            "oldValue": "old-1",
+          }
+        }
+        key="test-1-old-1-new-1"
+      />
+    </div>
+  </div>
+</Fragment>
+`;
index 5e0f68e43d9b41a9c94956de57f0c7ac62b28367..7262c691e11cbdb8ff8c8b4ad78a2938fb53d55b 100644 (file)
@@ -4,28 +4,62 @@ exports[`should render correctly: empty tab 1`] = `
 <Fragment>
   <BoxedTabs
     onSelect={[Function]}
-    selected="vulnerability"
+    selected="risk"
     tabs={
       Array [
         Object {
+          "content": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
           "key": "vulnerability",
-          "label": "hotspot.tabs.vulnerability_description",
+          "label": "hotspots.tabs.vulnerability_description",
         },
         Object {
+          "content": "<p>This a <strong>strong</strong> message about fixing !</p>",
           "key": "fix",
-          "label": "hotspot.tabs.fix_recommendations",
+          "label": "hotspots.tabs.fix_recommendations",
+        },
+        Object {
+          "content": <HotspotViewerReviewHistoryTab
+            history={
+              Array [
+                Object {
+                  "date": "2013-05-13T17:55:41+0200",
+                  "type": 0,
+                  "user": Object {
+                    "active": true,
+                    "avatar": undefined,
+                    "name": "John Doe",
+                  },
+                },
+              ]
+            }
+          />,
+          "key": "review",
+          "label": <React.Fragment>
+            <span>
+              hotspots.tabs.review_history
+            </span>
+            <span
+              className="counter-badge spacer-left"
+            >
+              1
+            </span>
+          </React.Fragment>,
         },
       ]
     }
   />
   <div
-    className="boxed-group markdown big-padded"
-    dangerouslySetInnerHTML={
-      Object {
-        "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+    className="boxed-group big-padded"
+  >
+    <div
+      className="markdown"
+      dangerouslySetInnerHTML={
+        Object {
+          "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+        }
       }
-    }
-  />
+    />
+  </div>
 </Fragment>
 `;
 
@@ -37,63 +71,208 @@ exports[`should render correctly: fix 1`] = `
     tabs={
       Array [
         Object {
+          "content": "<p>This a <strong>strong</strong> message about risk !</p>",
           "key": "risk",
-          "label": "hotspot.tabs.risk_description",
+          "label": "hotspots.tabs.risk_description",
         },
         Object {
+          "content": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
           "key": "vulnerability",
-          "label": "hotspot.tabs.vulnerability_description",
+          "label": "hotspots.tabs.vulnerability_description",
         },
         Object {
+          "content": "<p>This a <strong>strong</strong> message about fixing !</p>",
           "key": "fix",
-          "label": "hotspot.tabs.fix_recommendations",
+          "label": "hotspots.tabs.fix_recommendations",
+        },
+        Object {
+          "content": <HotspotViewerReviewHistoryTab
+            history={
+              Array [
+                Object {
+                  "date": "2013-05-13T17:55:41+0200",
+                  "type": 0,
+                  "user": Object {
+                    "active": true,
+                    "avatar": undefined,
+                    "name": "John Doe",
+                  },
+                },
+              ]
+            }
+          />,
+          "key": "review",
+          "label": <React.Fragment>
+            <span>
+              hotspots.tabs.review_history
+            </span>
+            <span
+              className="counter-badge spacer-left"
+            >
+              1
+            </span>
+          </React.Fragment>,
         },
       ]
     }
   />
   <div
-    className="boxed-group markdown big-padded"
-    dangerouslySetInnerHTML={
-      Object {
-        "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
+    className="boxed-group big-padded"
+  >
+    <div
+      className="markdown"
+      dangerouslySetInnerHTML={
+        Object {
+          "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
+        }
       }
-    }
-  />
+    />
+  </div>
 </Fragment>
 `;
 
 exports[`should render correctly: no tabs 1`] = `""`;
 
-exports[`should render correctly: risk 1`] = `
+exports[`should render correctly: review 1`] = `
 <Fragment>
   <BoxedTabs
     onSelect={[Function]}
-    selected="risk"
+    selected="review"
     tabs={
       Array [
         Object {
+          "content": "<p>This a <strong>strong</strong> message about risk !</p>",
           "key": "risk",
-          "label": "hotspot.tabs.risk_description",
+          "label": "hotspots.tabs.risk_description",
         },
         Object {
+          "content": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
           "key": "vulnerability",
-          "label": "hotspot.tabs.vulnerability_description",
+          "label": "hotspots.tabs.vulnerability_description",
         },
         Object {
+          "content": "<p>This a <strong>strong</strong> message about fixing !</p>",
           "key": "fix",
-          "label": "hotspot.tabs.fix_recommendations",
+          "label": "hotspots.tabs.fix_recommendations",
+        },
+        Object {
+          "content": <HotspotViewerReviewHistoryTab
+            history={
+              Array [
+                Object {
+                  "date": "2013-05-13T17:55:41+0200",
+                  "type": 0,
+                  "user": Object {
+                    "active": true,
+                    "avatar": undefined,
+                    "name": "John Doe",
+                  },
+                },
+              ]
+            }
+          />,
+          "key": "review",
+          "label": <React.Fragment>
+            <span>
+              hotspots.tabs.review_history
+            </span>
+            <span
+              className="counter-badge spacer-left"
+            >
+              1
+            </span>
+          </React.Fragment>,
         },
       ]
     }
   />
   <div
-    className="boxed-group markdown big-padded"
-    dangerouslySetInnerHTML={
-      Object {
-        "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
+    className="boxed-group big-padded"
+  >
+    <HotspotViewerReviewHistoryTab
+      history={
+        Array [
+          Object {
+            "date": "2013-05-13T17:55:41+0200",
+            "type": 0,
+            "user": Object {
+              "active": true,
+              "avatar": undefined,
+              "name": "John Doe",
+            },
+          },
+        ]
       }
+    />
+  </div>
+</Fragment>
+`;
+
+exports[`should render correctly: risk 1`] = `
+<Fragment>
+  <BoxedTabs
+    onSelect={[Function]}
+    selected="risk"
+    tabs={
+      Array [
+        Object {
+          "content": "<p>This a <strong>strong</strong> message about risk !</p>",
+          "key": "risk",
+          "label": "hotspots.tabs.risk_description",
+        },
+        Object {
+          "content": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+          "key": "vulnerability",
+          "label": "hotspots.tabs.vulnerability_description",
+        },
+        Object {
+          "content": "<p>This a <strong>strong</strong> message about fixing !</p>",
+          "key": "fix",
+          "label": "hotspots.tabs.fix_recommendations",
+        },
+        Object {
+          "content": <HotspotViewerReviewHistoryTab
+            history={
+              Array [
+                Object {
+                  "date": "2013-05-13T17:55:41+0200",
+                  "type": 0,
+                  "user": Object {
+                    "active": true,
+                    "avatar": undefined,
+                    "name": "John Doe",
+                  },
+                },
+              ]
+            }
+          />,
+          "key": "review",
+          "label": <React.Fragment>
+            <span>
+              hotspots.tabs.review_history
+            </span>
+            <span
+              className="counter-badge spacer-left"
+            >
+              1
+            </span>
+          </React.Fragment>,
+        },
+      ]
     }
   />
+  <div
+    className="boxed-group big-padded"
+  >
+    <div
+      className="markdown"
+      dangerouslySetInnerHTML={
+        Object {
+          "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
+        }
+      }
+    />
+  </div>
 </Fragment>
 `;
 
@@ -105,27 +284,62 @@ exports[`should render correctly: vulnerability 1`] = `
     tabs={
       Array [
         Object {
+          "content": "<p>This a <strong>strong</strong> message about risk !</p>",
           "key": "risk",
-          "label": "hotspot.tabs.risk_description",
+          "label": "hotspots.tabs.risk_description",
         },
         Object {
+          "content": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
           "key": "vulnerability",
-          "label": "hotspot.tabs.vulnerability_description",
+          "label": "hotspots.tabs.vulnerability_description",
         },
         Object {
+          "content": "<p>This a <strong>strong</strong> message about fixing !</p>",
           "key": "fix",
-          "label": "hotspot.tabs.fix_recommendations",
+          "label": "hotspots.tabs.fix_recommendations",
+        },
+        Object {
+          "content": <HotspotViewerReviewHistoryTab
+            history={
+              Array [
+                Object {
+                  "date": "2013-05-13T17:55:41+0200",
+                  "type": 0,
+                  "user": Object {
+                    "active": true,
+                    "avatar": undefined,
+                    "name": "John Doe",
+                  },
+                },
+              ]
+            }
+          />,
+          "key": "review",
+          "label": <React.Fragment>
+            <span>
+              hotspots.tabs.review_history
+            </span>
+            <span
+              className="counter-badge spacer-left"
+            >
+              1
+            </span>
+          </React.Fragment>,
         },
       ]
     }
   />
   <div
-    className="boxed-group markdown big-padded"
-    dangerouslySetInnerHTML={
-      Object {
-        "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+    className="boxed-group big-padded"
+  >
+    <div
+      className="markdown"
+      dangerouslySetInnerHTML={
+        Object {
+          "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+        }
       }
-    }
-  />
+    />
+  </div>
 </Fragment>
 `;
index e2d86f7ecc1393110ce080f9c1ef09aec58b550f..bed8333ed12d005c4fca13fe10c53e831b69e5c5 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { groupBy, sortBy } from 'lodash';
-import { DetailedHotspot, RawHotspot, RiskExposure } from '../../types/security-hotspots';
+import {
+  Hotspot,
+  RawHotspot,
+  ReviewHistoryElement,
+  ReviewHistoryType,
+  RiskExposure
+} from '../../types/security-hotspots';
 
 export const RISK_EXPOSURE_LEVELS = [RiskExposure.HIGH, RiskExposure.MEDIUM, RiskExposure.LOW];
 
@@ -61,7 +67,7 @@ function getCategoryTitle(key: string, securityCategories: T.StandardSecurityCat
 }
 
 export function constructSourceViewerFile(
-  { component, project }: DetailedHotspot,
+  { component, project }: Hotspot,
   lines?: number
 ): T.SourceViewerFile {
   return {
@@ -74,3 +80,36 @@ export function constructSourceViewerFile(
     uuid: ''
   };
 }
+
+export function getHotspotReviewHistory(hotspot: Hotspot): ReviewHistoryElement[] {
+  const history: ReviewHistoryElement[] = [];
+
+  if (hotspot.creationDate) {
+    history.push({
+      type: ReviewHistoryType.Creation,
+      date: hotspot.creationDate,
+      user: {
+        avatar: hotspot.author.avatar,
+        name: hotspot.author.name || hotspot.author.login,
+        active: hotspot.author.active
+      }
+    });
+  }
+
+  if (hotspot.changelog) {
+    history.push(
+      ...hotspot.changelog.map(log => ({
+        type: ReviewHistoryType.Diff,
+        date: log.creationDate,
+        user: {
+          avatar: log.avatar,
+          name: log.userName || log.user,
+          active: log.isUserActive
+        },
+        diffs: log.diffs
+      }))
+    );
+  }
+
+  return sortBy(history, elt => elt.date);
+}
index a0633c1892e77f7ea3fe92b08cc50c5d7b6ddee5..4351dc56ecbca45c800f631622e77a41c8365411 100644 (file)
  */
 import { ComponentQualifier } from '../../types/component';
 import {
-  DetailedHotspot,
-  DetailedHotspotRule,
+  Hotspot,
   HotspotResolution,
+  HotspotRule,
   HotspotStatus,
   RawHotspot,
+  ReviewHistoryElement,
+  ReviewHistoryType,
   RiskExposure
 } from '../../types/security-hotspots';
 import { mockComponent, mockUser } from '../testMocks';
@@ -47,10 +49,11 @@ export function mockRawHotspot(overrides: Partial<RawHotspot> = {}): RawHotspot
   };
 }
 
-export function mockDetailledHotspot(overrides?: Partial<DetailedHotspot>): DetailedHotspot {
+export function mockHotspot(overrides?: Partial<Hotspot>): Hotspot {
   return {
     assignee: mockUser(),
     author: mockUser(),
+    changelog: [],
     component: mockComponent({ qualifier: ComponentQualifier.File }),
     creationDate: '2013-05-13T17:55:41+0200',
     key: '01fc972e-2a3c-433e-bcae-0bd7f88f5123',
@@ -58,7 +61,7 @@ export function mockDetailledHotspot(overrides?: Partial<DetailedHotspot>): Deta
     message: "'3' is a magic number.",
     project: mockComponent({ qualifier: ComponentQualifier.Project }),
     resolution: HotspotResolution.FIXED,
-    rule: mockDetailledHotspotRule(),
+    rule: mockHotspotRule(),
     status: HotspotStatus.REVIEWED,
     textRange: {
       startLine: 142,
@@ -71,9 +74,7 @@ export function mockDetailledHotspot(overrides?: Partial<DetailedHotspot>): Deta
   };
 }
 
-export function mockDetailledHotspotRule(
-  overrides?: Partial<DetailedHotspotRule>
-): DetailedHotspotRule {
+export function mockHotspotRule(overrides?: Partial<HotspotRule>): HotspotRule {
   return {
     key: 'squid:S2077',
     name: 'That rule',
@@ -85,3 +86,14 @@ export function mockDetailledHotspotRule(
     ...overrides
   };
 }
+
+export function mockHotspotReviewHistoryElement(
+  overrides?: Partial<ReviewHistoryElement>
+): ReviewHistoryElement {
+  return {
+    date: '2019-09-13T17:55:42+0200',
+    type: ReviewHistoryType.Creation,
+    user: mockUser(),
+    ...overrides
+  };
+}
index 51d8134ca39418291dfae70b28d0d47cf62399d4..a7e3536f4144f6dbff45f77cf9b7a066e53d29c4 100644 (file)
@@ -68,9 +68,10 @@ export interface RawHotspot {
   vulnerabilityProbability: RiskExposure;
 }
 
-export interface DetailedHotspot {
+export interface Hotspot {
   assignee?: Pick<T.UserBase, 'active' | 'login' | 'name'>;
-  author?: Pick<T.UserBase, 'login'>;
+  author: Pick<T.UserBase, 'active' | 'avatar' | 'login' | 'name'>;
+  changelog?: T.IssueChangelog[];
   component: T.Component;
   creationDate: string;
   key: string;
@@ -78,7 +79,7 @@ export interface DetailedHotspot {
   message: string;
   project: T.Component;
   resolution?: string;
-  rule: DetailedHotspotRule;
+  rule: HotspotRule;
   status: string;
   textRange: T.TextRange;
   updateDate: string;
@@ -93,7 +94,7 @@ export interface HotspotUpdate extends HotspotUpdateFields {
   key: string;
 }
 
-export interface DetailedHotspotRule {
+export interface HotspotRule {
   fixRecommendations?: string;
   key: string;
   name: string;
@@ -103,6 +104,18 @@ export interface DetailedHotspotRule {
   vulnerabilityProbability: RiskExposure;
 }
 
+export interface ReviewHistoryElement {
+  type: ReviewHistoryType;
+  date: string;
+  user: Pick<T.UserBase, 'active' | 'avatar' | 'name'>;
+  diffs?: T.IssueChangelogDiff[];
+}
+
+export enum ReviewHistoryType {
+  Creation,
+  Diff
+}
+
 export interface HotspotSearchResponse {
   components?: { key: string; qualifier: string; name: string }[];
   hotspots: RawHotspot[];
index 237bc3805126b2b1fa2d6c3064a503708ac32e73..8c26488e343ad6fd77fbfe20e2e1fec2954c885a 100644 (file)
@@ -656,9 +656,12 @@ hotspots.risk_exposure=Review priority:
 hotspot.category=Category:
 hotspot.status=Status:
 hotspot.assigned_to=Assigned to:
-hotspot.tabs.risk_description=What's the risk?
-hotspot.tabs.vulnerability_description=Are you vulnerable?
-hotspot.tabs.fix_recommendations=How can you fix it?
+hotspots.tabs.risk_description=What's the risk?
+hotspots.tabs.vulnerability_description=Are you vulnerable?
+hotspots.tabs.fix_recommendations=How can you fix it?
+hotspots.tabs.review_history=Review history
+hotspots.tabs.review_history.created=created Security Hotspot
+
 hotspot.change_status.REVIEWED=Change status
 hotspot.change_status.TO_REVIEW=Review Hotspot
 
@@ -672,6 +675,7 @@ hotspot.filters.assignee.all=All
 hotspot.filters.status.to_review=To review
 hotspot.filters.status.fixed=Reviewed as fixed
 hotspot.filters.status.safe=Reviewed as safe
+hotspots.review_hotspot=Review Hotspot
 
 hotspots.form.title=Mark Security Hotspot as: