]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12719 Improve visual feedback of hotspots status update
authorJeremy Davis <jeremy.davis@sonarsource.com>
Fri, 14 Feb 2020 10:28:25 +0000 (11:28 +0100)
committerSonarTech <sonartech@sonarsource.com>
Fri, 21 Feb 2020 19:46:19 +0000 (20:46 +0100)
22 files changed:
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/utils-test.ts
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCategory.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.css
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotCategory-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotList-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotListItem-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotList-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/Assignee-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelection.tsx
server/sonar-web/src/main/js/apps/security-hotspots/utils.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 13e16ce83b8399717b92f06d24d44b664994e834..c2a81755b4d4ca72f4b2dc60b4ce6ec2386b201c 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { Location } from 'history';
+import { flatMap, range } from 'lodash';
 import * as React from 'react';
 import { addNoFooterPageClass, removeNoFooterPageClass } from 'sonar-ui-common/helpers/pages';
 import { getMeasures } from '../../api/measures';
@@ -35,7 +36,6 @@ import {
   HotspotResolution,
   HotspotStatus,
   HotspotStatusFilter,
-  HotspotUpdate,
   RawHotspot
 } from '../../types/security-hotspots';
 import SecurityHotspotsAppRenderer from './SecurityHotspotsAppRenderer';
@@ -61,7 +61,7 @@ interface State {
   loadingMeasure: boolean;
   loadingMore: boolean;
   securityCategories: T.StandardSecurityCategories;
-  selectedHotspotKey: string | undefined;
+  selectedHotspot: RawHotspot | undefined;
   filters: HotspotFilters;
 }
 
@@ -79,7 +79,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
       hotspots: [],
       hotspotsPageIndex: 1,
       securityCategories: {},
-      selectedHotspotKey: undefined,
+      selectedHotspot: undefined,
       filters: {
         ...this.constructFiltersFromProps(props),
         status: HotspotStatusFilter.TO_REVIEW
@@ -150,13 +150,13 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
           hotspotsTotal: paging.total,
           loading: false,
           securityCategories: sonarsourceSecurity,
-          selectedHotspotKey: hotspots.length > 0 ? hotspots[0].key : undefined
+          selectedHotspot: hotspots.length > 0 ? hotspots[0] : undefined
         });
       })
       .catch(this.handleCallFailure);
   }
 
-  fetchSecurityHotspotsReviewed() {
+  fetchSecurityHotspotsReviewed = () => {
     const { branchLike, component } = this.props;
     const { filters } = this.state;
 
@@ -186,7 +186,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
           this.setState({ loadingMeasure: false });
         }
       });
-  }
+  };
 
   fetchSecurityHotspots(page = 1) {
     const { branchLike, component, location } = this.props;
@@ -241,7 +241,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
           hotspotsPageIndex: 1,
           hotspotsTotal: paging.total,
           loading: false,
-          selectedHotspotKey: hotspots.length > 0 ? hotspots[0].key : undefined
+          selectedHotspot: hotspots.length > 0 ? hotspots[0] : undefined
         });
       })
       .catch(this.handleCallFailure);
@@ -259,24 +259,30 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     );
   };
 
-  handleHotspotClick = (key: string) => this.setState({ selectedHotspotKey: key });
+  handleHotspotClick = (selectedHotspot: RawHotspot) => this.setState({ selectedHotspot });
 
-  handleHotspotUpdate = ({ key, status, resolution }: HotspotUpdate) => {
-    this.setState(({ hotspots }) => {
-      const index = hotspots.findIndex(h => h.key === key);
+  handleHotspotUpdate = (hotspotKey: string) => {
+    const { hotspots, hotspotsPageIndex } = this.state;
+    const index = hotspots.findIndex(h => h.key === hotspotKey);
 
-      if (index > -1) {
-        const hotspot = {
-          ...hotspots[index],
-          status,
-          resolution
-        };
+    return Promise.all(
+      range(hotspotsPageIndex).map(p => this.fetchSecurityHotspots(p + 1 /* pages are 1-indexed */))
+    )
+      .then(hotspotPages => {
+        const allHotspots = flatMap(hotspotPages, 'hotspots');
 
-        return { hotspots: [...hotspots.slice(0, index), hotspot, ...hotspots.slice(index + 1)] };
-      }
-      return null;
-    });
-    return this.fetchSecurityHotspotsReviewed();
+        const { paging } = hotspotPages[hotspotPages.length - 1];
+
+        const nextHotspot = allHotspots[Math.min(index, allHotspots.length - 1)];
+
+        this.setState({
+          hotspots: allHotspots,
+          hotspotsPageIndex: paging.pageIndex,
+          hotspotsTotal: paging.total,
+          selectedHotspot: nextHotspot
+        });
+      })
+      .then(this.fetchSecurityHotspotsReviewed);
   };
 
   handleShowAllHotspots = () => {
@@ -317,7 +323,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
       loadingMeasure,
       loadingMore,
       securityCategories,
-      selectedHotspotKey,
+      selectedHotspot,
       filters
     } = this.state;
 
@@ -339,7 +345,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
         onShowAllHotspots={this.handleShowAllHotspots}
         onUpdateHotspot={this.handleHotspotUpdate}
         securityCategories={securityCategories}
-        selectedHotspotKey={selectedHotspotKey}
+        selectedHotspot={selectedHotspot}
       />
     );
   }
index 898dcbe50749c839825c7bd67d87def29d4ebcc2..51b2558f12ba10b7af803c84021801a2d287065c 100644 (file)
@@ -26,12 +26,7 @@ import Suggestions from '../../app/components/embed-docs-modal/Suggestions';
 import ScreenPositionHelper from '../../components/common/ScreenPositionHelper';
 import { isBranch } from '../../helpers/branch-like';
 import { BranchLike } from '../../types/branch-like';
-import {
-  HotspotFilters,
-  HotspotStatusFilter,
-  HotspotUpdate,
-  RawHotspot
-} from '../../types/security-hotspots';
+import { HotspotFilters, HotspotStatusFilter, RawHotspot } from '../../types/security-hotspots';
 import EmptyHotspotsPage from './components/EmptyHotspotsPage';
 import FilterBar from './components/FilterBar';
 import HotspotList from './components/HotspotList';
