diff options
author | Revanshu Paliwal <revanshu.paliwal@sonarsource.com> | 2022-02-16 10:41:39 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-02-25 20:02:54 +0000 |
commit | e5474111c8f3e985cfa751d8cd86f1950aaf8e4d (patch) | |
tree | 5164d6200ea226175def9007343bd1948507c40a /server | |
parent | 9620694f92f2525837ce822d69f1296bf003ae69 (diff) | |
download | sonarqube-e5474111c8f3e985cfa751d8cd86f1950aaf8e4d.tar.gz sonarqube-e5474111c8f3e985cfa751d8cd86f1950aaf8e4d.zip |
SONAR-16007 Showing secondary locations in hotspot list box
Diffstat (limited to 'server')
28 files changed, 714 insertions, 876 deletions
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx index 0080b942dc6..2411146ab9c 100644 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx +++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.tsx @@ -18,11 +18,13 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import classNames from 'classnames'; +import { uniq } from 'lodash'; import * as React from 'react'; +import LocationsList from '../../../components/locations/LocationsList'; import TypeHelper from '../../../components/shared/TypeHelper'; import { Issue } from '../../../types/types'; +import { getLocations } from '../utils'; import ConciseIssueLocations from './ConciseIssueLocations'; -import ConciseIssueLocationsNavigator from './ConciseIssueLocationsNavigator'; interface Props { issue: Issue; @@ -75,7 +77,7 @@ export default class ConciseIssueBox extends React.PureComponent<Props> { }; render() { - const { issue, selected } = this.props; + const { issue, selected, selectedFlowIndex, selectedLocationIndex } = this.props; const clickAttributesMain = selected ? {} @@ -85,6 +87,11 @@ export default class ConciseIssueBox extends React.PureComponent<Props> { ? { onClick: this.handleClick, role: 'listitem', tabIndex: 0 } : {}; + const locations = getLocations(issue, selectedFlowIndex); + + const locationComponents = [issue.component, ...locations.map(location => location.component)]; + const isCrossFile = uniq(locationComponents).length > 1; + return ( <div className={classNames('concise-issue-box', 'clearfix', { selected })} @@ -101,16 +108,18 @@ export default class ConciseIssueBox extends React.PureComponent<Props> { <ConciseIssueLocations issue={issue} onFlowSelect={this.props.onFlowSelect} - selectedFlowIndex={this.props.selectedFlowIndex} + selectedFlowIndex={selectedFlowIndex} /> </div> {selected && ( - <ConciseIssueLocationsNavigator - issue={issue} + <LocationsList + locations={locations} + uniqueKey={issue.key} + isCrossFile={isCrossFile} onLocationSelect={this.props.onLocationSelect} scroll={this.props.scroll} - selectedFlowIndex={this.props.selectedFlowIndex} - selectedLocationIndex={this.props.selectedLocationIndex} + selectedFlowIndex={selectedFlowIndex} + selectedLocationIndex={selectedLocationIndex} /> )} </div> diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx deleted file mode 100644 index f633d402937..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2022 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 { uniq } from 'lodash'; -import * as React from 'react'; -import { Issue } from '../../../types/types'; -import { getLocations } from '../utils'; -import ConciseIssueLocationsNavigatorLocation from './ConciseIssueLocationsNavigatorLocation'; -import CrossFileLocationsNavigator from './CrossFileLocationsNavigator'; - -interface Props { - issue: Pick<Issue, 'component' | 'key' | 'flows' | 'secondaryLocations' | 'type'>; - onLocationSelect: (index: number) => void; - scroll: (element: Element) => void; - selectedFlowIndex: number | undefined; - selectedLocationIndex: number | undefined; -} - -export default class ConciseIssueLocationsNavigator extends React.PureComponent<Props> { - render() { - const locations = getLocations(this.props.issue, this.props.selectedFlowIndex); - - if (!locations || locations.length === 0 || locations.every(location => !location.msg)) { - return null; - } - - const locationComponents = [ - this.props.issue.component, - ...locations.map(location => location.component) - ]; - const isCrossFile = uniq(locationComponents).length > 1; - - if (isCrossFile) { - return ( - <CrossFileLocationsNavigator - issue={this.props.issue} - locations={locations} - onLocationSelect={this.props.onLocationSelect} - scroll={this.props.scroll} - selectedLocationIndex={this.props.selectedLocationIndex} - /> - ); - } else { - return ( - <div className="concise-issue-locations-navigator spacer-top"> - {locations.map((location, index) => ( - <ConciseIssueLocationsNavigatorLocation - index={index} - key={index} - message={location.msg} - onClick={this.props.onLocationSelect} - scroll={this.props.scroll} - selected={index === this.props.selectedLocationIndex} - /> - ))} - </div> - ); - } - } -} diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx deleted file mode 100644 index 2b5509ec5a4..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2022 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 { mockIssue } from '../../../../helpers/testMocks'; -import { FlowLocation } from '../../../../types/types'; -import ConciseIssueLocationsNavigator from '../ConciseIssueLocationsNavigator'; - -const location1: FlowLocation = { - component: 'foo', - componentName: 'src/foo.js', - msg: 'Do not use foo', - textRange: { startLine: 7, endLine: 7, startOffset: 5, endOffset: 8 } -}; - -const location2: FlowLocation = { - component: 'foo', - componentName: 'src/foo.js', - msg: 'Do not use foo', - textRange: { startLine: 8, endLine: 8, startOffset: 0, endOffset: 5 } -}; - -const location3: FlowLocation = { - component: 'bar', - componentName: 'src/bar.js', - msg: 'Do not use bar', - textRange: { startLine: 15, endLine: 16, startOffset: 4, endOffset: 6 } -}; - -it('should render secondary locations in the same file', () => { - const issue = mockIssue(false, { - component: 'foo', - key: '', - flows: [], - secondaryLocations: [location1, location2] - }); - expect(shallowRender({ issue })).toMatchSnapshot(); -}); - -it('should render flow locations in the same file', () => { - const issue = mockIssue(false, { - component: 'foo', - key: '', - flows: [[location1, location2]], - secondaryLocations: [] - }); - expect(shallowRender({ issue })).toMatchSnapshot(); -}); - -it('should render selected flow locations in the same file', () => { - const issue = mockIssue(false, { - component: 'foo', - key: '', - flows: [[location1, location2]], - secondaryLocations: [location1] - }); - expect(shallowRender({ issue, selectedFlowIndex: 0 })).toMatchSnapshot(); -}); - -it('should render flow locations in different file', () => { - const issue = mockIssue(false, { - component: 'foo', - key: '', - flows: [[location1, location3]], - secondaryLocations: [] - }); - expect(shallowRender({ issue })).toMatchSnapshot(); -}); - -it('should not render locations', () => { - const issue = mockIssue(false, { - component: 'foo', - key: '', - flows: [], - secondaryLocations: [] - }); - const wrapper = shallowRender({ issue }); - expect(wrapper.type()).toBeNull(); -}); - -it('should render taint analysis issues correctly', () => { - const issue = mockIssue(false, { - component: 'foo', - key: '', - flows: [[location1, location2, location3]], - secondaryLocations: [], - type: 'VULNERABILITY' - }); - - expect(shallowRender({ issue })).toMatchSnapshot(); -}); - -function shallowRender(overrides: Partial<ConciseIssueLocationsNavigator['props']> = {}) { - return shallow<ConciseIssueLocationsNavigator>( - <ConciseIssueLocationsNavigator - issue={mockIssue()} - onLocationSelect={jest.fn()} - scroll={jest.fn()} - selectedFlowIndex={undefined} - selectedLocationIndex={undefined} - {...overrides} - /> - ); -} diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueBox-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueBox-test.tsx.snap index 6947b89f047..5badb12ee28 100644 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueBox-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueBox-test.tsx.snap @@ -55,42 +55,14 @@ exports[`should render correctly 1`] = ` selectedFlowIndex={0} /> </div> - <ConciseIssueLocationsNavigator - issue={ - Object { - "actions": Array [], - "component": "main.js", - "componentLongName": "main.js", - "componentQualifier": "FIL", - "componentUuid": "foo1234", - "creationDate": "2017-03-01T09:36:01+0100", - "flows": Array [], - "fromHotspot": false, - "key": "AVsae-CQS-9G3txfbFN2", - "line": 25, - "message": "Reduce the number of conditional operators (4) used in the expression", - "project": "myproject", - "projectKey": "foo", - "projectName": "Foo", - "rule": "javascript:S1067", - "ruleName": "foo", - "secondaryLocations": Array [], - "severity": "MAJOR", - "status": "OPEN", - "textRange": Object { - "endLine": 26, - "endOffset": 15, - "startLine": 25, - "startOffset": 0, - }, - "transitions": Array [], - "type": "BUG", - } - } + <LocationsList + isCrossFile={false} + locations={Array []} onLocationSelect={[MockFunction]} scroll={[MockFunction]} selectedFlowIndex={0} selectedLocationIndex={0} + uniqueKey="AVsae-CQS-9G3txfbFN2" /> </div> `; @@ -219,111 +191,44 @@ exports[`should render correctly 2`] = ` selectedFlowIndex={0} /> </div> - <ConciseIssueLocationsNavigator - issue={ - Object { - "actions": Array [], - "component": "main.js", - "componentLongName": "main.js", - "componentQualifier": "FIL", - "componentUuid": "foo1234", - "creationDate": "2017-03-01T09:36:01+0100", - "flows": Array [ - Array [ - Object { - "component": "main.js", - "textRange": Object { - "endLine": 2, - "endOffset": 2, - "startLine": 1, - "startOffset": 1, - }, - }, - Object { - "component": "main.js", - "textRange": Object { - "endLine": 2, - "endOffset": 2, - "startLine": 1, - "startOffset": 1, - }, - }, - Object { - "component": "main.js", - "textRange": Object { - "endLine": 2, - "endOffset": 2, - "startLine": 1, - "startOffset": 1, - }, - }, - ], - Array [ - Object { - "component": "main.js", - "textRange": Object { - "endLine": 2, - "endOffset": 2, - "startLine": 1, - "startOffset": 1, - }, - }, - Object { - "component": "main.js", - "textRange": Object { - "endLine": 2, - "endOffset": 2, - "startLine": 1, - "startOffset": 1, - }, - }, - ], - ], - "fromHotspot": false, - "key": "AVsae-CQS-9G3txfbFN2", - "line": 25, - "message": "Reduce the number of conditional operators (4) used in the expression", - "project": "myproject", - "projectKey": "foo", - "projectName": "Foo", - "rule": "javascript:S1067", - "ruleName": "foo", - "secondaryLocations": Array [ - Object { - "component": "main.js", - "textRange": Object { - "endLine": 2, - "endOffset": 2, - "startLine": 1, - "startOffset": 1, - }, + <LocationsList + isCrossFile={false} + locations={ + Array [ + Object { + "component": "main.js", + "textRange": Object { + "endLine": 2, + "endOffset": 2, + "startLine": 1, + "startOffset": 1, }, - Object { - "component": "main.js", - "textRange": Object { - "endLine": 2, - "endOffset": 2, - "startLine": 1, - "startOffset": 1, - }, + }, + Object { + "component": "main.js", + "textRange": Object { + "endLine": 2, + "endOffset": 2, + "startLine": 1, + "startOffset": 1, }, - ], - "severity": "MAJOR", - "status": "OPEN", - "textRange": Object { - "endLine": 26, - "endOffset": 15, - "startLine": 25, - "startOffset": 0, }, - "transitions": Array [], - "type": "BUG", - } + Object { + "component": "main.js", + "textRange": Object { + "endLine": 2, + "endOffset": 2, + "startLine": 1, + "startOffset": 1, + }, + }, + ] } onLocationSelect={[MockFunction]} scroll={[MockFunction]} selectedFlowIndex={0} selectedLocationIndex={0} + uniqueKey="AVsae-CQS-9G3txfbFN2" /> </div> `; diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap deleted file mode 100644 index 0434816e9e4..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap +++ /dev/null @@ -1,270 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render flow locations in different file 1`] = ` -<CrossFileLocationsNavigator - issue={ - Object { - "actions": Array [], - "component": "foo", - "componentLongName": "main.js", - "componentQualifier": "FIL", - "componentUuid": "foo1234", - "creationDate": "2017-03-01T09:36:01+0100", - "flows": Array [ - Array [ - Object { - "component": "foo", - "componentName": "src/foo.js", - "msg": "Do not use foo", - "textRange": Object { - "endLine": 7, - "endOffset": 8, - "startLine": 7, - "startOffset": 5, - }, - }, - Object { - "component": "bar", - "componentName": "src/bar.js", - "msg": "Do not use bar", - "textRange": Object { - "endLine": 16, - "endOffset": 6, - "startLine": 15, - "startOffset": 4, - }, - }, - ], - ], - "fromHotspot": false, - "key": "", - "line": 25, - "message": "Reduce the number of conditional operators (4) used in the expression", - "project": "myproject", - "projectKey": "foo", - "projectName": "Foo", - "rule": "javascript:S1067", - "ruleName": "foo", - "secondaryLocations": Array [], - "severity": "MAJOR", - "status": "OPEN", - "textRange": Object { - "endLine": 26, - "endOffset": 15, - "startLine": 25, - "startOffset": 0, - }, - "transitions": Array [], - "type": "BUG", - } - } - locations={ - Array [ - Object { - "component": "foo", - "componentName": "src/foo.js", - "msg": "Do not use foo", - "textRange": Object { - "endLine": 7, - "endOffset": 8, - "startLine": 7, - "startOffset": 5, - }, - }, - Object { - "component": "bar", - "componentName": "src/bar.js", - "msg": "Do not use bar", - "textRange": Object { - "endLine": 16, - "endOffset": 6, - "startLine": 15, - "startOffset": 4, - }, - }, - ] - } - onLocationSelect={[MockFunction]} - scroll={[MockFunction]} -/> -`; - -exports[`should render flow locations in the same file 1`] = ` -<div - className="concise-issue-locations-navigator spacer-top" -> - <ConciseIssueLocationsNavigatorLocation - index={0} - key="0" - message="Do not use foo" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> - <ConciseIssueLocationsNavigatorLocation - index={1} - key="1" - message="Do not use foo" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> -</div> -`; - -exports[`should render secondary locations in the same file 1`] = ` -<div - className="concise-issue-locations-navigator spacer-top" -> - <ConciseIssueLocationsNavigatorLocation - index={0} - key="0" - message="Do not use foo" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> - <ConciseIssueLocationsNavigatorLocation - index={1} - key="1" - message="Do not use foo" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> -</div> -`; - -exports[`should render selected flow locations in the same file 1`] = ` -<div - className="concise-issue-locations-navigator spacer-top" -> - <ConciseIssueLocationsNavigatorLocation - index={0} - key="0" - message="Do not use foo" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> - <ConciseIssueLocationsNavigatorLocation - index={1} - key="1" - message="Do not use foo" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> -</div> -`; - -exports[`should render taint analysis issues correctly 1`] = ` -<CrossFileLocationsNavigator - issue={ - Object { - "actions": Array [], - "component": "foo", - "componentLongName": "main.js", - "componentQualifier": "FIL", - "componentUuid": "foo1234", - "creationDate": "2017-03-01T09:36:01+0100", - "flows": Array [ - Array [ - Object { - "component": "foo", - "componentName": "src/foo.js", - "msg": "Do not use foo", - "textRange": Object { - "endLine": 7, - "endOffset": 8, - "startLine": 7, - "startOffset": 5, - }, - }, - Object { - "component": "foo", - "componentName": "src/foo.js", - "msg": "Do not use foo", - "textRange": Object { - "endLine": 8, - "endOffset": 5, - "startLine": 8, - "startOffset": 0, - }, - }, - Object { - "component": "bar", - "componentName": "src/bar.js", - "msg": "Do not use bar", - "textRange": Object { - "endLine": 16, - "endOffset": 6, - "startLine": 15, - "startOffset": 4, - }, - }, - ], - ], - "fromHotspot": false, - "key": "", - "line": 25, - "message": "Reduce the number of conditional operators (4) used in the expression", - "project": "myproject", - "projectKey": "foo", - "projectName": "Foo", - "rule": "javascript:S1067", - "ruleName": "foo", - "secondaryLocations": Array [], - "severity": "MAJOR", - "status": "OPEN", - "textRange": Object { - "endLine": 26, - "endOffset": 15, - "startLine": 25, - "startOffset": 0, - }, - "transitions": Array [], - "type": "VULNERABILITY", - } - } - locations={ - Array [ - Object { - "component": "foo", - "componentName": "src/foo.js", - "msg": "Do not use foo", - "textRange": Object { - "endLine": 7, - "endOffset": 8, - "startLine": 7, - "startOffset": 5, - }, - }, - Object { - "component": "foo", - "componentName": "src/foo.js", - "msg": "Do not use foo", - "textRange": Object { - "endLine": 8, - "endOffset": 5, - "startLine": 8, - "startOffset": 0, - }, - }, - Object { - "component": "bar", - "componentName": "src/bar.js", - "msg": "Do not use bar", - "textRange": Object { - "endLine": 16, - "endOffset": 6, - "startLine": 15, - "startOffset": 4, - }, - }, - ] - } - onLocationSelect={[MockFunction]} - scroll={[MockFunction]} -/> -`; diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap deleted file mode 100644 index fd78ea74a5c..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap +++ /dev/null @@ -1,112 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render 1`] = ` -<div - className="concise-issue-locations-navigator spacer-top" -> - <div - className="concise-issue-locations-navigator-file" - key="0" - > - <div - className="concise-issue-location-file" - > - <i - className="concise-issue-location-file-circle little-spacer-right" - /> - src/foo.js - </div> - <div - className="concise-issue-location-file-locations" - > - <ConciseIssueLocationsNavigatorLocation - index={0} - key="0" - message="Do not use foo" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> - </div> - </div> - <div - className="concise-issue-locations-navigator-file" - > - <div - className="concise-issue-location-file" - > - <i - className="concise-issue-location-file-circle-multiple little-spacer-right" - /> - <a - className="concise-issue-location-file-more" - href="#" - onClick={[Function]} - > - issues.x_more_locations.1 - </a> - </div> - </div> - <div - className="concise-issue-locations-navigator-file" - key="1" - > - <div - className="concise-issue-location-file" - > - <i - className="concise-issue-location-file-circle little-spacer-right" - /> - src/bar.js - </div> - <div - className="concise-issue-location-file-locations" - > - <ConciseIssueLocationsNavigatorLocation - index={2} - key="2" - message="Do not use bar" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> - </div> - </div> -</div> -`; - -exports[`should render locations with no component name 1`] = ` -<div - className="concise-issue-locations-navigator spacer-top" -> - <div - className="concise-issue-locations-navigator-file" - key="0" - > - <div - className="concise-issue-location-file" - > - <i - className="concise-issue-location-file-circle little-spacer-right" - /> - </div> - <div - className="concise-issue-location-file-locations" - > - <ConciseIssueLocationsNavigatorLocation - index={0} - key="0" - onClick={[MockFunction]} - scroll={[MockFunction]} - selected={false} - /> - </div> - </div> -</div> -`; - -exports[`should render with no locations 1`] = ` -<div - className="concise-issue-locations-navigator spacer-top" -/> -`; 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 d8cfea885cb..16f0981c472 100644 --- a/server/sonar-web/src/main/js/apps/issues/styles.css +++ b/server/sonar-web/src/main/js/apps/issues/styles.css @@ -137,95 +137,6 @@ color: white; } -.concise-issue-locations-navigator-location { - position: relative; - z-index: var(--aboveNormalZIndex); - display: inline-flex; - align-items: flex-start; - max-width: 100%; - border: none; -} - -.concise-issue-locations-navigator-file { - position: relative; -} - -.concise-issue-locations-navigator-file + .concise-issue-locations-navigator-file { - margin-top: calc(1.5 * var(--gridSize)); -} - -.concise-issue-locations-navigator-file:not(:last-child)::before { - position: absolute; - display: block; - width: 0; - top: 13px; - bottom: calc(-2 * var(--gridSize)); - left: 4px; - border-left: 1px dotted var(--conciseIssueRed); - content: ''; -} - -.concise-issue-location-file { - height: calc(2 * var(--gridSize)); - padding-bottom: calc(0.5 * var(--gridSize)); - font-size: var(--smallFontSize); - font-weight: bold; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.concise-issue-location-file-circle, -.concise-issue-location-file-circle-multiple, -.concise-issue-location-file-circle-multiple::before, -.concise-issue-location-file-circle-multiple::after { - position: relative; - top: 1px; - display: inline-block; - width: calc(1px + var(--gridSize)); - height: calc(1px + var(--gridSize)); - border: 1px solid var(--conciseIssueRed); - border-radius: 100%; - box-sizing: border-box; - background-color: var(--issueBgColor); -} - -.concise-issue-location-file-circle-multiple { - top: -2px; -} - -.concise-issue-location-file-circle-multiple::before { - position: absolute; - z-index: calc(5 + var(--normalZIndex)); - top: 2px; - left: -1px; - content: ''; -} - -.concise-issue-location-file-circle-multiple::after { - position: absolute; - z-index: calc(5 + var(--aboveNormalZIndex)); - top: 5px; - left: -1px; - content: ''; -} - -.concise-issue-location-file-locations { - padding-left: calc(2 * var(--gridSize)); -} - -.concise-issue-location-file-more { - border-color: rgba(209, 133, 130, 0.2); - color: rgb(209, 133, 130) !important; - font-style: italic; - font-weight: normal; -} - -.concise-issue-location-file-more:hover, -.concise-issue-location-file-more:focus { - border-color: rgba(209, 133, 130, 0.6); -} - .component-source-container { border: 1px solid var(--gray80); } 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 969217f5617..f90fc16bf4b 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 @@ -74,7 +74,8 @@ interface State { loading: boolean; loadingMeasure: boolean; loadingMore: boolean; - selectedHotspot: RawHotspot | undefined; + selectedHotspot?: RawHotspot; + selectedHotspotLocation?: number; standards: Standards; } 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 715ce19cbff..43f521eb9e4 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 @@ -60,7 +60,8 @@ export interface SecurityHotspotsAppRendererProps { onShowAllHotspots: () => void; onSwitchStatusFilter: (option: HotspotStatusFilter) => void; onUpdateHotspot: (hotspotKey: string) => Promise<void>; - selectedHotspot: RawHotspot | undefined; + selectedHotspot?: RawHotspot; + selectedHotspotLocation?: number; securityCategories: StandardSecurityCategories; standards: Standards; } 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 d8155826feb..12856d774dd 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 @@ -24,12 +24,14 @@ import { HotspotStatus, HotspotStatusFilter, HotspotStatusOption, + RawHotspot, ReviewHistoryType, RiskExposure } from '../../../types/security-hotspots'; -import { IssueChangelog } from '../../../types/types'; +import { FlowLocation, IssueChangelog } from '../../../types/types'; import { getHotspotReviewHistory, + getLocations, getStatusAndResolutionFromStatusOption, getStatusFilterFromStatusOption, getStatusOptionFromStatusAndResolution, @@ -277,3 +279,43 @@ describe('getStatusFilterFromStatusOption', () => { ); }); }); + +describe('getLocations', () => { + it('should return the correct value', () => { + const location1: FlowLocation = { + component: 'foo', + msg: 'Do not use foo', + textRange: { startLine: 7, endLine: 7, startOffset: 5, endOffset: 8 } + }; + + const location2: FlowLocation = { + component: 'foo2', + msg: 'Do not use foo2', + textRange: { startLine: 7, endLine: 7, startOffset: 5, endOffset: 8 } + }; + + let rawFlows: RawHotspot['flows'] = [ + { + locations: [location1] + } + ]; + expect(getLocations(rawFlows, undefined)).toEqual([location1]); + + rawFlows = [ + { + locations: [location1, location2] + } + ]; + expect(getLocations(rawFlows, undefined)).toEqual([location2, location1]); + + rawFlows = [ + { + locations: [location1, location2] + }, + { + locations: [] + } + ]; + expect(getLocations(rawFlows, 0)).toEqual([location2, location1]); + }); +}); 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 4d2f505c4f2..fa176838b89 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 @@ -31,6 +31,7 @@ export interface HotspotCategoryProps { onHotspotClick: (hotspot: RawHotspot) => void; onToggleExpand?: (categoryKey: string, value: boolean) => void; selectedHotspot: RawHotspot; + selectedHotspotLocation?: number; title: string; isLastAndIncomplete: boolean; } 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 482112477b2..8fa66008174 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 @@ -96,3 +96,7 @@ .hotspot-risk-badge.LOW { background-color: var(--yellow); } + +.hotspot-box-filename { + direction: rtl; +} 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 8c78d98ab5e..afa5194f180 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 @@ -39,6 +39,7 @@ interface Props { onLoadMore: () => void; securityCategories: StandardSecurityCategories; selectedHotspot: RawHotspot; + selectedHotspotLocation?: number; statusFilter: HotspotStatusFilter; } 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 ed241c168dc..7865080337c 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 @@ -19,9 +19,11 @@ */ import classNames from 'classnames'; import * as React from 'react'; -import { translate } from '../../../helpers/l10n'; +import QualifierIcon from '../../../components/icons/QualifierIcon'; +import { ComponentQualifier } from '../../../types/component'; +import { getFilePath, getLocations } from '../utils'; +import LocationsList from '../../../components/locations/LocationsList'; import { RawHotspot } from '../../../types/security-hotspots'; -import { getStatusOptionFromStatusAndResolution } from '../utils'; export interface HotspotListItemProps { hotspot: RawHotspot; @@ -31,16 +33,36 @@ export interface HotspotListItemProps { export default function HotspotListItem(props: HotspotListItemProps) { const { hotspot, selected } = props; + const locations = getLocations(hotspot.flows, undefined); + const path = getFilePath(hotspot.component, hotspot.project); + return ( <a className={classNames('hotspot-item', { highlight: selected })} href="#" onClick={() => !selected && props.onClick(hotspot)}> - <div className="little-spacer-left">{hotspot.message}</div> - <div className="badge spacer-top"> - {translate( - 'hotspots.status_option', - getStatusOptionFromStatusAndResolution(hotspot.status, hotspot.resolution) + <div className="little-spacer-left text-bold">{hotspot.message}</div> + <div className="display-flex-center"> + <QualifierIcon qualifier={ComponentQualifier.File} /> + <div + className="little-spacer-left hotspot-box-filename text-ellipsis big-spacer-top big-spacer-bottom" + title={path}> + {path} + </div> + </div> + <div className="spacer-top"> + {selected && ( + <LocationsList + locations={locations} + isCrossFile={false} // Currently we are not supporting cross file for security hotspot + uniqueKey={hotspot.key} + onLocationSelect={() => { + /* noop */ + }} + scroll={() => { + /* noop */ + }} + /> )} </div> </a> diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotListItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotListItem-test.tsx.snap index b959dae2d97..0d0f163550e 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotListItem-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotListItem-test.tsx.snap @@ -7,15 +7,26 @@ exports[`should render correctly 1`] = ` onClick={[Function]} > <div - className="little-spacer-left" + className="little-spacer-left text-bold" > '3' is a magic number. </div> <div - className="badge spacer-top" + className="display-flex-center" > - hotspots.status_option.TO_REVIEW + <QualifierIcon + qualifier="FIL" + /> + <div + className="little-spacer-left hotspot-box-filename text-ellipsis big-spacer-top big-spacer-bottom" + title="com.github.kevinsawicki.http.HttpRequest" + > + com.github.kevinsawicki.http.HttpRequest + </div> </div> + <div + className="spacer-top" + /> </a> `; @@ -26,14 +37,33 @@ exports[`should render correctly 2`] = ` onClick={[Function]} > <div - className="little-spacer-left" + className="little-spacer-left text-bold" > '3' is a magic number. </div> <div - className="badge spacer-top" + className="display-flex-center" + > + <QualifierIcon + qualifier="FIL" + /> + <div + className="little-spacer-left hotspot-box-filename text-ellipsis big-spacer-top big-spacer-bottom" + title="com.github.kevinsawicki.http.HttpRequest" + > + com.github.kevinsawicki.http.HttpRequest + </div> + </div> + <div + className="spacer-top" > - hotspots.status_option.TO_REVIEW + <LocationsList + isCrossFile={false} + locations={Array []} + onLocationSelect={[Function]} + scroll={[Function]} + uniqueKey="01fc972e-2a3c-433e-bcae-0bd7f88f5123" + /> </div> </a> `; 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 b50a669065c..cdcdd8cae45 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 @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { groupBy, sortBy } from 'lodash'; +import { flatten, groupBy, sortBy } from 'lodash'; import { renderCWECategory, renderOwaspTop10Category, @@ -36,7 +36,12 @@ import { ReviewHistoryType, RiskExposure } from '../../types/security-hotspots'; -import { Dict, SourceViewerFile, StandardSecurityCategories } from '../../types/types'; +import { + Dict, + FlowLocation, + SourceViewerFile, + StandardSecurityCategories +} from '../../types/types'; export const RISK_EXPOSURE_LEVELS = [RiskExposure.HIGH, RiskExposure.MEDIUM, RiskExposure.LOW]; export const SECURITY_STANDARDS = [ @@ -190,3 +195,46 @@ const STATUS_OPTION_TO_STATUS_FILTER = { export function getStatusFilterFromStatusOption(statusOption: HotspotStatusOption) { return STATUS_OPTION_TO_STATUS_FILTER[statusOption]; } + +function getSecondaryLocations(flows: RawHotspot['flows']) { + const parsedFlows: FlowLocation[][] = (flows || []) + .filter(flow => flow.locations !== undefined) + .map(flow => flow.locations!.filter(location => location.textRange != null)) + .map(flow => + flow.map(location => { + return { ...location }; + }) + ); + + const onlySecondaryLocations = parsedFlows.every(flow => flow.length === 1); + + return onlySecondaryLocations + ? { secondaryLocations: orderLocations(flatten(parsedFlows)), flows: [] } + : { secondaryLocations: [], flows: parsedFlows.map(reverseLocations) }; +} + +export function getLocations(rawFlows: RawHotspot['flows'], selectedFlowIndex: number | undefined) { + const { flows, secondaryLocations } = getSecondaryLocations(rawFlows); + if (selectedFlowIndex !== undefined) { + return flows[selectedFlowIndex] || []; + } + return flows.length > 0 ? flows[0] : secondaryLocations; +} + +function orderLocations(locations: FlowLocation[]) { + return sortBy( + locations, + location => location.textRange && location.textRange.startLine, + location => location.textRange && location.textRange.startOffset + ); +} + +function reverseLocations(locations: FlowLocation[]): FlowLocation[] { + const x = [...locations]; + x.reverse(); + return x; +} + +export function getFilePath(component: string, project: string) { + return component.replace(project, '').replace(':', ''); +} diff --git a/server/sonar-web/src/main/js/components/locations/CrossFileLocationNavigator.css b/server/sonar-web/src/main/js/components/locations/CrossFileLocationNavigator.css new file mode 100644 index 00000000000..42f917e964a --- /dev/null +++ b/server/sonar-web/src/main/js/components/locations/CrossFileLocationNavigator.css @@ -0,0 +1,98 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 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. + */ +.locations-navigator-file { + position: relative; +} + +.locations-navigator-file + .locations-navigator-file { + margin-top: calc(1.5 * var(--gridSize)); +} + +.locations-navigator-file:not(:last-child)::before { + position: absolute; + display: block; + width: 0; + top: 13px; + bottom: calc(-2 * var(--gridSize)); + left: 4px; + border-left: 1px dotted var(--conciseIssueRed); + content: ''; +} + +.location-file-locations { + padding-left: calc(2 * var(--gridSize)); +} + +.location-file { + height: calc(2 * var(--gridSize)); + padding-bottom: calc(0.5 * var(--gridSize)); + font-size: var(--smallFontSize); + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.location-file-circle, +.location-file-circle-multiple, +.location-file-circle-multiple::before, +.location-file-circle-multiple::after { + position: relative; + top: 1px; + display: inline-block; + width: calc(1px + var(--gridSize)); + height: calc(1px + var(--gridSize)); + border: 1px solid var(--conciseIssueRed); + border-radius: 100%; + box-sizing: border-box; + background-color: var(--issueBgColor); +} + +.location-file-circle-multiple { + top: -2px; +} + +.location-file-circle-multiple::before { + position: absolute; + z-index: calc(5 + var(--normalZIndex)); + top: 2px; + left: -1px; + content: ''; +} + +.location-file-circle-multiple::after { + position: absolute; + z-index: calc(5 + var(--aboveNormalZIndex)); + top: 5px; + left: -1px; + content: ''; +} + +.location-file-more { + border-color: rgba(209, 133, 130, 0.2); + color: rgb(209, 133, 130) !important; + font-style: italic; + font-weight: normal; +} + +.location-file-more:hover, +.location-file-more:focus { + border-color: rgba(209, 133, 130, 0.6); +} diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/CrossFileLocationsNavigator.tsx b/server/sonar-web/src/main/js/components/locations/CrossFileLocationNavigator.tsx index a25c64fbfde..bf6074b5a54 100644 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/CrossFileLocationsNavigator.tsx +++ b/server/sonar-web/src/main/js/components/locations/CrossFileLocationNavigator.tsx @@ -18,13 +18,14 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { translateWithParameters } from '../../../helpers/l10n'; -import { collapsePath } from '../../../helpers/path'; -import { FlowLocation, Issue } from '../../../types/types'; -import ConciseIssueLocationsNavigatorLocation from './ConciseIssueLocationsNavigatorLocation'; +import { translateWithParameters } from '../../helpers/l10n'; +import { collapsePath } from '../../helpers/path'; +import SingleFileLocationNavigator from './SingleFileLocationNavigator'; +import './CrossFileLocationNavigator.css'; +import { FlowLocation } from '../../types/types'; interface Props { - issue: Pick<Issue, 'key' | 'type'>; + uniqueKey: string; locations: FlowLocation[]; onLocationSelect: (index: number) => void; scroll: (element: Element) => void; @@ -44,11 +45,11 @@ interface LocationGroup { const MAX_PATH_LENGTH = 15; -export default class CrossFileLocationsNavigator extends React.PureComponent<Props, State> { +export default class CrossFileLocationNavigator extends React.PureComponent<Props, State> { state: State = { collapsed: true }; componentWillReceiveProps(nextProps: Props) { - if (nextProps.issue.key !== this.props.issue.key) { + if (nextProps.uniqueKey !== this.props.uniqueKey) { this.setState({ collapsed: true }); } @@ -112,7 +113,7 @@ export default class CrossFileLocationsNavigator extends React.PureComponent<Pro renderLocation = (index: number, message: string | undefined) => { return ( - <ConciseIssueLocationsNavigatorLocation + <SingleFileLocationNavigator index={index} key={index} message={message} @@ -131,13 +132,13 @@ export default class CrossFileLocationsNavigator extends React.PureComponent<Pro const { firstLocationIndex } = group; const lastLocationIndex = group.locations.length - 1; return ( - <div className="concise-issue-locations-navigator-file" key={groupIndex}> - <div className="concise-issue-location-file"> - <i className="concise-issue-location-file-circle little-spacer-right" /> + <div className="locations-navigator-file" key={groupIndex}> + <div className="location-file"> + <i className="location-file-circle little-spacer-right" /> {collapsePath(group.componentName || '', MAX_PATH_LENGTH)} </div> {group.locations.length > 0 && ( - <div className="concise-issue-location-file-locations"> + <div className="location-file-locations"> {onlyFirst && this.renderLocation(firstLocationIndex, group.locations[0].msg)} {onlyLast && @@ -160,33 +161,33 @@ export default class CrossFileLocationsNavigator extends React.PureComponent<Pro render() { const { locations } = this.props; const groups = this.groupByFile(locations); + const MIN_LOCATION_LENGTH = 2; - if (locations.length > 2 && groups.length > 1 && this.state.collapsed) { + if (locations.length > MIN_LOCATION_LENGTH && groups.length > 1 && this.state.collapsed) { const firstGroup = groups[0]; const lastGroup = groups[groups.length - 1]; return ( - <div className="concise-issue-locations-navigator spacer-top"> + <div className="spacer-top"> {this.renderGroup(firstGroup, 0, { onlyFirst: true })} - <div className="concise-issue-locations-navigator-file"> - <div className="concise-issue-location-file"> - <i className="concise-issue-location-file-circle-multiple little-spacer-right" /> - <a - className="concise-issue-location-file-more" - href="#" - onClick={this.handleMoreLocationsClick}> - {translateWithParameters('issues.x_more_locations', locations.length - 2)} + <div className="locations-navigator-file"> + <div className="location-file"> + <i className="location-file-circle-multiple little-spacer-right" /> + <a className="location-file-more" href="#" onClick={this.handleMoreLocationsClick}> + {translateWithParameters( + 'issues.x_more_locations', + locations.length - MIN_LOCATION_LENGTH + )} </a> </div> </div> {this.renderGroup(lastGroup, groups.length - 1, { onlyLast: true })} </div> ); - } else { - return ( - <div className="concise-issue-locations-navigator spacer-top"> - {groups.map((group, groupIndex) => this.renderGroup(group, groupIndex))} - </div> - ); } + return ( + <div className="spacer-top"> + {groups.map((group, groupIndex) => this.renderGroup(group, groupIndex))} + </div> + ); } } diff --git a/server/sonar-web/src/main/js/components/locations/LocationsList.tsx b/server/sonar-web/src/main/js/components/locations/LocationsList.tsx new file mode 100644 index 00000000000..6a38b047a05 --- /dev/null +++ b/server/sonar-web/src/main/js/components/locations/LocationsList.tsx @@ -0,0 +1,70 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 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 { FlowLocation } from '../../types/types'; +import CrossFileLocationNavigator from './CrossFileLocationNavigator'; +import SingleFileLocationNavigator from './SingleFileLocationNavigator'; + +interface Props { + isCrossFile: boolean; + uniqueKey: string; + locations: FlowLocation[]; + onLocationSelect: (index: number) => void; + scroll: (element: Element) => void; + selectedFlowIndex?: number; + selectedLocationIndex?: number; +} + +export default class LocationsList extends React.PureComponent<Props> { + render() { + const { isCrossFile, locations, uniqueKey, selectedLocationIndex } = this.props; + + if (!locations || locations.length === 0 || locations.every(location => !location.msg)) { + return null; + } + + if (isCrossFile) { + return ( + <CrossFileLocationNavigator + uniqueKey={uniqueKey} + locations={locations} + onLocationSelect={this.props.onLocationSelect} + scroll={this.props.scroll} + selectedLocationIndex={selectedLocationIndex} + /> + ); + } + return ( + <div className="spacer-top"> + {locations.map((location, index) => ( + <SingleFileLocationNavigator + index={index} + // eslint-disable-next-line react/no-array-index-key + key={index} + message={location.msg} + onClick={this.props.onLocationSelect} + scroll={this.props.scroll} + selected={index === selectedLocationIndex} + /> + ))} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/locations/SingleFileLocationNavigator.css b/server/sonar-web/src/main/js/components/locations/SingleFileLocationNavigator.css new file mode 100644 index 00000000000..f3e60e4a969 --- /dev/null +++ b/server/sonar-web/src/main/js/components/locations/SingleFileLocationNavigator.css @@ -0,0 +1,27 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 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. + */ +.locations-navigator { + position: relative; + z-index: var(--aboveNormalZIndex); + display: inline-flex; + align-items: flex-start; + max-width: 100%; + border: none; +} diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx b/server/sonar-web/src/main/js/components/locations/SingleFileLocationNavigator.tsx index de086bf2a10..07d329ed224 100644 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx +++ b/server/sonar-web/src/main/js/components/locations/SingleFileLocationNavigator.tsx @@ -18,8 +18,9 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import LocationIndex from '../../../components/common/LocationIndex'; -import LocationMessage from '../../../components/common/LocationMessage'; +import LocationIndex from '../common/LocationIndex'; +import LocationMessage from '../common/LocationMessage'; +import './SingleFileLocationNavigator.css'; interface Props { index: number; @@ -29,7 +30,7 @@ interface Props { selected: boolean; } -export default class ConciseIssueLocationsNavigatorLocation extends React.PureComponent<Props> { +export default class SingleFileLocationNavigator extends React.PureComponent<Props> { node?: HTMLElement | null; componentDidMount() { @@ -54,10 +55,7 @@ export default class ConciseIssueLocationsNavigatorLocation extends React.PureCo return ( <div className="little-spacer-top" ref={node => (this.node = node)}> - <a - className="concise-issue-locations-navigator-location" - href="#" - onClick={this.handleClick}> + <a className="locations-navigator" href="#" onClick={this.handleClick}> <LocationIndex selected={selected}>{index + 1}</LocationIndex> <LocationMessage selected={selected}>{message}</LocationMessage> </a> diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/CrossFileLocationsNavigator-test.tsx b/server/sonar-web/src/main/js/components/locations/__tests__/CrossFileLocationsNavigator-test.tsx index a5e96b1f6d5..a5f7cd136d3 100644 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/CrossFileLocationsNavigator-test.tsx +++ b/server/sonar-web/src/main/js/components/locations/__tests__/CrossFileLocationsNavigator-test.tsx @@ -19,10 +19,10 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { mockFlowLocation } from '../../../../helpers/testMocks'; -import { click } from '../../../../helpers/testUtils'; -import { FlowLocation } from '../../../../types/types'; -import CrossFileLocationsNavigator from '../CrossFileLocationsNavigator'; +import { click } from '../../../helpers/testUtils'; +import { mockFlowLocation } from '../../../helpers/testMocks'; +import { FlowLocation } from '../../../types/types'; +import CrossFileLocationsNavigator from '../CrossFileLocationNavigator'; const location1: FlowLocation = { component: 'foo', @@ -57,40 +57,40 @@ it('should render', () => { const wrapper = shallowRender(); expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2); + expect(wrapper.find('SingleFileLocationNavigator').length).toBe(2); - click(wrapper.find('.concise-issue-location-file-more')); - expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(3); + click(wrapper.find('.location-file-more')); + expect(wrapper.find('SingleFileLocationNavigator').length).toBe(3); }); it('should render all locations', () => { const wrapper = shallowRender({ locations: [location1, location2] }); - expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2); + expect(wrapper.find('SingleFileLocationNavigator').length).toBe(2); }); it('should expand all locations', () => { const wrapper = shallowRender(); - expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2); + expect(wrapper.find('SingleFileLocationNavigator').length).toBe(2); wrapper.setProps({ selectedLocationIndex: 1 }); - expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(3); + expect(wrapper.find('SingleFileLocationNavigator').length).toBe(3); }); it('should collapse locations when issue changes', () => { const wrapper = shallowRender(); wrapper.setProps({ selectedLocationIndex: 1 }); - expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(3); + expect(wrapper.find('SingleFileLocationNavigator').length).toBe(3); - wrapper.setProps({ issue: { key: 'def', type: 'BUG' }, selectedLocationIndex: undefined }); - expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2); + wrapper.setProps({ uniqueKey: 'def', selectedLocationIndex: undefined }); + expect(wrapper.find('SingleFileLocationNavigator').length).toBe(2); }); function shallowRender(props: Partial<CrossFileLocationsNavigator['props']> = {}) { return shallow<CrossFileLocationsNavigator>( <CrossFileLocationsNavigator - issue={{ key: 'abcd', type: 'BUG' }} + uniqueKey="abcd" locations={[location1, location2, location3]} onLocationSelect={jest.fn()} scroll={jest.fn()} diff --git a/server/sonar-web/src/main/js/components/locations/__tests__/LocationsList-test.tsx b/server/sonar-web/src/main/js/components/locations/__tests__/LocationsList-test.tsx new file mode 100644 index 00000000000..48b9868be13 --- /dev/null +++ b/server/sonar-web/src/main/js/components/locations/__tests__/LocationsList-test.tsx @@ -0,0 +1,75 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 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 { mockIssue } from '../../../helpers/testMocks'; +import { FlowLocation } from '../../../types/types'; +import LocationsList from '../LocationsList'; + +const location1: FlowLocation = { + component: 'foo', + componentName: 'src/foo.js', + msg: 'Do not use foo', + textRange: { startLine: 7, endLine: 7, startOffset: 5, endOffset: 8 } +}; + +const location2: FlowLocation = { + component: 'foo', + componentName: 'src/foo.js', + msg: 'Do not use foo', + textRange: { startLine: 8, endLine: 8, startOffset: 0, endOffset: 5 } +}; + +const location3: FlowLocation = { + component: 'bar', + componentName: 'src/bar.js', + msg: 'Do not use bar', + textRange: { startLine: 15, endLine: 16, startOffset: 4, endOffset: 6 } +}; + +it('should render locations in the same file', () => { + const locations = [location1, location2]; + expect(shallowRender({ uniqueKey: '', locations, isCrossFile: false })).toMatchSnapshot(); +}); + +it('should render flow locations in different file', () => { + const locations = [location1, location3]; + expect(shallowRender({ uniqueKey: '', locations, isCrossFile: true })).toMatchSnapshot(); +}); + +it('should not render locations', () => { + const wrapper = shallowRender({ uniqueKey: '', locations: [] }); + expect(wrapper.type()).toBeNull(); +}); + +function shallowRender(overrides: Partial<LocationsList['props']> = {}) { + return shallow<LocationsList>( + <LocationsList + uniqueKey={mockIssue().key} + locations={mockIssue().secondaryLocations} + isCrossFile={true} + onLocationSelect={jest.fn()} + scroll={jest.fn()} + selectedFlowIndex={undefined} + selectedLocationIndex={undefined} + {...overrides} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigatorLocation-test.tsx b/server/sonar-web/src/main/js/components/locations/__tests__/SingleFileLocationsNavigator-test.tsx index 53e58b9c745..9410e6ac13e 100644 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigatorLocation-test.tsx +++ b/server/sonar-web/src/main/js/components/locations/__tests__/SingleFileLocationsNavigator-test.tsx @@ -19,16 +19,16 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import ConciseIssueLocationsNavigatorLocation from '../ConciseIssueLocationsNavigatorLocation'; +import SingleFileLocationNavigator from '../SingleFileLocationNavigator'; it('should render correctly', () => { expect(shallowRender()).toMatchSnapshot('index 1'); expect(shallowRender({ index: 1 })).toMatchSnapshot('index 2'); }); -function shallowRender(props: Partial<ConciseIssueLocationsNavigatorLocation['props']> = {}) { +function shallowRender(props: Partial<SingleFileLocationNavigator['props']> = {}) { return shallow( - <ConciseIssueLocationsNavigatorLocation + <SingleFileLocationNavigator index={0} message="" onClick={jest.fn()} diff --git a/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap b/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap new file mode 100644 index 00000000000..53ee06d1024 --- /dev/null +++ b/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` +<div + className="spacer-top" +> + <div + className="locations-navigator-file" + key="0" + > + <div + className="location-file" + > + <i + className="location-file-circle little-spacer-right" + /> + src/foo.js + </div> + <div + className="location-file-locations" + > + <SingleFileLocationNavigator + index={0} + key="0" + message="Do not use foo" + onClick={[MockFunction]} + scroll={[MockFunction]} + selected={false} + /> + </div> + </div> + <div + className="locations-navigator-file" + > + <div + className="location-file" + > + <i + className="location-file-circle-multiple little-spacer-right" + /> + <a + className="location-file-more" + href="#" + onClick={[Function]} + > + issues.x_more_locations.1 + </a> + </div> + </div> + <div + className="locations-navigator-file" + key="1" + > + <div + className="location-file" + > + <i + className="location-file-circle little-spacer-right" + /> + src/bar.js + </div> + <div + className="location-file-locations" + > + <SingleFileLocationNavigator + index={2} + key="2" + message="Do not use bar" + onClick={[MockFunction]} + scroll={[MockFunction]} + selected={false} + /> + </div> + </div> +</div> +`; + +exports[`should render locations with no component name 1`] = ` +<div + className="spacer-top" +> + <div + className="locations-navigator-file" + key="0" + > + <div + className="location-file" + > + <i + className="location-file-circle little-spacer-right" + /> + </div> + <div + className="location-file-locations" + > + <SingleFileLocationNavigator + index={0} + key="0" + onClick={[MockFunction]} + scroll={[MockFunction]} + selected={false} + /> + </div> + </div> +</div> +`; + +exports[`should render with no locations 1`] = ` +<div + className="spacer-top" +/> +`; diff --git a/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/LocationsList-test.tsx.snap b/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/LocationsList-test.tsx.snap new file mode 100644 index 00000000000..9cb465ba244 --- /dev/null +++ b/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/LocationsList-test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render flow locations in different file 1`] = ` +<CrossFileLocationNavigator + locations={ + Array [ + Object { + "component": "foo", + "componentName": "src/foo.js", + "msg": "Do not use foo", + "textRange": Object { + "endLine": 7, + "endOffset": 8, + "startLine": 7, + "startOffset": 5, + }, + }, + Object { + "component": "bar", + "componentName": "src/bar.js", + "msg": "Do not use bar", + "textRange": Object { + "endLine": 16, + "endOffset": 6, + "startLine": 15, + "startOffset": 4, + }, + }, + ] + } + onLocationSelect={[MockFunction]} + scroll={[MockFunction]} + uniqueKey="" +/> +`; + +exports[`should render locations in the same file 1`] = ` +<div + className="spacer-top" +> + <SingleFileLocationNavigator + index={0} + key="0" + message="Do not use foo" + onClick={[MockFunction]} + scroll={[MockFunction]} + selected={false} + /> + <SingleFileLocationNavigator + index={1} + key="1" + message="Do not use foo" + onClick={[MockFunction]} + scroll={[MockFunction]} + selected={false} + /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigatorLocation-test.tsx.snap b/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/SingleFileLocationsNavigator-test.tsx.snap index a609334626a..46f03290c53 100644 --- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigatorLocation-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/locations/__tests__/__snapshots__/SingleFileLocationsNavigator-test.tsx.snap @@ -5,7 +5,7 @@ exports[`should render correctly: index 1 1`] = ` className="little-spacer-top" > <a - className="concise-issue-locations-navigator-location" + className="locations-navigator" href="#" onClick={[Function]} > @@ -26,7 +26,7 @@ exports[`should render correctly: index 2 1`] = ` className="little-spacer-top" > <a - className="concise-issue-locations-navigator-location" + className="locations-navigator" href="#" onClick={[Function]} > diff --git a/server/sonar-web/src/main/js/types/security-hotspots.ts b/server/sonar-web/src/main/js/types/security-hotspots.ts index 5d0ec2621ea..ecd85abcda1 100644 --- a/server/sonar-web/src/main/js/types/security-hotspots.ts +++ b/server/sonar-web/src/main/js/types/security-hotspots.ts @@ -70,6 +70,9 @@ export interface RawHotspot { subProject?: string; updateDate: string; vulnerabilityProbability: RiskExposure; + flows?: Array<{ + locations?: Array<Omit<FlowLocation, 'componentName'>>; + }>; } export interface Hotspot { |