From 075baf29c5250bcb52634cd4f4ab678c68581808 Mon Sep 17 00:00:00 2001
From: Jeremy Davis
Date: Wed, 11 Dec 2019 18:36:30 +0100
Subject: [PATCH] SONAR-12718 Hotspot Code Snippet
---
.../SnippetViewer.css | 80 +++++
.../SnippetViewer.tsx | 3 +-
.../src/main/js/apps/issues/styles.css | 59 ----
.../securityHotspots/SecurityHotspotsApp.tsx | 2 +
.../SecurityHotspotsAppRenderer.tsx | 5 +-
.../SecurityHotspotsApp-test.tsx.snap | 8 +
.../components/HotspotSnippetContainer.tsx | 188 ++++++++++
.../HotspotSnippetContainerRenderer.tsx | 102 ++++++
.../components/HotspotViewer.tsx | 5 +-
.../components/HotspotViewerRenderer.tsx | 6 +-
.../HotspotSnippetContainer-test.tsx | 186 ++++++++++
.../HotspotSnippetContainerRenderer-test.tsx | 52 +++
.../HotspotSnippetContainer-test.tsx.snap | 116 +++++++
...spotSnippetContainerRenderer-test.tsx.snap | 247 +++++++++++++
.../HotspotViewerRenderer-test.tsx.snap | 328 ++++++++++++++++++
.../main/js/apps/securityHotspots/utils.ts | 17 +-
.../SourceViewer/helpers/indexing.ts | 2 +-
.../sonar-web/src/main/js/helpers/issues.ts | 2 +-
18 files changed, 1342 insertions(+), 66 deletions(-)
create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.css
create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainer.tsx
create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx
create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx
create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx
create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap
create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.css b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.css
new file mode 100644
index 00000000000..89fd8a2e662
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.css
@@ -0,0 +1,80 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+.snippet {
+ margin: var(--gridSize);
+ border: 1px solid var(--gray80);
+ overflow-x: auto;
+ overflow-y: hidden;
+ transition: max-height 0.2s;
+}
+
+.snippet > div {
+ display: table;
+ width: 100%;
+ position: relative;
+ transition: margin-top 0.2s;
+}
+
+.snippet table {
+ width: 100%;
+}
+
+.expand-block {
+ position: absolute;
+ z-index: 2;
+ width: 100%;
+}
+
+.expand-block > button {
+ background: transparent;
+ box-sizing: border-box;
+ color: var(--secondFontColor);
+ height: 20px;
+ width: 100%;
+ padding: calc(var(--gridSize) / 4);
+ border: 0;
+ text-align: left;
+ cursor: pointer;
+}
+
+.expand-block > button:hover,
+.expand-block > button:focus,
+.expand-block > button:active {
+ color: var(--darkBlue);
+ outline: none;
+}
+
+.expand-block-above {
+ background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAADdJREFUCB1dzMEKADAIAlBd1v9/bcc2YgRjHh8qq2qTxCQzsX4wM6y30RARF3sy0Es1SIK7Y64OpCES1W69JS4AAAAASUVORK5CYII=');
+ top: 0;
+}
+
+.expand-block-below {
+ background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wQQBjQEQVd5jwAAADhJREFUCNddyTEKADEMA8GVA/7/Z+PGwUp1cGTaYe/tv5lxrLWoKj6SiMzkjZDEG7JtANt0N+ccLrB/KZxXTt7fAAAAAElFTkSuQmCC');
+ bottom: 0;
+}
+
+.source-table.expand-up {
+ margin-top: 20px;
+}
+
+.source-table.expand-down {
+ margin-bottom: 20px;
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
index cc39d046142..ad13be5f4b8 100644
--- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
@@ -31,6 +31,7 @@ import {
optimizeSelectedIssue
} from '../../../components/SourceViewer/helpers/lines';
import { BranchLike } from '../../../types/branch-like';
+import './SnippetViewer.css';
import { inSnippet, LINES_BELOW_ISSUE } from './utils';
interface Props {
@@ -46,7 +47,7 @@ interface Props {
highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
highlightedSymbols: string[];
index: number;
- issue: T.Issue;
+ issue: Pick;
issuePopup?: { issue: string; name: string };
issuesByLine: T.IssuesByLine;
last: boolean;
diff --git a/server/sonar-web/src/main/js/apps/issues/styles.css b/server/sonar-web/src/main/js/apps/issues/styles.css
index 7a28b199b22..88402d912af 100644
--- a/server/sonar-web/src/main/js/apps/issues/styles.css
+++ b/server/sonar-web/src/main/js/apps/issues/styles.css
@@ -238,65 +238,6 @@
padding: var(--gridSize);
}
-.snippet {
- margin: var(--gridSize);
- border: 1px solid var(--gray80);
- overflow-x: auto;
- overflow-y: hidden;
- transition: max-height 0.2s;
-}
-
-.snippet > div {
- display: table;
- width: 100%;
- position: relative;
- transition: margin-top 0.2s;
-}
-
-.snippet table {
- width: 100%;
-}
-
-.expand-block {
- position: absolute;
- z-index: 2;
- width: 100%;
-}
-
-.expand-block > button {
- background: transparent;
- box-sizing: border-box;
- color: var(--secondFontColor);
- height: 20px;
- width: 100%;
- padding: calc(var(--gridSize) / 4);
- border: 0;
- text-align: left;
- cursor: pointer;
-}
-.expand-block > button:hover,
-.expand-block > button:focus,
-.expand-block > button:active {
- color: var(--darkBlue);
- outline: none;
-}
-.expand-block-above {
- background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAADdJREFUCB1dzMEKADAIAlBd1v9/bcc2YgRjHh8qq2qTxCQzsX4wM6y30RARF3sy0Es1SIK7Y64OpCES1W69JS4AAAAASUVORK5CYII=');
- top: 0;
-}
-.expand-block-below {
- background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wQQBjQEQVd5jwAAADhJREFUCNddyTEKADEMA8GVA/7/Z+PGwUp1cGTaYe/tv5lxrLWoKj6SiMzkjZDEG7JtANt0N+ccLrB/KZxXTt7fAAAAAElFTkSuQmCC');
- bottom: 0;
-}
-
-.source-table.expand-up {
- margin-top: 20px;
-}
-
-.source-table.expand-down {
- margin-bottom: 20px;
-}
-
.issues-my-issues-filter {
margin-bottom: 24px;
text-align: center;
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx
index f960ac80185..e1d9e8a17fd 100644
--- a/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx
@@ -96,10 +96,12 @@ export default class SecurityHotspotsApp extends React.PureComponent this.setState({ selectedHotspotKey: key });
render() {
+ const { branchLike } = this.props;
const { hotspots, loading, securityCategories, selectedHotspotKey } = this.state;
return (
void;
@@ -41,7 +43,7 @@ export interface SecurityHotspotsAppRendererProps {
}
export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRendererProps) {
- const { hotspots, loading, securityCategories, selectedHotspotKey } = props;
+ const { branchLike, hotspots, loading, securityCategories, selectedHotspotKey } = props;
return (
@@ -87,6 +89,7 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
{selectedHotspotKey && (
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsApp-test.tsx.snap
index 4f83ea76cd3..6ad55141e38 100644
--- a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsApp-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsApp-test.tsx.snap
@@ -2,6 +2,14 @@
exports[`should render correctly 1`] = `
{
+ mounted = false;
+ state: State = {
+ highlightedSymbols: [],
+ loading: true,
+ sourceLines: []
+ };
+
+ componentWillMount() {
+ this.mounted = true;
+ this.fetchSources();
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.hotspot.key !== this.props.hotspot.key) {
+ this.fetchSources();
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ checkLastLine(lines: T.SourceLine[], target: number): number | undefined {
+ if (lines.length < 1) {
+ return undefined;
+ }
+ const lastLineReceived = lines[lines.length - 1].line;
+ if (lastLineReceived < target) {
+ return lastLineReceived;
+ }
+ return undefined;
+ }
+
+ fetchSources() {
+ const { component, textRange } = this.props.hotspot;
+
+ const from = Math.max(1, textRange.startLine - BUFFER_LINES);
+ // Add 1 to check for end-of-file:
+ const to = textRange.endLine + BUFFER_LINES + 1;
+
+ this.setState({ loading: true });
+ return getSources({ key: component.key, from, to })
+ .then(sourceLines => {
+ if (this.mounted) {
+ const lastLine = this.checkLastLine(sourceLines, to);
+
+ // remove extra sourceline if we didn't reach the end:
+ sourceLines = lastLine ? sourceLines : sourceLines.slice(0, -1);
+ this.setState({ lastLine, loading: false, sourceLines });
+ }
+ })
+ .catch(() => this.mounted && this.setState({ loading: false }));
+ }
+
+ handleExpansion = (direction: T.ExpandDirection) => {
+ const { branchLike, hotspot } = this.props;
+ const { sourceLines } = this.state;
+
+ const range =
+ direction === 'up'
+ ? {
+ from: Math.max(1, sourceLines[0].line - EXPAND_BY_LINES),
+ to: sourceLines[0].line - 1
+ }
+ : {
+ from: sourceLines[sourceLines.length - 1].line + 1,
+ // Add 1 to check for end-of-file:
+ to: sourceLines[sourceLines.length - 1].line + EXPAND_BY_LINES + 1
+ };
+
+ return getSources({
+ key: hotspot.component.key,
+ ...range,
+ ...getBranchLikeQuery(branchLike)
+ }).then(additionalLines => {
+ const lastLine =
+ direction === 'down' ? this.checkLastLine(additionalLines, range.to) : undefined;
+
+ let concatSourceLines;
+ if (direction === 'up') {
+ concatSourceLines = additionalLines.concat(sourceLines);
+ } else {
+ // remove extra sourceline if we didn't reach the end:
+ concatSourceLines = sourceLines.concat(
+ lastLine ? additionalLines : additionalLines.slice(0, -1)
+ );
+ }
+
+ this.setState({
+ lastLine,
+ sourceLines: concatSourceLines
+ });
+ });
+ };
+
+ handleLinePopupToggle = (params: T.LinePopup & { component: string }) => {
+ const { component, index, line, name, open } = params;
+ this.setState((state: State) => {
+ const samePopup =
+ state.linePopup !== undefined &&
+ state.linePopup.line === line &&
+ state.linePopup.name === name &&
+ state.linePopup.component === component &&
+ state.linePopup.index === index;
+ if (open !== false && !samePopup) {
+ return { linePopup: params };
+ } else if (open !== true && samePopup) {
+ return { linePopup: undefined };
+ }
+ return null;
+ });
+ };
+
+ handleSymbolClick = (highlightedSymbols: string[]) => {
+ this.setState({ highlightedSymbols });
+ };
+
+ render() {
+ const { branchLike, hotspot } = this.props;
+ const { highlightedSymbols, lastLine, linePopup, loading, sourceLines } = this.state;
+
+ const locations = locationsByLine([hotspot]);
+
+ const sourceViewerFile = constructSourceViewerFile(hotspot, lastLine);
+
+ return (
+
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx
new file mode 100644
index 00000000000..d8dd52cabea
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx
@@ -0,0 +1,102 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
+import { SourceViewerContext } from '../../../components/SourceViewer/SourceViewerContext';
+import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
+import { BranchLike } from '../../../types/branch-like';
+import { DetailedHotspot } from '../../../types/security-hotspots';
+import SnippetViewer from '../../issues/crossComponentSourceViewer/SnippetViewer';
+
+export interface HotspotSnippetContainerRendererProps {
+ branchLike?: BranchLike;
+ highlightedSymbols: string[];
+ hotspot: DetailedHotspot;
+ lastLine?: number;
+ loading: boolean;
+ locations: { [line: number]: T.LinearIssueLocation[] };
+ linePopup?: T.LinePopup & { component: string };
+ onExpandBlock: (direction: T.ExpandDirection) => Promise;
+ onLinePopupToggle: (line: T.SourceLine) => void;
+ onSymbolClick: (symbols: string[]) => void;
+ sourceLines: T.SourceLine[];
+ sourceViewerFile: T.SourceViewerFile;
+}
+
+const noop = () => undefined;
+
+export default function HotspotSnippetContainerRenderer(
+ props: HotspotSnippetContainerRendererProps
+) {
+ const {
+ branchLike,
+ highlightedSymbols,
+ hotspot,
+ linePopup,
+ loading,
+ locations,
+ sourceLines,
+ sourceViewerFile
+ } = props;
+
+ return (
+
+
+
+ {sourceLines.length > 0 && (
+
+ props.onExpandBlock(direction)}
+ handleCloseIssues={noop}
+ handleLinePopupToggle={props.onLinePopupToggle}
+ handleOpenIssues={noop}
+ handleSymbolClick={props.onSymbolClick}
+ highlightedLocationMessage={undefined}
+ highlightedSymbols={highlightedSymbols}
+ index={0}
+ issue={hotspot}
+ issuesByLine={{}}
+ last={false}
+ linePopup={linePopup}
+ loadDuplications={noop}
+ locations={[]}
+ locationsByLine={locations}
+ onIssueChange={noop}
+ onIssuePopupToggle={noop}
+ onLocationSelect={noop}
+ openIssuesByLine={{}}
+ renderDuplicationPopup={noop}
+ snippet={sourceLines}
+ />
+
+ )}
+
+
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx
index 35192c96b62..9ca28887e07 100644
--- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx
@@ -20,10 +20,12 @@
import * as React from 'react';
import { getSecurityHotspotDetails } from '../../../api/security-hotspots';
+import { BranchLike } from '../../../types/branch-like';
import { DetailedHotspot } from '../../../types/security-hotspots';
import HotspotViewerRenderer from './HotspotViewerRenderer';
interface Props {
+ branchLike?: BranchLike;
hotspotKey: string;
securityCategories: T.StandardSecurityCategories;
}
@@ -59,11 +61,12 @@ export default class HotspotViewer extends React.PureComponent {
}
render() {
- const { securityCategories } = this.props;
+ const { branchLike, securityCategories } = this.props;
const { hotspot, loading } = this.state;
return (
@@ -68,6 +71,7 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
>
)}
+
)}
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx
new file mode 100644
index 00000000000..4000d8ed8dd
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx
@@ -0,0 +1,186 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { shallow } from 'enzyme';
+import { range } from 'lodash';
+import * as React from 'react';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { getSources } from '../../../../api/components';
+import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots';
+import { mockSourceLine } from '../../../../helpers/testMocks';
+import HotspotSnippetContainer from '../HotspotSnippetContainer';
+import HotspotSnippetContainerRenderer from '../HotspotSnippetContainerRenderer';
+
+jest.mock('../../../../api/components', () => ({
+ getSources: jest.fn().mockResolvedValue([])
+}));
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should load sources on mount', async () => {
+ (getSources as jest.Mock).mockResolvedValueOnce(
+ range(5, 18).map(line => mockSourceLine({ line }))
+ );
+
+ const hotspot = mockDetailledHotspot({
+ textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 }
+ });
+
+ const wrapper = shallowRender({ hotspot });
+
+ await waitAndUpdate(wrapper);
+
+ expect(getSources).toBeCalled();
+ expect(wrapper.state().lastLine).toBeUndefined();
+ expect(wrapper.state().sourceLines).toHaveLength(12);
+});
+
+it('should handle end-of-file on mount', async () => {
+ (getSources as jest.Mock).mockResolvedValueOnce(
+ range(5, 15).map(line => mockSourceLine({ line }))
+ );
+
+ const hotspot = mockDetailledHotspot({
+ textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 }
+ });
+
+ const wrapper = shallowRender({ hotspot });
+
+ await waitAndUpdate(wrapper);
+
+ expect(getSources).toBeCalled();
+ expect(wrapper.state().lastLine).toBe(14);
+ expect(wrapper.state().sourceLines).toHaveLength(10);
+});
+
+describe('Expansion', () => {
+ beforeEach(() => {
+ (getSources as jest.Mock).mockResolvedValueOnce(
+ range(5, 18).map(line => mockSourceLine({ line }))
+ );
+ });
+
+ const hotspot = mockDetailledHotspot({
+ textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 }
+ });
+
+ it('up should work', async () => {
+ (getSources as jest.Mock).mockResolvedValueOnce(
+ range(1, 5).map(line => mockSourceLine({ line }))
+ );
+
+ const wrapper = shallowRender({ hotspot });
+ await waitAndUpdate(wrapper);
+
+ wrapper
+ .find(HotspotSnippetContainerRenderer)
+ .props()
+ .onExpandBlock('up');
+
+ await waitAndUpdate(wrapper);
+
+ expect(wrapper.state().sourceLines).toHaveLength(16);
+ });
+
+ it('down should work', async () => {
+ (getSources as jest.Mock).mockResolvedValueOnce(
+ // lastLine + expand + extra for EOF check + range end is excluded
+ // 16 + 50 + 1 + 1
+ range(17, 68).map(line => mockSourceLine({ line }))
+ );
+
+ const wrapper = shallowRender({ hotspot });
+ await waitAndUpdate(wrapper);
+
+ wrapper
+ .find(HotspotSnippetContainerRenderer)
+ .props()
+ .onExpandBlock('down');
+
+ await waitAndUpdate(wrapper);
+
+ expect(wrapper.state().lastLine).toBeUndefined();
+ expect(wrapper.state().sourceLines).toHaveLength(62);
+ });
+
+ it('down should work and handle EOF', async () => {
+ (getSources as jest.Mock).mockResolvedValueOnce(
+ // lastLine + expand + extra for EOF check + range end is excluded - 1 to trigger end-of-file
+ // 16 + 50 + 1 + 1 - 1
+ range(17, 67).map(line => mockSourceLine({ line }))
+ );
+
+ const wrapper = shallowRender({ hotspot });
+ await waitAndUpdate(wrapper);
+
+ wrapper
+ .find(HotspotSnippetContainerRenderer)
+ .props()
+ .onExpandBlock('down');
+
+ await waitAndUpdate(wrapper);
+
+ expect(wrapper.state().lastLine).toBe(66);
+ expect(wrapper.state().sourceLines).toHaveLength(62);
+ });
+});
+
+it('should handle line popups', async () => {
+ (getSources as jest.Mock).mockResolvedValueOnce(
+ range(5, 18).map(line => mockSourceLine({ line }))
+ );
+
+ const wrapper = shallowRender();
+ await waitAndUpdate(wrapper);
+
+ const params = wrapper.state().sourceLines[0];
+
+ wrapper
+ .find(HotspotSnippetContainerRenderer)
+ .props()
+ .onLinePopupToggle(params);
+
+ expect(wrapper.state().linePopup).toEqual(params);
+
+ // Close it
+ wrapper
+ .find(HotspotSnippetContainerRenderer)
+ .props()
+ .onLinePopupToggle(params);
+
+ expect(wrapper.state().linePopup).toBeUndefined();
+});
+
+it('should handle symbol click', () => {
+ const wrapper = shallowRender();
+ const symbols = ['symbol'];
+ wrapper
+ .find(HotspotSnippetContainerRenderer)
+ .props()
+ .onSymbolClick(symbols);
+ expect(wrapper.state().highlightedSymbols).toBe(symbols);
+});
+
+function shallowRender(props?: Partial) {
+ return shallow(
+
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx
new file mode 100644
index 00000000000..9fd30ab5216
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx
@@ -0,0 +1,52 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockMainBranch } from '../../../../helpers/mocks/branch-like';
+import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots';
+import { mockSourceLine, mockSourceViewerFile } from '../../../../helpers/testMocks';
+import HotspotSnippetContainerRenderer, {
+ HotspotSnippetContainerRendererProps
+} from '../HotspotSnippetContainerRenderer';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+ expect(shallowRender({ sourceLines: [mockSourceLine()] })).toMatchSnapshot('with sourcelines');
+});
+
+function shallowRender(props?: Partial) {
+ return shallow(
+
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap
new file mode 100644
index 00000000000..1d94193a839
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap
@@ -0,0 +1,116 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+This a strong message about fixing !
",
+ "key": "squid:S2077",
+ "name": "That rule",
+ "riskDescription": "This a strong message about risk !
",
+ "securityCategory": "sql-injection",
+ "vulnerabilityDescription": "This a strong message about vulnerability !
",
+ "vulnerabilityProbability": "HIGH",
+ },
+ "status": "RESOLVED",
+ "textRange": Object {
+ "endLine": 142,
+ "endOffset": 83,
+ "startLine": 142,
+ "startOffset": 26,
+ },
+ "updateDate": "2013-05-13T17:55:42+0200",
+ }
+ }
+ loading={true}
+ locations={
+ Object {
+ "142": Array [
+ Object {
+ "from": 26,
+ "line": 142,
+ "to": 83,
+ },
+ ],
+ }
+ }
+ onExpandBlock={[Function]}
+ onLinePopupToggle={[Function]}
+ onSymbolClick={[Function]}
+ sourceLines={Array []}
+ sourceViewerFile={
+ Object {
+ "key": "my-project",
+ "measures": Object {
+ "lines": undefined,
+ },
+ "path": "",
+ "project": "my-project",
+ "projectName": "MyProject",
+ "q": "FIL",
+ "uuid": "",
+ }
+ }
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap
new file mode 100644
index 00000000000..883f7e10dad
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap
@@ -0,0 +1,247 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+
+
+
+
+`;
+
+exports[`should render correctly: with sourcelines 1`] = `
+
+
+
+
+ This a strong message about fixing !",
+ "key": "squid:S2077",
+ "name": "That rule",
+ "riskDescription": "This a strong message about risk !
",
+ "securityCategory": "sql-injection",
+ "vulnerabilityDescription": "This a strong message about vulnerability !
",
+ "vulnerabilityProbability": "HIGH",
+ },
+ "status": "RESOLVED",
+ "textRange": Object {
+ "endLine": 142,
+ "endOffset": 83,
+ "startLine": 142,
+ "startOffset": 26,
+ },
+ "updateDate": "2013-05-13T17:55:42+0200",
+ }
+ }
+ issuesByLine={Object {}}
+ last={false}
+ loadDuplications={[Function]}
+ locations={Array []}
+ locationsByLine={Object {}}
+ onIssueChange={[Function]}
+ onIssuePopupToggle={[Function]}
+ onLocationSelect={[Function]}
+ openIssuesByLine={Object {}}
+ renderDuplicationPopup={[Function]}
+ snippet={
+ Array [
+ Object {
+ "code": "import java.util.ArrayList;",
+ "coverageStatus": "covered",
+ "coveredConditions": 2,
+ "duplicated": false,
+ "isNew": true,
+ "line": 16,
+ "scmAuthor": "simon.brandhof@sonarsource.com",
+ "scmDate": "2018-12-11T10:48:39+0100",
+ "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
+ },
+ ]
+ }
+ />
+
+
+
+`;
diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap
index 4f8cbe67c88..315b57ab627 100644
--- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap
@@ -53,6 +53,88 @@ exports[`should render correctly 1`] = `
John Doe
+ This a strong message about fixing !",
+ "key": "squid:S2077",
+ "name": "That rule",
+ "riskDescription": "This a strong message about risk !
",
+ "securityCategory": "sql-injection",
+ "vulnerabilityDescription": "This a strong message about vulnerability !
",
+ "vulnerabilityProbability": "HIGH",
+ },
+ "status": "RESOLVED",
+ "textRange": Object {
+ "endLine": 142,
+ "endOffset": 83,
+ "startLine": 142,
+ "startOffset": 26,
+ },
+ "updateDate": "2013-05-13T17:55:42+0200",
+ }
+ }
+ />
+ This a strong message about fixing !",
+ "key": "squid:S2077",
+ "name": "That rule",
+ "riskDescription": "This a strong message about risk !
",
+ "securityCategory": "sql-injection",
+ "vulnerabilityDescription": "This a strong message about vulnerability !
",
+ "vulnerabilityProbability": "HIGH",
+ },
+ "status": "RESOLVED",
+ "textRange": Object {
+ "endLine": 142,
+ "endOffset": 83,
+ "startLine": 142,
+ "startOffset": 26,
+ },
+ "updateDate": "2013-05-13T17:55:42+0200",
+ }
+ }
+ />
+ This a strong message about fixing !",
+ "key": "squid:S2077",
+ "name": "That rule",
+ "riskDescription": "This a strong message about risk !
",
+ "securityCategory": "sql-injection",
+ "vulnerabilityDescription": "This a strong message about vulnerability !
",
+ "vulnerabilityProbability": "HIGH",
+ },
+ "status": "RESOLVED",
+ "textRange": Object {
+ "endLine": 142,
+ "endOffset": 83,
+ "startLine": 142,
+ "startOffset": 26,
+ },
+ "updateDate": "2013-05-13T17:55:42+0200",
+ }
+ }
+ />
+ This a strong message about fixing !",
+ "key": "squid:S2077",
+ "name": "That rule",
+ "riskDescription": "This a strong message about risk !
",
+ "securityCategory": "sql-injection",
+ "vulnerabilityDescription": "This a strong message about vulnerability !
",
+ "vulnerabilityProbability": "HIGH",
+ },
+ "status": "RESOLVED",
+ "textRange": Object {
+ "endLine": 142,
+ "endOffset": 83,
+ "startLine": 142,
+ "startOffset": 26,
+ },
+ "updateDate": "2013-05-13T17:55:42+0200",
+ }
+ }
+ />
[]) {
const index: { [line: number]: T.LinearIssueLocation[] } = {};
issues.forEach(issue => {
getLinearLocations(issue.textRange).forEach(location => {
diff --git a/server/sonar-web/src/main/js/helpers/issues.ts b/server/sonar-web/src/main/js/helpers/issues.ts
index 4f2330f7888..dc84f8d64fb 100644
--- a/server/sonar-web/src/main/js/helpers/issues.ts
+++ b/server/sonar-web/src/main/js/helpers/issues.ts
@@ -55,7 +55,7 @@ export interface RawIssue extends IssueBase {
textRange?: T.TextRange;
}
-export function sortByType(issues: T.Issue[]): T.Issue[] {
+export function sortByType>(issues: T[]): T[] {
return sortBy(issues, issue => ISSUE_TYPES.indexOf(issue.type));
}
--
2.39.5