diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2020-02-14 11:28:25 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2020-02-21 20:46:19 +0100 |
commit | 264d88bf2eec04aa9c5d88f038ef6e19766b66f0 (patch) | |
tree | a5b0bf8dfc48fe2be1faeb8fd0705e33fe97b228 | |
parent | 9e025bf15700eff81e11cb00bcf5e9650f765ee9 (diff) | |
download | sonarqube-264d88bf2eec04aa9c5d88f038ef6e19766b66f0.tar.gz sonarqube-264d88bf2eec04aa9c5d88f038ef6e19766b66f0.zip |
SONAR-12719 Improve visual feedback of hotspots status update
22 files changed, 549 insertions, 342 deletions
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx index 13e16ce83b8..c2a81755b4d 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx @@ -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} /> ); } diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx index 898dcbe5074..51b2558f12b 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx @@ -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> )} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx index 8437fcffb58..79cd1f4ed8e 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-test.tsx @@ -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 () => { diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx index b90229c0aff..ab89cfc8616 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx @@ -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} /> ); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap index 64e20d6e7c9..0b68326d146 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap @@ -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> diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/utils-test.ts index 0f134e3b118..174d4dbe7e2 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/utils-test.ts @@ -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' - ]); }); }); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCategory.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCategory.tsx index be732964b3b..2e9664662ef 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCategory.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotCategory.tsx @@ -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> ))} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.css b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.css index 242baa85c30..2e8a86dc8b4 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.css +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.css @@ -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); } diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.tsx index 286e9a30b85..c2dd2b36d9a 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotList.tsx @@ -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> + ); + } } diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx index 59540a73c4e..f865f3722d3 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotListItem.tsx @@ -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( diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx index 480b3001dd0..37ba943c2bc 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx @@ -21,13 +21,13 @@ 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); } }); }; diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx index 3a841add34a..ca3f618ec41 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx @@ -31,7 +31,7 @@ export interface HotspotViewerRendererProps { branchLike?: BranchLike; hotspot?: Hotspot; loading: boolean; - onUpdateHotspot: () => void; + onUpdateHotspot: () => Promise<void>; securityCategories: T.StandardSecurityCategories; } diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotCategory-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotCategory-test.tsx index 99a432c2b5d..37c1ef08768 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotCategory-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotCategory-test.tsx @@ -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} /> diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotList-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotList-test.tsx index caaa7bff5d7..d34a64de63a 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotList-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotList-test.tsx @@ -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} /> diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotListItem-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotListItem-test.tsx index 3e6467046a5..c728a7a2525 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotListItem-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotListItem-test.tsx @@ -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> = {}) { diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap index a075d958dfe..892425e8a15 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap @@ -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" > @@ -103,6 +100,34 @@ exports[`should render correctly with hotspots 1`] = ` > 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" + > + Class Injection + </strong> + <span> + <span + className="counter-badge" + > + 2 + </span> <ChevronUpIcon className="big-spacer-left" /> @@ -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`] = `""`; diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotList-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotList-test.tsx.snap index 07c77d60fa5..c7ddd11be7d 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotList-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotList-test.tsx.snap @@ -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> diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx index 67afff62765..0a7d9611316 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx @@ -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 })); } }; diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/Assignee-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/Assignee-test.tsx index 79d58a59563..0e25ce9ac6a 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/Assignee-test.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/Assignee-test.tsx @@ -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']>) { diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelection.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelection.tsx index 943fc94f904..0dd0a989e29 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelection.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/status/StatusSelection.tsx @@ -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 })); } }; diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/utils.ts b/server/sonar-web/src/main/js/apps/security-hotspots/utils.ts index 0f3165da554..c0fe1efc968 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/utils.ts +++ b/server/sonar-web/src/main/js/apps/security-hotspots/utils.ts @@ -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( diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index d5fa52bc55b..f4c01c35339 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 |