} from '../../types/security-hotspots';
import SecurityHotspotsAppRenderer from './SecurityHotspotsAppRenderer';
import './styles.css';
-import { sortHotspots } from './utils';
const PAGE_SIZE = 500;
interface State {
hotspotKeys?: string[];
hotspots: RawHotspot[];
+ hotspotsPageIndex: number;
+ hotspotsTotal?: number;
loading: boolean;
+ loadingMore: boolean;
securityCategories: T.StandardSecurityCategories;
selectedHotspotKey: string | undefined;
filters: HotspotFilters;
this.state = {
loading: true,
+ loadingMore: false,
hotspots: [],
+ hotspotsPageIndex: 1,
securityCategories: {},
selectedHotspotKey: undefined,
filters: {
handleCallFailure = () => {
if (this.mounted) {
- this.setState({ loading: false });
+ this.setState({ loading: false, loadingMore: false });
}
};
fetchInitialData() {
return Promise.all([getStandards(), this.fetchSecurityHotspots()])
- .then(([{ sonarsourceSecurity }, response]) => {
+ .then(([{ sonarsourceSecurity }, { hotspots, paging }]) => {
if (!this.mounted) {
return;
}
- const hotspots = sortHotspots(response.hotspots, sonarsourceSecurity);
-
this.setState({
hotspots,
+ hotspotsTotal: paging.total,
loading: false,
securityCategories: sonarsourceSecurity,
selectedHotspotKey: hotspots.length > 0 ? hotspots[0].key : undefined
.catch(this.handleCallFailure);
}
- fetchSecurityHotspots() {
+ fetchSecurityHotspots(page = 1) {
const { branchLike, component, location } = this.props;
const { filters } = this.state;
return getSecurityHotspots({
projectKey: component.key,
- p: 1,
+ p: page,
ps: PAGE_SIZE,
status,
resolution,
}
reloadSecurityHotspotList = () => {
- const { securityCategories } = this.state;
-
this.setState({ loading: true });
return this.fetchSecurityHotspots()
- .then(response => {
+ .then(({ hotspots, paging }) => {
if (!this.mounted) {
return;
}
- const hotspots = sortHotspots(response.hotspots, securityCategories);
-
this.setState({
hotspots,
+ hotspotsPageIndex: 1,
+ hotspotsTotal: paging.total,
loading: false,
selectedHotspotKey: hotspots.length > 0 ? hotspots[0].key : undefined
});
});
};
+ handleLoadMore = () => {
+ const { hotspots, hotspotsPageIndex: hotspotPages } = this.state;
+
+ this.setState({ loadingMore: true });
+
+ return this.fetchSecurityHotspots(hotspotPages + 1)
+ .then(({ hotspots: additionalHotspots }) => {
+ if (!this.mounted) {
+ return;
+ }
+
+ this.setState({
+ hotspots: [...hotspots, ...additionalHotspots],
+ hotspotsPageIndex: hotspotPages + 1,
+ loadingMore: false
+ });
+ })
+ .catch(this.handleCallFailure);
+ };
+
render() {
const { branchLike } = this.props;
const {
hotspotKeys,
hotspots,
+ hotspotsTotal,
loading,
+ loadingMore,
securityCategories,
selectedHotspotKey,
filters
branchLike={branchLike}
filters={filters}
hotspots={hotspots}
+ hotspotsTotal={hotspotsTotal}
isStaticListOfHotspots={Boolean(hotspotKeys && hotspotKeys.length > 0)}
loading={loading}
+ loadingMore={loadingMore}
onChangeFilters={this.handleChangeFilters}
onHotspotClick={this.handleHotspotClick}
+ onLoadMore={this.handleLoadMore}
onShowAllHotspots={this.handleShowAllHotspots}
onUpdateHotspot={this.handleHotspotUpdate}
securityCategories={securityCategories}
branchLike?: BranchLike;
filters: HotspotFilters;
hotspots: RawHotspot[];
+ hotspotsTotal?: number;
isStaticListOfHotspots: boolean;
loading: boolean;
+ loadingMore: boolean;
onChangeFilters: (filters: Partial<HotspotFilters>) => void;
onHotspotClick: (key: string) => void;
+ onLoadMore: () => void;
onShowAllHotspots: () => void;
onUpdateHotspot: (hotspot: HotspotUpdate) => void;
selectedHotspotKey?: string;
const {
branchLike,
hotspots,
+ hotspotsTotal,
isStaticListOfHotspots,
loading,
+ loadingMore,
securityCategories,
selectedHotspotKey,
filters
<div className="sidebar">
<HotspotList
hotspots={hotspots}
+ hotspotsTotal={hotspotsTotal}
isStaticListOfHotspots={isStaticListOfHotspots}
+ loadingMore={loadingMore}
onHotspotClick={props.onHotspotClick}
+ onLoadMore={props.onLoadMore}
securityCategories={securityCategories}
selectedHotspotKey={selectedHotspotKey}
statusFilter={filters.status}
}));
jest.mock('../../../api/security-hotspots', () => ({
- getSecurityHotspots: jest.fn().mockResolvedValue({ hotspots: [], rules: [] }),
+ getSecurityHotspots: jest.fn().mockResolvedValue({ hotspots: [], paging: { total: 0 } }),
getSecurityHotspotList: jest.fn().mockResolvedValue({ hotspots: [], rules: [] })
}));
jest.mock('../../../helpers/security-standard', () => ({
- getStandards: jest.fn()
+ getStandards: jest.fn().mockResolvedValue({ sonarsourceSecurity: { cat1: { title: 'cat 1' } } })
}));
const branch = mockBranch();
});
it('should load data correctly', async () => {
- const sonarsourceSecurity = { cat1: { title: 'cat 1' } };
- (getStandards as jest.Mock).mockResolvedValue({ sonarsourceSecurity });
-
const hotspots = [mockRawHotspot()];
- (getSecurityHotspots as jest.Mock).mockResolvedValueOnce({
- hotspots
+ (getSecurityHotspots as jest.Mock).mockResolvedValue({
+ hotspots,
+ paging: {
+ total: 1
+ }
});
const wrapper = shallowRender();
expect(wrapper.state().loading).toBe(false);
expect(wrapper.state().hotspots).toEqual(hotspots);
expect(wrapper.state().selectedHotspotKey).toBe(hotspots[0].key);
- expect(wrapper.state().securityCategories).toBe(sonarsourceSecurity);
+ expect(wrapper.state().securityCategories).toEqual({
+ cat1: { title: 'cat 1' }
+ });
expect(wrapper.state());
});
it('should load data correctly when hotspot key list is forced', async () => {
- const sonarsourceSecurity = { cat1: { title: 'cat 1' } };
- (getStandards as jest.Mock).mockResolvedValue({ sonarsourceSecurity });
-
const hotspots = [
mockRawHotspot({ key: 'test1' }),
mockRawHotspot({ key: 'test2' }),
).toBe(true);
});
+it('should handle loading more', async () => {
+ const hotspots = [mockRawHotspot({ key: '1' }), mockRawHotspot({ key: '2' })];
+ const hotspots2 = [mockRawHotspot({ key: '3' }), mockRawHotspot({ key: '4' })];
+ (getSecurityHotspots as jest.Mock)
+ .mockResolvedValueOnce({
+ hotspots,
+ paging: { total: 5 }
+ })
+ .mockResolvedValueOnce({
+ hotspots: hotspots2,
+ paging: { total: 5 }
+ });
+
+ const wrapper = shallowRender();
+
+ await waitAndUpdate(wrapper);
+
+ wrapper.instance().handleLoadMore();
+
+ expect(wrapper.state().loadingMore).toBe(true);
+ expect(getSecurityHotspots).toBeCalledTimes(2);
+
+ await waitAndUpdate(wrapper);
+
+ expect(wrapper.state().loadingMore).toBe(false);
+ expect(wrapper.state().hotspotsPageIndex).toBe(2);
+ expect(wrapper.state().hotspotsTotal).toBe(5);
+ expect(wrapper.state().hotspots).toHaveLength(4);
+});
+
it('should handle hotspot update', async () => {
const key = 'hotspotKey';
const hotspots = [mockRawHotspot(), mockRawHotspot({ key })];
- (getSecurityHotspots as jest.Mock).mockResolvedValue({
- hotspots
+ (getSecurityHotspots as jest.Mock).mockResolvedValueOnce({
+ hotspots,
+ paging: { total: 2 }
});
const wrapper = shallowRender();
status: HotspotStatus.REVIEWED,
resolution: HotspotResolution.SAFE
});
+
+ const previousState = wrapper.state();
+ wrapper.instance().handleHotspotUpdate({
+ key: 'unknown',
+ status: HotspotStatus.REVIEWED,
+ resolution: HotspotResolution.SAFE
+ });
+ await waitAndUpdate(wrapper);
+ expect(wrapper.state()).toEqual(previousState);
});
it('should handle status filter change', async () => {
const hotspots = [mockRawHotspot({ key: 'key1' })];
const hotspots2 = [mockRawHotspot({ key: 'key2' })];
(getSecurityHotspots as jest.Mock)
- .mockResolvedValueOnce({ hotspots })
- .mockResolvedValueOnce({ hotspots: hotspots2 })
- .mockResolvedValueOnce({ hotspots: [] });
+ .mockResolvedValueOnce({ hotspots, paging: { total: 1 } })
+ .mockResolvedValueOnce({ hotspots: hotspots2, paging: { total: 1 } })
+ .mockResolvedValueOnce({ hotspots: [], paging: { total: 0 } });
const wrapper = shallowRender();
it('should render correctly with hotspots', () => {
const hotspots = [mockRawHotspot({ key: 'h1' }), mockRawHotspot({ key: 'h2' })];
expect(
- shallowRender({ hotspots })
+ shallowRender({ hotspots, hotspotsTotal: 2 })
.find(ScreenPositionHelper)
.dive()
).toMatchSnapshot();
expect(
- shallowRender({ hotspots, selectedHotspotKey: 'h2' })
+ shallowRender({ hotspots, hotspotsTotal: 3, selectedHotspotKey: 'h2' })
.find(ScreenPositionHelper)
.dive()
).toMatchSnapshot();
function shallowRender(props: Partial<SecurityHotspotsAppRendererProps> = {}) {
return shallow(
<SecurityHotspotsAppRenderer
+ filters={{
+ assignedToMe: false,
+ newCode: false,
+ status: HotspotStatusFilter.TO_REVIEW
+ }}
hotspots={[]}
isStaticListOfHotspots={true}
loading={false}
+ loadingMore={false}
onChangeFilters={jest.fn()}
onHotspotClick={jest.fn()}
+ onLoadMore={jest.fn()}
onShowAllHotspots={jest.fn()}
onUpdateHotspot={jest.fn()}
securityCategories={{}}
- filters={{
- assignedToMe: false,
- newCode: false,
- status: HotspotStatusFilter.TO_REVIEW
- }}
{...props}
/>
);
hotspots={Array []}
isStaticListOfHotspots={false}
loading={true}
+ loadingMore={false}
onChangeFilters={[Function]}
onHotspotClick={[Function]}
+ onLoadMore={[Function]}
onShowAllHotspots={[Function]}
onUpdateHotspot={[Function]}
securityCategories={Object {}}
},
]
}
+ hotspotsTotal={2}
isStaticListOfHotspots={true}
+ loadingMore={false}
onHotspotClick={[MockFunction]}
+ onLoadMore={[MockFunction]}
securityCategories={Object {}}
statusFilter="TO_REVIEW"
/>
},
]
}
+ hotspotsTotal={3}
isStaticListOfHotspots={true}
+ loadingMore={false}
onHotspotClick={[MockFunction]}
+ onLoadMore={[MockFunction]}
securityCategories={Object {}}
selectedHotspotKey="h2"
statusFilter="TO_REVIEW"
import * as classNames from 'classnames';
import { groupBy } from 'lodash';
import * as React from 'react';
+import ListFooter from 'sonar-ui-common/components/controls/ListFooter';
import SecurityHotspotIcon from 'sonar-ui-common/components/icons/SecurityHotspotIcon';
import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
import { HotspotStatusFilter, RawHotspot, RiskExposure } from '../../../types/security-hotspots';
export interface HotspotListProps {
hotspots: RawHotspot[];
+ hotspotsTotal?: number;
isStaticListOfHotspots: boolean;
+ loadingMore: boolean;
onHotspotClick: (key: string) => void;
+ onLoadMore: () => void;
securityCategories: T.StandardSecurityCategories;
selectedHotspotKey: string | undefined;
statusFilter: HotspotStatusFilter;
export default function HotspotList(props: HotspotListProps) {
const {
hotspots,
+ hotspotsTotal,
isStaticListOfHotspots,
+ loadingMore,
securityCategories,
selectedHotspotKey,
statusFilter
}, [hotspots, securityCategories]);
return (
- <>
+ <div className="huge-spacer-bottom">
<h1 className="hotspot-list-header bordered-bottom">
<SecurityHotspotIcon className="spacer-right" />
{translateWithParameters(
hotspots.length
)}
</h1>
- <ul className="huge-spacer-bottom">
+ <ul className="big-spacer-bottom">
{groupedHotspots.map(riskGroup => (
<li className="big-spacer-bottom" key={riskGroup.risk}>
<div className="hotspot-risk-header little-spacer-left">
</li>
))}
</ul>
- </>
+ <ListFooter
+ count={hotspots.length}
+ loadMore={!loadingMore ? props.onLoadMore : undefined}
+ loading={loadingMore}
+ total={hotspotsTotal}
+ />
+ </div>
);
}
it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot();
+ expect(shallowRender({ loadingMore: true })).toMatchSnapshot();
});
it('should render correctly when the list of hotspot is static', () => {
vulnerabilityProbability: RiskExposure.MEDIUM
})
];
- expect(shallowRender({ hotspots })).toMatchSnapshot();
+ expect(shallowRender({ hotspots })).toMatchSnapshot('no pagination');
+ expect(shallowRender({ hotspots, hotspotsTotal: 7 })).toMatchSnapshot('pagination');
});
function shallowRender(props: Partial<HotspotListProps> = {}) {
<HotspotList
hotspots={[]}
isStaticListOfHotspots={false}
+ loadingMore={false}
onHotspotClick={jest.fn()}
+ onLoadMore={jest.fn()}
securityCategories={{}}
selectedHotspotKey="h2"
statusFilter={HotspotStatusFilter.TO_REVIEW}
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render correctly 1`] = `
-<Fragment>
+<div
+ className="huge-spacer-bottom"
+>
<h1
className="hotspot-list-header bordered-bottom"
>
hotspots.list_title.TO_REVIEW.0
</h1>
<ul
- className="huge-spacer-bottom"
+ className="big-spacer-bottom"
/>
-</Fragment>
+ <ListFooter
+ count={0}
+ loadMore={[MockFunction]}
+ loading={false}
+ />
+</div>
+`;
+
+exports[`should render correctly 2`] = `
+<div
+ className="huge-spacer-bottom"
+>
+ <h1
+ className="hotspot-list-header bordered-bottom"
+ >
+ <SecurityHotspotIcon
+ className="spacer-right"
+ />
+ hotspots.list_title.TO_REVIEW.0
+ </h1>
+ <ul
+ className="big-spacer-bottom"
+ />
+ <ListFooter
+ count={0}
+ loading={true}
+ />
+</div>
`;
exports[`should render correctly when the list of hotspot is static 1`] = `
-<Fragment>
+<div
+ className="huge-spacer-bottom"
+>
<h1
className="hotspot-list-header bordered-bottom"
>
hotspots.list_title.0
</h1>
<ul
- className="huge-spacer-bottom"
+ className="big-spacer-bottom"
/>
-</Fragment>
+ <ListFooter
+ count={0}
+ loadMore={[MockFunction]}
+ loading={false}
+ />
+</div>
`;
-exports[`should render correctly with hotspots 1`] = `
-<Fragment>
+exports[`should render correctly with hotspots: no pagination 1`] = `
+<div
+ className="huge-spacer-bottom"
+>
<h1
className="hotspot-list-header bordered-bottom"
>
hotspots.list_title.TO_REVIEW.5
</h1>
<ul
- className="huge-spacer-bottom"
+ className="big-spacer-bottom"
>
<li
className="big-spacer-bottom"
</ul>
</li>
</ul>
-</Fragment>
+ <ListFooter
+ count={5}
+ loadMore={[MockFunction]}
+ loading={false}
+ />
+</div>
+`;
+
+exports[`should render correctly with hotspots: pagination 1`] = `
+<div
+ className="huge-spacer-bottom"
+>
+ <h1
+ className="hotspot-list-header bordered-bottom"
+ >
+ <SecurityHotspotIcon
+ className="spacer-right"
+ />
+ hotspots.list_title.TO_REVIEW.5
+ </h1>
+ <ul
+ className="big-spacer-bottom"
+ >
+ <li
+ className="big-spacer-bottom"
+ key="HIGH"
+ >
+ <div
+ className="hotspot-risk-header little-spacer-left"
+ >
+ <span>
+ hotspots.risk_exposure
+ </span>
+ <div
+ className="hotspot-risk-badge spacer-left HIGH"
+ >
+ risk_exposure.HIGH
+ </div>
+ </div>
+ <ul>
+ <li
+ className="spacer-bottom"
+ key="cat1"
+ >
+ <HotspotCategory
+ category={
+ Object {
+ "key": "cat1",
+ "title": "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": "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": "cat1",
+ "status": "TO_REVIEW",
+ "updateDate": "2013-05-13T17:55:39+0200",
+ "vulnerabilityProbability": "HIGH",
+ },
+ ]
+ }
+ onHotspotClick={[MockFunction]}
+ selectedHotspotKey="h2"
+ />
+ </li>
+ <li
+ className="spacer-bottom"
+ key="cat2"
+ >
+ <HotspotCategory
+ category={
+ Object {
+ "key": "cat2",
+ "title": "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": "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": "cat2",
+ "status": "TO_REVIEW",
+ "updateDate": "2013-05-13T17:55:39+0200",
+ "vulnerabilityProbability": "HIGH",
+ },
+ ]
+ }
+ onHotspotClick={[MockFunction]}
+ selectedHotspotKey="h2"
+ />
+ </li>
+ </ul>
+ </li>
+ <li
+ className="big-spacer-bottom"
+ key="MEDIUM"
+ >
+ <div
+ className="hotspot-risk-header little-spacer-left"
+ >
+ <span>
+ hotspots.risk_exposure
+ </span>
+ <div
+ className="hotspot-risk-badge spacer-left MEDIUM"
+ >
+ risk_exposure.MEDIUM
+ </div>
+ </div>
+ <ul>
+ <li
+ className="spacer-bottom"
+ key="cat1"
+ >
+ <HotspotCategory
+ category={
+ Object {
+ "key": "cat1",
+ "title": "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": "h3",
+ "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",
+ "status": "TO_REVIEW",
+ "updateDate": "2013-05-13T17:55:39+0200",
+ "vulnerabilityProbability": "MEDIUM",
+ },
+ Object {
+ "author": "Developer 1",
+ "component": "com.github.kevinsawicki:http-request:com.github.kevinsawicki.http.HttpRequest",
+ "creationDate": "2013-05-13T17:55:39+0200",
+ "key": "h4",
+ "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",
+ "status": "TO_REVIEW",
+ "updateDate": "2013-05-13T17:55:39+0200",
+ "vulnerabilityProbability": "MEDIUM",
+ },
+ ]
+ }
+ onHotspotClick={[MockFunction]}
+ selectedHotspotKey="h2"
+ />
+ </li>
+ <li
+ className="spacer-bottom"
+ key="cat2"
+ >
+ <HotspotCategory
+ category={
+ Object {
+ "key": "cat2",
+ "title": "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": "h5",
+ "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",
+ "status": "TO_REVIEW",
+ "updateDate": "2013-05-13T17:55:39+0200",
+ "vulnerabilityProbability": "MEDIUM",
+ },
+ ]
+ }
+ onHotspotClick={[MockFunction]}
+ selectedHotspotKey="h2"
+ />
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <ListFooter
+ count={5}
+ loadMore={[MockFunction]}
+ loading={false}
+ total={7}
+ />
+</div>
`;