@@ -50,11 +45,11 @@ export interface SecurityHotspotsAppRendererProps {
   loadingMeasure: boolean;
   loadingMore: boolean;
   onChangeFilters: (filters: Partial<HotspotFilters>) => void;
-  onHotspotClick: (key: string) => void;
+  onHotspotClick: (hotspot: RawHotspot) => void;
   onLoadMore: () => void;
   onShowAllHotspots: () => void;
-  onUpdateHotspot: (hotspot: HotspotUpdate) => void;
-  selectedHotspotKey?: string;
+  onUpdateHotspot: (hotspotKey: string) => Promise<void>;
+  selectedHotspot: RawHotspot | undefined;
   securityCategories: T.StandardSecurityCategories;
 }
 
@@ -70,7 +65,7 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
     loadingMeasure,
     loadingMore,
     securityCategories,
-    selectedHotspotKey,
+    selectedHotspot,
     filters
   } = props;
 
@@ -98,7 +93,7 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
               <DeferredSpinner className="huge-spacer-left big-spacer-top" />
             ) : (
               <>
-                {hotspots.length === 0 ? (
+                {hotspots.length === 0 || !selectedHotspot ? (
                   <EmptyHotspotsPage
                     filtered={
                       filters.assignedToMe ||
@@ -118,19 +113,17 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
                         onHotspotClick={props.onHotspotClick}
                         onLoadMore={props.onLoadMore}
                         securityCategories={securityCategories}
-                        selectedHotspotKey={selectedHotspotKey}
+                        selectedHotspot={selectedHotspot}
                         statusFilter={filters.status}
                       />
                     </div>
                     <div className="main">
-                      {selectedHotspotKey && (
-                        <HotspotViewer
-                          branchLike={branchLike}
-                          hotspotKey={selectedHotspotKey}
-                          onUpdateHotspot={props.onUpdateHotspot}
-                          securityCategories={securityCategories}
-                        />
-                      )}
+                      <HotspotViewer
+                        branchLike={branchLike}
+                        hotspotKey={selectedHotspot.key}
+                        onUpdateHotspot={props.onUpdateHotspot}
+                        securityCategories={securityCategories}
+                      />
                     </div>
                   </div>
                 )}
index 8437fcffb585cd044297ae3e0345b6d61b4d578a..79cd1f4ed8e2bb2d1bd31636db759517a6c84703 100644 (file)
@@ -99,7 +99,7 @@ it('should load data correctly', async () => {
 
   expect(wrapper.state().loading).toBe(false);
   expect(wrapper.state().hotspots).toEqual(hotspots);
-  expect(wrapper.state().selectedHotspotKey).toBe(hotspots[0].key);
+  expect(wrapper.state().selectedHotspot).toBe(hotspots[0]);
   expect(wrapper.state().securityCategories).toEqual({
     cat1: { title: 'cat 1' }
   });
@@ -219,35 +219,43 @@ it('should handle hotspot update', async () => {
   const hotspots = [mockRawHotspot(), mockRawHotspot({ key })];
   (getSecurityHotspots as jest.Mock).mockResolvedValueOnce({
     hotspots,
-    paging: { total: 2 }
+    paging: { pageIndex: 1, total: 1252 }
   });
 
   const wrapper = shallowRender();
-
   await waitAndUpdate(wrapper);
+  wrapper.setState({ hotspotsPageIndex: 2 });
 
-  wrapper
+  jest.clearAllMocks();
+  (getSecurityHotspots as jest.Mock)
+    .mockResolvedValueOnce({
+      hotspots: [mockRawHotspot()],
+      paging: { pageIndex: 1, total: 1251 }
+    })
+    .mockResolvedValueOnce({
+      hotspots: [mockRawHotspot()],
+      paging: { pageIndex: 2, total: 1251 }
+    });
+
+  const selectedHotspotIndex = wrapper
+    .state()
+    .hotspots.findIndex(h => h.key === wrapper.state().selectedHotspot?.key);
+
+  await wrapper
     .find(SecurityHotspotsAppRenderer)
     .props()
-    .onUpdateHotspot({ key, status: HotspotStatus.REVIEWED, resolution: HotspotResolution.SAFE });
+    .onUpdateHotspot(key);
 
-  expect(wrapper.state().hotspots[0]).toEqual(hotspots[0]);
-  expect(wrapper.state().hotspots[1]).toEqual({
-    ...hotspots[1],
-    status: HotspotStatus.REVIEWED,
-    resolution: HotspotResolution.SAFE
-  });
-  expect(getMeasures).toBeCalled();
+  expect(getSecurityHotspots).toHaveBeenCalledTimes(2);
 
-  await waitAndUpdate(wrapper);
-  const previousState = wrapper.state();
-  wrapper.instance().handleHotspotUpdate({
-    key: 'unknown',
-    status: HotspotStatus.REVIEWED,
-    resolution: HotspotResolution.SAFE
-  });
-  await waitAndUpdate(wrapper);
-  expect(wrapper.state()).toEqual(previousState);
+  expect(wrapper.state().hotspots).toHaveLength(2);
+  expect(wrapper.state().hotspotsPageIndex).toBe(2);
+  expect(wrapper.state().hotspotsTotal).toBe(1251);
+  expect(
+    wrapper.state().hotspots.findIndex(h => h.key === wrapper.state().selectedHotspot?.key)
+  ).toBe(selectedHotspotIndex);
+
+  expect(getMeasures).toBeCalled();
 });
 
 it('should handle status filter change', async () => {
index b90229c0aff6640f6d87e3393a8709ba778888e7..ab89cfc8616ab27bc8849eccff7a917d43105744 100644 (file)
@@ -51,7 +51,7 @@ it('should render correctly with hotspots', () => {
       .dive()
   ).toMatchSnapshot();
   expect(
-    shallowRender({ hotspots, hotspotsTotal: 3, selectedHotspotKey: 'h2' })
+    shallowRender({ hotspots, hotspotsTotal: 3, selectedHotspot: mockRawHotspot({ key: 'h2' }) })
       .find(ScreenPositionHelper)
       .dive()
   ).toMatchSnapshot();
@@ -89,6 +89,7 @@ function shallowRender(props: Partial<SecurityHotspotsAppRendererProps> = {}) {
       onShowAllHotspots={jest.fn()}
       onUpdateHotspot={jest.fn()}
       securityCategories={{}}
+      selectedHotspot={undefined}
       {...props}
     />
   );
index 64e20d6e7c93a03569159361a386e67471a7e665..0b68326d146fbc62750155c778da004c0eb20a7e 100644 (file)
@@ -46,60 +46,10 @@ exports[`should render correctly with hotspots 1`] = `
     <A11ySkipTarget
       anchor="security_hotspots_main"
     />
-    <div
-      className="layout-page"
-    >
-      <div
-        className="sidebar"
-      >
-        <HotspotList
-          hotspots={
-            Array [
-              Object {
-                "author": "Developer 1",
-                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
-                "creationDate": "2013-05-13T17:55:39+0200",
-                "key": "h1",
-                "line": 81,
-                "message": "'3' is a magic number.",
-                "project": "com.github.kevinsawicki:http-request",
-                "resolution": undefined,
-                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
-                "securityCategory": "command-injection",
-                "status": "TO_REVIEW",
-                "updateDate": "2013-05-13T17:55:39+0200",
-                "vulnerabilityProbability": "HIGH",
-              },
-              Object {
-                "author": "Developer 1",
-                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
-                "creationDate": "2013-05-13T17:55:39+0200",
-                "key": "h2",
-                "line": 81,
-                "message": "'3' is a magic number.",
-                "project": "com.github.kevinsawicki:http-request",
-                "resolution": undefined,
-                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
-                "securityCategory": "command-injection",
-                "status": "TO_REVIEW",
-                "updateDate": "2013-05-13T17:55:39+0200",
-                "vulnerabilityProbability": "HIGH",
-              },
-            ]
-          }
-          hotspotsTotal={2}
-          isStaticListOfHotspots={true}
-          loadingMore={false}
-          onHotspotClick={[MockFunction]}
-          onLoadMore={[MockFunction]}
-          securityCategories={Object {}}
-          statusFilter="TO_REVIEW"
-        />
-      </div>
-      <div
-        className="main"
-      />
-    </div>
+    <EmptyHotspotsPage
+      filtered={false}
+      isStaticListOfHotspots={true}
+    />
   </div>
 </div>
 `;
@@ -172,7 +122,23 @@ exports[`should render correctly with hotspots 2`] = `
           onHotspotClick={[MockFunction]}
           onLoadMore={[MockFunction]}
           securityCategories={Object {}}
-          selectedHotspotKey="h2"
+          selectedHotspot={
+            Object {
+              "author": "Developer 1",
+              "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+              "creationDate": "2013-05-13T17:55:39+0200",
+              "key": "h2",
+              "line": 81,
+              "message": "'3' is a magic number.",
+              "project": "com.github.kevinsawicki:http-request",
+              "resolution": undefined,
+              "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+              "securityCategory": "command-injection",
+              "status": "TO_REVIEW",
+              "updateDate": "2013-05-13T17:55:39+0200",
+              "vulnerabilityProbability": "HIGH",
+            }
+          }
           statusFilter="TO_REVIEW"
         />
       </div>
index 0f134e3b118f2d68d7682a3afdfe0c6550b20e15..174d4dbe7e20219e5fbd4adc95e3f3fb82bd4921 100644 (file)
@@ -125,19 +125,10 @@ describe('sortHotspots', () => {
 });
 
 describe('groupByCategory', () => {
-  it('should group and sort properly', () => {
+  it('should group properly', () => {
     const result = groupByCategory(hotspots, categories);
 
     expect(result).toHaveLength(7);
-    expect(result.map(g => g.key)).toEqual([
-      'xss',
-      'dos',
-      'log-injection',
-      'object-injection',
-      'ssrf',
-      'xxe',
-      'xpath-injection'
-    ]);
   });
 });
 
index be732964b3b14df35416a8cbd52c8e6bc7999e0a..2e9664662ef18f436c42d69ce68f1ab05400c3b8 100644 (file)
@@ -25,17 +25,17 @@ import { RawHotspot } from '../../../types/security-hotspots';
 import HotspotListItem from './HotspotListItem';
 
 export interface HotspotCategoryProps {
+  categoryKey: string;
+  expanded: boolean;
   hotspots: RawHotspot[];
-  onHotspotClick: (key: string) => void;
-  selectedHotspotKey: string | undefined;
-  startsExpanded: boolean;
+  onHotspotClick: (hotspot: RawHotspot) => void;
+  onToggleExpand: (categoryKey: string, value: boolean) => void;
+  selectedHotspot: RawHotspot;
   title: string;
 }
 
 export default function HotspotCategory(props: HotspotCategoryProps) {
-  const { hotspots, selectedHotspotKey, startsExpanded, title } = props;
-
-  const [expanded, setExpanded] = React.useState(startsExpanded);
+  const { categoryKey, expanded, hotspots, selectedHotspot, title } = props;
 
   if (hotspots.length < 1) {
     return null;
@@ -46,9 +46,12 @@ export default function HotspotCategory(props: HotspotCategoryProps) {
   return (
     <div className={classNames('hotspot-category', risk)}>
       <a
-        className="hotspot-category-header display-flex-space-between display-flex-center"
+        className={classNames(
+          'hotspot-category-header display-flex-space-between display-flex-center',
+          { 'contains-selected-hotspot': selectedHotspot.securityCategory === categoryKey }
+        )}
         href="#"
-        onClick={() => setExpanded(!expanded)}>
+        onClick={() => props.onToggleExpand(categoryKey, !expanded)}>
         <strong className="flex-1">{title}</strong>
         <span>
           <span className="counter-badge">{hotspots.length}</span>
@@ -66,7 +69,7 @@ export default function HotspotCategory(props: HotspotCategoryProps) {
               <HotspotListItem
                 hotspot={h}
                 onClick={props.onHotspotClick}
-                selected={h.key === selectedHotspotKey}
+                selected={h.key === selectedHotspot.key}
               />
             </li>
           ))}
index 242baa85c3027725b27e9c2061164b847583354b..2e8a86dc8b434cd4573f91e81ec6451809aa3943 100644 (file)
@@ -37,7 +37,8 @@
   border-left: 4px solid;
 }
 
-.hotspot-category .hotspot-category-header:hover {
+.hotspot-category .hotspot-category-header:hover,
+.hotspot-category .hotspot-category-header.contains-selected-hotspot {
   color: var(--blue);
 }
 
index 286e9a30b85b125781b99bcb52386ddb01ed32e2..c2dd2b36d9a754d980638a2e3aea3fa3db4dfc65 100644 (file)
@@ -28,81 +28,127 @@ import { groupByCategory, RISK_EXPOSURE_LEVELS } from '../utils';
 import HotspotCategory from './HotspotCategory';
 import './HotspotList.css';
 
-export interface HotspotListProps {
+interface Props {
   hotspots: RawHotspot[];
   hotspotsTotal?: number;
   isStaticListOfHotspots: boolean;
   loadingMore: boolean;
-  onHotspotClick: (key: string) => void;
+  onHotspotClick: (hotspot: RawHotspot) => void;
   onLoadMore: () => void;
   securityCategories: T.StandardSecurityCategories;
-  selectedHotspotKey: string | undefined;
+  selectedHotspot: RawHotspot;
   statusFilter: HotspotStatusFilter;
 }
 
-export default function HotspotList(props: HotspotListProps) {
-  const {
-    hotspots,
-    hotspotsTotal,
-    isStaticListOfHotspots,
-    loadingMore,
-    securityCategories,
-    selectedHotspotKey,
-    statusFilter
-  } = props;
-
-  const groupedHotspots: Array<{
+interface State {
+  expandedCategories: T.Dict<boolean>;
+  groupedHotspots: Array<{
     risk: RiskExposure;
     categories: Array<{ key: string; hotspots: RawHotspot[]; title: string }>;
-  }> = React.useMemo(() => {
+  }>;
+}
+
+export default class HotspotList extends React.Component<Props, State> {
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      expandedCategories: { [props.selectedHotspot.securityCategory]: true },
+      groupedHotspots: this.groupHotspots(props.hotspots, props.securityCategories)
+    };
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    // Force open the category of selected hotspot
+    if (
+      this.props.selectedHotspot.securityCategory !== prevProps.selectedHotspot.securityCategory
+    ) {
+      this.handleToggleCategory(this.props.selectedHotspot.securityCategory, true);
+    }
+
+    // Compute the hotspot tree from the list
+    if (
+      this.props.hotspots !== prevProps.hotspots ||
+      this.props.securityCategories !== prevProps.securityCategories
+    ) {
+      const groupedHotspots = this.groupHotspots(
+        this.props.hotspots,
+        this.props.securityCategories
+      );
+      this.setState({ groupedHotspots });
+    }
+  }
+
+  groupHotspots = (hotspots: RawHotspot[], securityCategories: T.StandardSecurityCategories) => {
     const risks = groupBy(hotspots, h => h.vulnerabilityProbability);
 
     return RISK_EXPOSURE_LEVELS.map(risk => ({
       risk,
       categories: groupByCategory(risks[risk], securityCategories)
     })).filter(risk => risk.categories.length > 0);
-  }, [hotspots, securityCategories]);
+  };
+
+  handleToggleCategory = (categoryKey: string, value: boolean) => {
+    this.setState(({ expandedCategories }) => ({
+      expandedCategories: { ...expandedCategories, [categoryKey]: value }
+    }));
+  };
+
+  render() {
+    const {
+      hotspots,
+      hotspotsTotal,
+      isStaticListOfHotspots,
+      loadingMore,
+      selectedHotspot,
+      statusFilter
+    } = this.props;
+
+    const { expandedCategories, groupedHotspots } = this.state;
 
-  return (
-    <div className="huge-spacer-bottom">
-      <h1 className="hotspot-list-header bordered-bottom">
-        <SecurityHotspotIcon className="spacer-right" />
-        {translateWithParameters(
-          isStaticListOfHotspots ? 'hotspots.list_title' : `hotspots.list_title.${statusFilter}`,
-          hotspots.length
-        )}
-      </h1>
-      <ul className="big-spacer-bottom">
-        {groupedHotspots.map((riskGroup, groupIndex) => (
-          <li className="big-spacer-bottom" key={riskGroup.risk}>
-            <div className="hotspot-risk-header little-spacer-left">
-              <span>{translate('hotspots.risk_exposure')}</span>
-              <div className={classNames('hotspot-risk-badge', 'spacer-left', riskGroup.risk)}>
-                {translate('risk_exposure', riskGroup.risk)}
+    return (
+      <div className="huge-spacer-bottom">
+        <h1 className="hotspot-list-header bordered-bottom">
+          <SecurityHotspotIcon className="spacer-right" />
+          {translateWithParameters(
+            isStaticListOfHotspots ? 'hotspots.list_title' : `hotspots.list_title.${statusFilter}`,
+            hotspots.length
+          )}
+        </h1>
+        <ul className="big-spacer-bottom">
+          {groupedHotspots.map(riskGroup => (
+            <li className="big-spacer-bottom" key={riskGroup.risk}>
+              <div className="hotspot-risk-header little-spacer-left">
+                <span>{translate('hotspots.risk_exposure')}</span>
+                <div className={classNames('hotspot-risk-badge', 'spacer-left', riskGroup.risk)}>
+                  {translate('risk_exposure', riskGroup.risk)}
+                </div>
               </div>
-            </div>
-            <ul>
-              {riskGroup.categories.map((cat, catIndex) => (
-                <li className="spacer-bottom" key={cat.key}>
-                  <HotspotCategory
-                    hotspots={cat.hotspots}
-                    onHotspotClick={props.onHotspotClick}
-                    selectedHotspotKey={selectedHotspotKey}
-                    startsExpanded={groupIndex === 0 && catIndex === 0}
-                    title={cat.title}
-                  />
-                </li>
-              ))}
-            </ul>
-          </li>
-        ))}
-      </ul>
-      <ListFooter
-        count={hotspots.length}
-        loadMore={!loadingMore ? props.onLoadMore : undefined}
-        loading={loadingMore}
-        total={hotspotsTotal}
-      />
-    </div>
-  );
+              <ul>
+                {riskGroup.categories.map(cat => (
+                  <li className="spacer-bottom" key={cat.key}>
+                    <HotspotCategory
+                      categoryKey={cat.key}
+                      expanded={expandedCategories[cat.key]}
+                      hotspots={cat.hotspots}
+                      onHotspotClick={this.props.onHotspotClick}
+                      onToggleExpand={this.handleToggleCategory}
+                      selectedHotspot={selectedHotspot}
+                      title={cat.title}
+                    />
+                  </li>
+                ))}
+              </ul>
+            </li>
+          ))}
+        </ul>
+        <ListFooter
+          count={hotspots.length}
+          loadMore={!loadingMore ? this.props.onLoadMore : undefined}
+          loading={loadingMore}
+          total={hotspotsTotal}
+        />
+      </div>
+    );
+  }
 }
index 59540a73c4eefd271802f0805a92441378d87418..f865f3722d319f5d2a7a3ad08e0a697890174c96 100644 (file)
@@ -25,7 +25,7 @@ import { getStatusOptionFromStatusAndResolution } from '../utils';
 
 export interface HotspotListItemProps {
   hotspot: RawHotspot;
-  onClick: (key: string) => void;
+  onClick: (hotspot: RawHotspot) => void;
   selected: boolean;
 }
 
@@ -35,7 +35,7 @@ export default function HotspotListItem(props: HotspotListItemProps) {
     <a
       className={classNames('hotspot-item', { highlight: selected })}
       href="#"
-      onClick={() => !selected && props.onClick(hotspot.key)}>
+      onClick={() => !selected && props.onClick(hotspot)}>
       <div className="little-spacer-left">{hotspot.message}</div>
       <div className="badge spacer-top">
         {translate(
index 480b3001dd0d1ac3f72b5bfcc94d8d955982fe98..37ba943c2bcb2d5f865d2fd62cfdd3393d99f457 100644 (file)
 import * as React from 'react';
 import { getSecurityHotspotDetails } from '../../../api/security-hotspots';
 import { BranchLike } from '../../../types/branch-like';
-import { Hotspot, HotspotUpdate } from '../../../types/security-hotspots';
+import { Hotspot } from '../../../types/security-hotspots';
 import HotspotViewerRenderer from './HotspotViewerRenderer';
 
 interface Props {
   branchLike?: BranchLike;
   hotspotKey: string;
-  onUpdateHotspot: (hotspot: HotspotUpdate) => void;
+  onUpdateHotspot: (hotspotKey: string) => Promise<void>;
   securityCategories: T.StandardSecurityCategories;
 }
 
@@ -70,11 +70,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> {
   handleHotspotUpdate = () => {
     return this.fetchHotspot().then((hotspot?: Hotspot) => {
       if (hotspot) {
-        this.props.onUpdateHotspot({
-          key: hotspot.key,
-          status: hotspot.status,
-          resolution: hotspot.resolution
-        });
+        return this.props.onUpdateHotspot(hotspot.key);
       }
     });
   };
index 3a841add34abfccd1b1cd1d9644be66adfe26e86..ca3f618ec4168df404b8040ae9a54f0e50592875 100644 (file)
@@ -31,7 +31,7 @@ export interface HotspotViewerRendererProps {
   branchLike?: BranchLike;
   hotspot?: Hotspot;
   loading: boolean;
-  onUpdateHotspot: () => void;
+  onUpdateHotspot: () => Promise<void>;
   securityCategories: T.StandardSecurityCategories;
 }
 
index 99a432c2b5deb24e56e0efa98a679cdabe5d83dd..37c1ef087684efd97c27e0d9d2608375c9b3fa55 100644 (file)
@@ -27,30 +27,49 @@ it('should render correctly', () => {
 });
 
 it('should render correctly with hotspots', () => {
-  const hotspots = [mockRawHotspot({ key: 'h1' }), mockRawHotspot({ key: 'h2' })];
+  const securityCategory = 'command-injection';
+  const hotspots = [
+    mockRawHotspot({ key: 'h1', securityCategory }),
+    mockRawHotspot({ key: 'h2', securityCategory })
+  ];
   expect(shallowRender({ hotspots })).toMatchSnapshot();
-  expect(shallowRender({ hotspots, startsExpanded: false })).toMatchSnapshot('collapsed');
+  expect(shallowRender({ hotspots, expanded: false })).toMatchSnapshot('collapsed');
+  expect(
+    shallowRender({ categoryKey: securityCategory, hotspots, selectedHotspot: hotspots[0] })
+  ).toMatchSnapshot('contains selected');
 });
 
 it('should handle collapse and expand', () => {
-  const wrapper = shallowRender({ hotspots: [mockRawHotspot()] });
+  const onToggleExpand = jest.fn();
+
+  const categoryKey = 'xss-injection';
+
+  const wrapper = shallowRender({
+    categoryKey,
+    expanded: true,
+    hotspots: [mockRawHotspot()],
+    onToggleExpand
+  });
 
   wrapper.find('.hotspot-category-header').simulate('click');
 
-  expect(wrapper).toMatchSnapshot();
+  expect(onToggleExpand).toBeCalledWith(categoryKey, false);
 
+  wrapper.setProps({ expanded: false });
   wrapper.find('.hotspot-category-header').simulate('click');
 
-  expect(wrapper).toMatchSnapshot();
+  expect(onToggleExpand).toBeCalledWith(categoryKey, true);
 });
 
 function shallowRender(props: Partial<HotspotCategoryProps> = {}) {
   return shallow(
     <HotspotCategory
+      categoryKey="xss-injection"
+      expanded={true}
       hotspots={[]}
       onHotspotClick={jest.fn()}
-      selectedHotspotKey=""
-      startsExpanded={true}
+      onToggleExpand={jest.fn()}
+      selectedHotspot={mockRawHotspot()}
       title="Class Injection"
       {...props}
     />
index caaa7bff5d7fcaf52067aa53465b5c44143aedbc..d34a64de63ac3207404f16ef25fc0357acac6cdb 100644 (file)
@@ -21,7 +21,7 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import { mockRawHotspot } from '../../../../helpers/mocks/security-hotspots';
 import { HotspotStatusFilter, RiskExposure } from '../../../../types/security-hotspots';
-import HotspotList, { HotspotListProps } from '../HotspotList';
+import HotspotList from '../HotspotList';
 
 it('should render correctly', () => {
   expect(shallowRender()).toMatchSnapshot();
@@ -32,32 +32,53 @@ it('should render correctly when the list of hotspot is static', () => {
   expect(shallowRender({ isStaticListOfHotspots: true })).toMatchSnapshot();
 });
 
+const hotspots = [
+  mockRawHotspot({ key: 'h1', securityCategory: 'cat2' }),
+  mockRawHotspot({ key: 'h2', securityCategory: 'cat1' }),
+  mockRawHotspot({
+    key: 'h3',
+    securityCategory: 'cat1',
+    vulnerabilityProbability: RiskExposure.MEDIUM
+  }),
+  mockRawHotspot({
+    key: 'h4',
+    securityCategory: 'cat1',
+    vulnerabilityProbability: RiskExposure.MEDIUM
+  }),
+  mockRawHotspot({
+    key: 'h5',
+    securityCategory: 'cat2',
+    vulnerabilityProbability: RiskExposure.MEDIUM
+  })
+];
+
 it('should render correctly with hotspots', () => {
-  const hotspots = [
-    mockRawHotspot({ key: 'h1', securityCategory: 'cat2' }),
-    mockRawHotspot({ key: 'h2', securityCategory: 'cat1' }),
-    mockRawHotspot({
-      key: 'h3',
-      securityCategory: 'cat1',
-      vulnerabilityProbability: RiskExposure.MEDIUM
-    }),
-    mockRawHotspot({
-      key: 'h4',
-      securityCategory: 'cat1',
-      vulnerabilityProbability: RiskExposure.MEDIUM
-    }),
-    mockRawHotspot({
-      key: 'h5',
-      securityCategory: 'cat2',
-      vulnerabilityProbability: RiskExposure.MEDIUM
-    })
-  ];
   expect(shallowRender({ hotspots })).toMatchSnapshot('no pagination');
   expect(shallowRender({ hotspots, hotspotsTotal: 7 })).toMatchSnapshot('pagination');
 });
 
-function shallowRender(props: Partial<HotspotListProps> = {}) {
-  return shallow(
+it('should update expanded categories correctly', () => {
+  const wrapper = shallowRender({ hotspots, selectedHotspot: hotspots[0] });
+
+  expect(wrapper.state().expandedCategories).toEqual({ cat2: true });
+
+  wrapper.setProps({ selectedHotspot: hotspots[1] });
+
+  expect(wrapper.state().expandedCategories).toEqual({ cat1: true, cat2: true });
+});
+
+it('should update grouped hotspots when the list changes', () => {
+  const wrapper = shallowRender({ hotspots, selectedHotspot: hotspots[0] });
+
+  wrapper.setProps({ hotspots: [mockRawHotspot()] });
+
+  expect(wrapper.state().groupedHotspots).toHaveLength(1);
+  expect(wrapper.state().groupedHotspots[0].categories).toHaveLength(1);
+  expect(wrapper.state().groupedHotspots[0].categories[0].hotspots).toHaveLength(1);
+});
+
+function shallowRender(props: Partial<HotspotList['props']> = {}) {
+  return shallow<HotspotList>(
     <HotspotList
       hotspots={[]}
       isStaticListOfHotspots={false}
@@ -65,7 +86,7 @@ function shallowRender(props: Partial<HotspotListProps> = {}) {
       onHotspotClick={jest.fn()}
       onLoadMore={jest.fn()}
       securityCategories={{}}
-      selectedHotspotKey="h2"
+      selectedHotspot={mockRawHotspot({ key: 'h2' })}
       statusFilter={HotspotStatusFilter.TO_REVIEW}
       {...props}
     />
index 3e6467046a57b91e697eaacbf84d98837719b64b..c728a7a252558fe1a4e5fc9688394f8a1448a4ae 100644 (file)
@@ -34,7 +34,7 @@ it('should handle click', () => {
 
   wrapper.simulate('click');
 
-  expect(onClick).toBeCalledWith(hotspot.key);
+  expect(onClick).toBeCalledWith(hotspot);
 });
 
 function shallowRender(props: Partial<HotspotListItemProps> = {}) {
index a075d958dfeb2d90373a9ed503d257e84d089c11..892425e8a150500edb42544368de54431520ec90 100644 (file)
@@ -1,34 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should handle collapse and expand 1`] = `
-<div
-  className="hotspot-category HIGH"
->
-  <a
-    className="hotspot-category-header display-flex-space-between display-flex-center"
-    href="#"
-    onClick={[Function]}
-  >
-    <strong
-      className="flex-1"
-    >
-      Class Injection
-    </strong>
-    <span>
-      <span
-        className="counter-badge"
-      >
-        1
-      </span>
-      <ChevronDownIcon
-        className="big-spacer-left"
-      />
-    </span>
-  </a>
-</div>
-`;
-
-exports[`should handle collapse and expand 2`] = `
+exports[`should render correctly with hotspots 1`] = `
 <div
   className="hotspot-category HIGH"
 >
@@ -46,7 +18,7 @@ exports[`should handle collapse and expand 2`] = `
       <span
         className="counter-badge"
       >
-        1
+        2
       </span>
       <ChevronUpIcon
         className="big-spacer-left"
@@ -55,7 +27,32 @@ exports[`should handle collapse and expand 2`] = `
   </a>
   <ul>
     <li
-      key="01fc972e-2a3c-433e-bcae-0bd7f88f5123"
+      key="h1"
+    >
+      <HotspotListItem
+        hotspot={
+          Object {
+            "author": "Developer 1",
+            "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+            "creationDate": "2013-05-13T17:55:39+0200",
+            "key": "h1",
+            "line": 81,
+            "message": "'3' is a magic number.",
+            "project": "com.github.kevinsawicki:http-request",
+            "resolution": undefined,
+            "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+            "securityCategory": "command-injection",
+            "status": "TO_REVIEW",
+            "updateDate": "2013-05-13T17:55:39+0200",
+            "vulnerabilityProbability": "HIGH",
+          }
+        }
+        onClick={[MockFunction]}
+        selected={false}
+      />
+    </li>
+    <li
+      key="h2"
     >
       <HotspotListItem
         hotspot={
@@ -63,7 +60,7 @@ exports[`should handle collapse and expand 2`] = `
             "author": "Developer 1",
             "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
             "creationDate": "2013-05-13T17:55:39+0200",
-            "key": "01fc972e-2a3c-433e-bcae-0bd7f88f5123",
+            "key": "h2",
             "line": 81,
             "message": "'3' is a magic number.",
             "project": "com.github.kevinsawicki:http-request",
@@ -83,7 +80,7 @@ exports[`should handle collapse and expand 2`] = `
 </div>
 `;
 
-exports[`should render correctly with hotspots 1`] = `
+exports[`should render correctly with hotspots: collapsed 1`] = `
 <div
   className="hotspot-category HIGH"
 >
@@ -91,6 +88,34 @@ exports[`should render correctly with hotspots 1`] = `
     className="hotspot-category-header display-flex-space-between display-flex-center"
     href="#"
     onClick={[Function]}
+  >
+    <strong
+      className="flex-1"
+    >
+      Class Injection
+    </strong>
+    <span>
+      <span
+        className="counter-badge"
+      >
+        2
+      </span>
+      <ChevronDownIcon
+        className="big-spacer-left"
+      />
+    </span>
+  </a>
+</div>
+`;
+
+exports[`should render correctly with hotspots: contains selected 1`] = `
+<div
+  className="hotspot-category HIGH"
+>
+  <a
+    className="hotspot-category-header display-flex-space-between display-flex-center contains-selected-hotspot"
+    href="#"
+    onClick={[Function]}
   >
     <strong
       className="flex-1"
@@ -131,7 +156,7 @@ exports[`should render correctly with hotspots 1`] = `
           }
         }
         onClick={[MockFunction]}
-        selected={false}
+        selected={true}
       />
     </li>
     <li
@@ -163,32 +188,4 @@ exports[`should render correctly with hotspots 1`] = `
 </div>
 `;
 
-exports[`should render correctly with hotspots: collapsed 1`] = `
-<div
-  className="hotspot-category HIGH"
->
-  <a
-    className="hotspot-category-header display-flex-space-between display-flex-center"
-    href="#"
-    onClick={[Function]}
-  >
-    <strong
-      className="flex-1"
-    >
-      Class Injection
-    </strong>
-    <span>
-      <span
-        className="counter-badge"
-      >
-        2
-      </span>
-      <ChevronDownIcon
-        className="big-spacer-left"
-      />
-    </span>
-  </a>
-</div>
-`;
-
 exports[`should render correctly: empty 1`] = `""`;
index 07c77d60fa5a58b985c82bc872741b9f67f97c27..c7ddd11be7dc65b2fc6e8e0131c2b38ad733b02b 100644 (file)
@@ -102,22 +102,23 @@ exports[`should render correctly with hotspots: no pagination 1`] = `
       <ul>
         <li
           className="spacer-bottom"
-          key="cat1"
+          key="cat2"
         >
           <HotspotCategory
+            categoryKey="cat2"
             hotspots={
               Array [
                 Object {
                   "author": "Developer 1",
                   "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
                   "creationDate": "2013-05-13T17:55:39+0200",
-                  "key": "h2",
+                  "key": "h1",
                   "line": 81,
                   "message": "'3' is a magic number.",
                   "project": "com.github.kevinsawicki:http-request",
                   "resolution": undefined,
                   "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
-                  "securityCategory": "cat1",
+                  "securityCategory": "cat2",
                   "status": "TO_REVIEW",
                   "updateDate": "2013-05-13T17:55:39+0200",
                   "vulnerabilityProbability": "HIGH",
@@ -125,29 +126,46 @@ exports[`should render correctly with hotspots: no pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={true}
-            title="cat1"
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
+            title="cat2"
           />
         </li>
         <li
           className="spacer-bottom"
-          key="cat2"
+          key="cat1"
         >
           <HotspotCategory
+            categoryKey="cat1"
             hotspots={
               Array [
                 Object {
                   "author": "Developer 1",
                   "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
                   "creationDate": "2013-05-13T17:55:39+0200",
-                  "key": "h1",
+                  "key": "h2",
                   "line": 81,
                   "message": "'3' is a magic number.",
                   "project": "com.github.kevinsawicki:http-request",
                   "resolution": undefined,
                   "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
-                  "securityCategory": "cat2",
+                  "securityCategory": "cat1",
                   "status": "TO_REVIEW",
                   "updateDate": "2013-05-13T17:55:39+0200",
                   "vulnerabilityProbability": "HIGH",
@@ -155,9 +173,25 @@ exports[`should render correctly with hotspots: no pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={false}
-            title="cat2"
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
+            title="cat1"
           />
         </li>
       </ul>
@@ -184,6 +218,7 @@ exports[`should render correctly with hotspots: no pagination 1`] = `
           key="cat1"
         >
           <HotspotCategory
+            categoryKey="cat1"
             hotspots={
               Array [
                 Object {
@@ -219,8 +254,24 @@ exports[`should render correctly with hotspots: no pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={false}
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
             title="cat1"
           />
         </li>
@@ -229,6 +280,7 @@ exports[`should render correctly with hotspots: no pagination 1`] = `
           key="cat2"
         >
           <HotspotCategory
+            categoryKey="cat2"
             hotspots={
               Array [
                 Object {
@@ -249,8 +301,24 @@ exports[`should render correctly with hotspots: no pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={false}
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
             title="cat2"
           />
         </li>
@@ -299,22 +367,23 @@ exports[`should render correctly with hotspots: pagination 1`] = `
       <ul>
         <li
           className="spacer-bottom"
-          key="cat1"
+          key="cat2"
         >
           <HotspotCategory
+            categoryKey="cat2"
             hotspots={
               Array [
                 Object {
                   "author": "Developer 1",
                   "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
                   "creationDate": "2013-05-13T17:55:39+0200",
-                  "key": "h2",
+                  "key": "h1",
                   "line": 81,
                   "message": "'3' is a magic number.",
                   "project": "com.github.kevinsawicki:http-request",
                   "resolution": undefined,
                   "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
-                  "securityCategory": "cat1",
+                  "securityCategory": "cat2",
                   "status": "TO_REVIEW",
                   "updateDate": "2013-05-13T17:55:39+0200",
                   "vulnerabilityProbability": "HIGH",
@@ -322,29 +391,46 @@ exports[`should render correctly with hotspots: pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={true}
-            title="cat1"
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
+            title="cat2"
           />
         </li>
         <li
           className="spacer-bottom"
-          key="cat2"
+          key="cat1"
         >
           <HotspotCategory
+            categoryKey="cat1"
             hotspots={
               Array [
                 Object {
                   "author": "Developer 1",
                   "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
                   "creationDate": "2013-05-13T17:55:39+0200",
-                  "key": "h1",
+                  "key": "h2",
                   "line": 81,
                   "message": "'3' is a magic number.",
                   "project": "com.github.kevinsawicki:http-request",
                   "resolution": undefined,
                   "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
-                  "securityCategory": "cat2",
+                  "securityCategory": "cat1",
                   "status": "TO_REVIEW",
                   "updateDate": "2013-05-13T17:55:39+0200",
                   "vulnerabilityProbability": "HIGH",
@@ -352,9 +438,25 @@ exports[`should render correctly with hotspots: pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={false}
-            title="cat2"
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
+            title="cat1"
           />
         </li>
       </ul>
@@ -381,6 +483,7 @@ exports[`should render correctly with hotspots: pagination 1`] = `
           key="cat1"
         >
           <HotspotCategory
+            categoryKey="cat1"
             hotspots={
               Array [
                 Object {
@@ -416,8 +519,24 @@ exports[`should render correctly with hotspots: pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={false}
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
             title="cat1"
           />
         </li>
@@ -426,6 +545,7 @@ exports[`should render correctly with hotspots: pagination 1`] = `
           key="cat2"
         >
           <HotspotCategory
+            categoryKey="cat2"
             hotspots={
               Array [
                 Object {
@@ -446,8 +566,24 @@ exports[`should render correctly with hotspots: pagination 1`] = `
               ]
             }
             onHotspotClick={[MockFunction]}
-            selectedHotspotKey="h2"
-            startsExpanded={false}
+            onToggleExpand={[Function]}
+            selectedHotspot={
+              Object {
+                "author": "Developer 1",
+                "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+                "creationDate": "2013-05-13T17:55:39+0200",
+                "key": "h2",
+                "line": 81,
+                "message": "'3' is a magic number.",
+                "project": "com.github.kevinsawicki:http-request",
+                "resolution": undefined,
+                "rule": "checkstyle:com.puppycrawl.tools.checkstyle.checks.coding.MagicNumberCheck",
+                "securityCategory": "command-injection",
+                "status": "TO_REVIEW",
+                "updateDate": "2013-05-13T17:55:39+0200",
+                "vulnerabilityProbability": "HIGH",
+              }
+            }
             title="cat2"
           />
         </li>
index 67afff627657265050ab2a117da163a12f0b9bdb..0a7d9611316fd76c1d353408315996af7eb72f83 100644 (file)
@@ -19,7 +19,9 @@
  */
 
 import * as React from 'react';
+import { translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import { assignSecurityHotspot } from '../../../../api/security-hotspots';
+import addGlobalSuccessMessage from '../../../../app/utils/addGlobalSuccessMessage';
 import { withCurrentUser } from '../../../../components/hoc/withCurrentUser';
 import { isLoggedIn } from '../../../../helpers/users';
 import { Hotspot, HotspotStatus } from '../../../../types/security-hotspots';
@@ -72,6 +74,11 @@ export class Assignee extends React.PureComponent<Props, State> {
             this.props.onAssigneeChange();
           }
         })
+        .then(() =>
+          addGlobalSuccessMessage(
+            translateWithParameters('hotspots.assign.success', newAssignee.name)
+          )
+        )
         .catch(() => this.setState({ loading: false }));
     }
   };
index 79d58a595632f69884aee3aa43bc3fa6a96d740d..0e25ce9ac6a0af8146a56c1db60d6154978c5eaf 100644 (file)
@@ -22,6 +22,7 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
 import { assignSecurityHotspot } from '../../../../../api/security-hotspots';
+import addGlobalSuccessMessage from '../../../../../app/utils/addGlobalSuccessMessage';
 import { mockHotspot } from '../../../../../helpers/mocks/security-hotspots';
 import { mockCurrentUser, mockUser } from '../../../../../helpers/testMocks';
 import { HotspotStatus } from '../../../../../types/security-hotspots';
@@ -32,6 +33,10 @@ jest.mock('../../../../../api/security-hotspots', () => ({
   assignSecurityHotspot: jest.fn()
 }));
 
+jest.mock('../../../../../app/utils/addGlobalSuccessMessage', () => ({
+  default: jest.fn()
+}));
+
 it('should render correctly', () => {
   expect(shallowRender()).toMatchSnapshot();
   expect(
@@ -78,6 +83,7 @@ it('should handle assign event correctly', async () => {
     loading: false
   });
   expect(onAssigneeChange).toHaveBeenCalled();
+  expect(addGlobalSuccessMessage).toHaveBeenCalled();
 });
 
 function shallowRender(props?: Partial<Assignee['props']>) {
index 943fc94f904f54681e28fd28ed0756651e586048..0dd0a989e29db18c25422587639fa3e088418b62 100644 (file)
@@ -18,7 +18,9 @@
  * 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 { setSecurityHotspotStatus } from '../../../../api/security-hotspots';
+import addGlobalSuccessMessage from '../../../../app/utils/addGlobalSuccessMessage';
 import { Hotspot, HotspotStatusOption } from '../../../../types/security-hotspots';
 import {
   getStatusAndResolutionFromStatusOption,
@@ -86,6 +88,14 @@ export default class StatusSelection extends React.PureComponent<Props, State> {
           this.setState({ loading: false });
           this.props.onStatusOptionChange(selectedStatus);
         })
+        .then(() =>
+          addGlobalSuccessMessage(
+            translateWithParameters(
+              'hotspots.update.success',
+              translate('hotspots.status_option', selectedStatus)
+            )
+          )
+        )
         .catch(() => this.setState({ loading: false }));
     }
   };
index 0f3165da5547171952d2630c038807d9ce088431..c0fe1efc9681503c9f9646315ef00b32c837d3e3 100644 (file)
@@ -44,14 +44,11 @@ export function groupByCategory(
 ) {
   const groups = groupBy(hotspots, h => h.securityCategory);
 
-  return sortBy(
-    Object.keys(groups).map(key => ({
-      key,
-      title: getCategoryTitle(key, securityCategories),
-      hotspots: groups[key]
-    })),
-    cat => cat.title
-  );
+  return Object.keys(groups).map(key => ({
+    key,
+    title: getCategoryTitle(key, securityCategories),
+    hotspots: groups[key]
+  }));
 }
 
 export function sortHotspots(
index d5fa52bc55bbbbcdaaca0969540ea23f078a05b3..f4c01c35339434e256e8dae858b93668d90e4a00 100644 (file)
@@ -699,6 +699,9 @@ hotspot.filters.show_all=Show all hotspots
 hotspots.reviewed.tooltip=Percentage of Security Hotspots reviewed (fixed or safe) among all non-closed Security Hotspots.
 hotspots.review_hotspot=Review Hotspot
 
+hotspots.assign.success=Security Hotspot was successfully assigned to {0}
+hotspots.update.success=Security Hotspot status was successfully changed to {0}
+
 #------------------------------------------------------------------------------
 #
 # ISSUES