From 5340427e788552bdff13756721110bf7c41b91f0 Mon Sep 17 00:00:00 2001 From: Jeremy Davis Date: Mon, 20 May 2019 17:58:59 +0200 Subject: [PATCH] SONAR-12112 horizontal scrolling --- .../ComponentSourceSnippetViewer.tsx | 180 +--- .../SnippetViewer.tsx | 237 +++++ .../ComponentSourceSnippetViewer-test.tsx | 10 +- .../__tests__/SnippetViewer-test.tsx | 115 +++ .../__snapshots__/SnippetViewer-test.tsx.snap | 930 ++++++++++++++++++ .../src/main/js/apps/issues/styles.css | 17 +- .../js/helpers/__tests__/scrolling-test.ts | 194 ++++ .../src/main/js/helpers/scrolling.ts | 92 +- 8 files changed, 1592 insertions(+), 183 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/SnippetViewer-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/helpers/__tests__/scrolling-test.ts diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx index fe4b99a31fd..bdbe163a429 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx @@ -19,27 +19,12 @@ */ import * as React from 'react'; import * as classNames from 'classnames'; -import { - createSnippets, - expandSnippet, - inSnippet, - EXPAND_BY_LINES, - LINES_BELOW_LAST, - MERGE_DISTANCE -} from './utils'; -import ExpandSnippetIcon from '../../../components/icons-components/ExpandSnippetIcon'; -import Line from '../../../components/SourceViewer/components/Line'; +import { createSnippets, expandSnippet, EXPAND_BY_LINES, MERGE_DISTANCE } from './utils'; +import SnippetViewer from './SnippetViewer'; import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim'; import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus'; import { getSources } from '../../../api/components'; -import { symbolsByLine, locationsByLine } from '../../../components/SourceViewer/helpers/indexing'; -import { getSecondaryIssueLocationsForLine } from '../../../components/SourceViewer/helpers/issueLocations'; -import { - optimizeLocationMessage, - optimizeHighlightedSymbols, - optimizeSelectedIssue -} from '../../../components/SourceViewer/helpers/lines'; -import { translate } from '../../../helpers/l10n'; +import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing'; import { getBranchLikeQuery } from '../../../helpers/branches'; interface Props { @@ -209,155 +194,49 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent i.key === this.props.issue.key); - return ( - 1} - displayLocationMarkers={true} - duplications={lineDuplications} - duplicationsCount={duplicationsCount} - highlighted={false} - highlightedLocationMessage={optimizeLocationMessage( - this.props.highlightedLocationMessage, - secondaryIssueLocations - )} - highlightedSymbols={optimizeHighlightedSymbols(symbols, this.state.highlightedSymbols)} - issueLocations={issueLocations} - issuePopup={this.props.issuePopup} - issues={issuesForLine} - key={line.line} - last={false} - line={line} - linePopup={this.props.linePopup} + component={this.props.snippetGroup.component} + expandBlock={this.expandBlock} + handleCloseIssues={this.handleCloseIssues} + handleLinePopupToggle={this.handleLinePopupToggle} + handleOpenIssues={this.handleOpenIssues} + handleSymbolClick={this.handleSymbolClick} + highlightedLocationMessage={this.props.highlightedLocationMessage} + highlightedSymbols={this.state.highlightedSymbols} + index={index} + issue={this.props.issue} + issuesByLine={issuesByLine} + key={index} + last={last} loadDuplications={this.loadDuplications} + locations={this.props.locations} + locationsByLine={locationsByLine} onIssueChange={this.props.onIssueChange} onIssuePopupToggle={this.props.onIssuePopupToggle} - onIssueSelect={() => {}} - onIssueUnselect={() => {}} - onIssuesClose={this.handleCloseIssues} - onIssuesOpen={this.handleOpenIssues} - onLinePopupToggle={this.handleLinePopupToggle} onLocationSelect={this.props.onLocationSelect} - onSymbolClick={this.handleSymbolClick} - openIssues={openIssuesByLine[line.line]} - previousLine={index > 0 ? snippet[index - 1] : undefined} + openIssuesByLine={this.state.openIssuesByLine} renderDuplicationPopup={this.renderDuplicationPopup} scroll={this.props.scroll} - secondaryIssueLocations={secondaryIssueLocations} - selectedIssue={optimizeSelectedIssue(this.props.issue.key, issuesForLine)} - verticalBuffer={verticalBuffer} + snippet={snippet} /> ); } - renderSnippet({ - snippet, - index, - issue, - issuesByLine = {}, - locationsByLine, - last - }: { - snippet: T.SourceLine[]; - index: number; - issue: T.Issue; - issuesByLine: T.IssuesByLine; - locationsByLine: { [line: number]: T.LinearIssueLocation[] }; - last: boolean; - }) { - const { component } = this.props.snippetGroup; - const lastLine = - component.measures && component.measures.lines && parseInt(component.measures.lines, 10); - - const symbols = symbolsByLine(snippet); - - const expandBlock = (direction: T.ExpandDirection) => () => this.expandBlock(index, direction); - - const bottomLine = snippet[snippet.length - 1].line; - const issueLine = issue.textRange ? issue.textRange.endLine : issue.line; - const lowestVisibleIssue = Math.max( - ...Object.keys(issuesByLine) - .map(k => parseInt(k, 10)) - .filter(l => inSnippet(l, snippet) && (l === issueLine || this.state.openIssuesByLine[l])) - ); - const verticalBuffer = last - ? Math.max(0, LINES_BELOW_LAST - (bottomLine - lowestVisibleIssue)) - : 0; - - const displayDuplications = snippet.some(s => !!s.duplicated); - - return ( -
- {snippet[0].line > 1 && ( - - )} - - - {snippet.map((line, index) => - this.renderLine({ - displayDuplications, - index, - issuesForLine: issuesByLine[line.line] || [], - issueLocations: locationsByLine[line.line] || [], - line, - snippet, - symbols: symbols[line.line], - verticalBuffer: index === snippet.length - 1 ? verticalBuffer : 0 - }) - )} - -
- {(!lastLine || snippet[snippet.length - 1].line < lastLine) && ( - - )} -
- ); - } - render() { const { branchLike, duplications, issue, issuesByLine, last, snippetGroup } = this.props; const { loading, snippets } = this.state; @@ -384,7 +263,6 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent void; + handleCloseIssues: (line: T.SourceLine) => void; + handleLinePopupToggle: (line: T.SourceLine) => void; + handleOpenIssues: (line: T.SourceLine) => void; + handleSymbolClick: (symbols: string[]) => void; + highlightedLocationMessage: { index: number; text: string | undefined } | undefined; + highlightedSymbols: string[]; + index: number; + issue: T.Issue; + issuePopup?: { issue: string; name: string }; + issuesByLine: T.IssuesByLine; + last: boolean; + linePopup?: T.LinePopup; + loadDuplications: (line: T.SourceLine) => void; + locations: T.FlowLocation[]; + locationsByLine: { [line: number]: T.LinearIssueLocation[] }; + onIssueChange: (issue: T.Issue) => void; + onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void; + onLocationSelect: (index: number) => void; + openIssuesByLine: T.Dict; + renderDuplicationPopup: (index: number, line: number) => React.ReactNode; + scroll?: (element: HTMLElement) => void; + snippet: T.SourceLine[]; +} + +const SCROLL_LEFT_OFFSET = 32; + +export default class SnippetViewer extends React.PureComponent { + node: React.RefObject; + + constructor(props: Props) { + super(props); + this.node = React.createRef(); + } + + doScroll = (element: HTMLElement) => { + if (this.props.scroll) { + this.props.scroll(element); + } + const parent = this.node.current as Element; + + if (parent) { + scrollHorizontally(element, { + leftOffset: SCROLL_LEFT_OFFSET, + rightOffset: parent.getBoundingClientRect().width - SCROLL_LEFT_OFFSET, + parent + }); + } + }; + + expandBlock = (direction: T.ExpandDirection) => () => + this.props.expandBlock(this.props.index, direction); + + renderLine({ + displayDuplications, + index, + issuesForLine, + issueLocations, + line, + snippet, + symbols, + verticalBuffer + }: { + displayDuplications: boolean; + index: number; + issuesForLine: T.Issue[]; + issueLocations: T.LinearIssueLocation[]; + line: T.SourceLine; + snippet: T.SourceLine[]; + symbols: string[]; + verticalBuffer: number; + }) { + const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, this.props.locations); + + const { duplications, duplicationsByLine } = this.props; + const duplicationsCount = duplications ? duplications.length : 0; + const lineDuplications = + (duplicationsCount && duplicationsByLine && duplicationsByLine[line.line]) || []; + + const isSinkLine = issuesForLine.some(i => i.key === this.props.issue.key); + + return ( + 1} + displayLocationMarkers={true} + duplications={lineDuplications} + duplicationsCount={duplicationsCount} + highlighted={false} + highlightedLocationMessage={optimizeLocationMessage( + this.props.highlightedLocationMessage, + secondaryIssueLocations + )} + highlightedSymbols={optimizeHighlightedSymbols(symbols, this.props.highlightedSymbols)} + issueLocations={issueLocations} + issuePopup={this.props.issuePopup} + issues={issuesForLine} + key={line.line} + last={false} + line={line} + linePopup={this.props.linePopup} + loadDuplications={this.props.loadDuplications} + onIssueChange={this.props.onIssueChange} + onIssuePopupToggle={this.props.onIssuePopupToggle} + onIssueSelect={() => {}} + onIssueUnselect={() => {}} + onIssuesClose={this.props.handleCloseIssues} + onIssuesOpen={this.props.handleOpenIssues} + onLinePopupToggle={this.props.handleLinePopupToggle} + onLocationSelect={this.props.onLocationSelect} + onSymbolClick={this.props.handleSymbolClick} + openIssues={this.props.openIssuesByLine[line.line]} + previousLine={index > 0 ? snippet[index - 1] : undefined} + renderDuplicationPopup={this.props.renderDuplicationPopup} + scroll={this.doScroll} + secondaryIssueLocations={secondaryIssueLocations} + selectedIssue={optimizeSelectedIssue(this.props.issue.key, issuesForLine)} + verticalBuffer={verticalBuffer} + /> + ); + } + + render() { + const { + component, + issue, + issuesByLine = {}, + last, + locationsByLine, + openIssuesByLine, + snippet + } = this.props; + const lastLine = + component.measures && component.measures.lines && parseInt(component.measures.lines, 10); + + const symbols = symbolsByLine(snippet); + + const bottomLine = snippet[snippet.length - 1].line; + const issueLine = issue.textRange ? issue.textRange.endLine : issue.line; + const lowestVisibleIssue = Math.max( + ...Object.keys(issuesByLine) + .map(k => parseInt(k, 10)) + .filter(l => inSnippet(l, snippet) && (l === issueLine || openIssuesByLine[l])) + ); + const verticalBuffer = last + ? Math.max(0, LINES_BELOW_LAST - (bottomLine - lowestVisibleIssue)) + : 0; + + const displayDuplications = snippet.some(s => !!s.duplicated); + + return ( +
+ + + {snippet[0].line > 1 && ( + + + + )} + {snippet.map((line, index) => + this.renderLine({ + displayDuplications, + index, + issuesForLine: issuesByLine[line.line] || [], + issueLocations: locationsByLine[line.line] || [], + line, + snippet, + symbols: symbols[line.line], + verticalBuffer: index === snippet.length - 1 ? verticalBuffer : 0 + }) + )} + {(!lastLine || snippet[snippet.length - 1].line < lastLine) && ( + + + + )} + +
+ +
+ +
+
+ ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx index 6ec7748b8e2..a1e7fdd29ff 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx @@ -102,7 +102,7 @@ it('should expand full component', async () => { expect(wrapper.state('snippets')[0]).toHaveLength(14); }); -it.only('should get the right branch when expanding', async () => { +it('should get the right branch when expanding', async () => { (getSources as jest.Mock).mockResolvedValueOnce( Object.values( mockSnippetsByComponent('a', [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]).sources @@ -168,19 +168,19 @@ it('should correctly handle lines actions', () => { const line = mockSourceLine(); wrapper - .find('Line') + .find('SnippetViewer') .first() .prop('loadDuplications')(line); expect(loadDuplications).toHaveBeenCalledWith('a', line); wrapper - .find('Line') + .find('SnippetViewer') .first() - .prop('onLinePopupToggle')({ line: 13, name: 'foo' }); + .prop('handleLinePopupToggle')({ line: 13, name: 'foo' }); expect(onLinePopupToggle).toHaveBeenCalledWith({ component: 'a', line: 13, name: 'foo' }); wrapper - .find('Line') + .find('SnippetViewer') .first() .prop('renderDuplicationPopup')(1, 13); expect(renderDuplicationPopup).toHaveBeenCalledWith( diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx new file mode 100644 index 00000000000..2424539c41a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx @@ -0,0 +1,115 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 { range } from 'lodash'; +import { shallow } from 'enzyme'; +import SnippetViewer from '../SnippetViewer'; +import { + mockSourceViewerFile, + mockMainBranch, + mockIssue, + mockSourceLine +} from '../../../../helpers/testMocks'; + +it('should render correctly', () => { + const snippet = range(5, 8).map(line => mockSourceLine({ line })); + const wrapper = shallowRender({ + snippet + }); + + expect(wrapper).toMatchSnapshot(); +}); + +it('should render correctly when at the top of the file', () => { + const snippet = range(1, 8).map(line => mockSourceLine({ line })); + const wrapper = shallowRender({ + snippet + }); + + expect(wrapper).toMatchSnapshot(); +}); + +it('should render correctly when at the bottom of the file', () => { + const component = mockSourceViewerFile({ measures: { lines: '14' } }); + const snippet = range(10, 14).map(line => mockSourceLine({ line })); + const wrapper = shallowRender({ + component, + snippet + }); + + expect(wrapper).toMatchSnapshot(); +}); + +it('should correctly handle expansion', () => { + const snippet = range(5, 8).map(line => mockSourceLine({ line })); + const expandBlock = jest.fn(); + + const wrapper = shallowRender({ + expandBlock, + index: 2, + snippet + }); + + wrapper + .find('.expand-block-above button') + .first() + .simulate('click'); + expect(expandBlock).toHaveBeenCalledWith(2, 'up'); + + wrapper + .find('.expand-block-below button') + .first() + .simulate('click'); + expect(expandBlock).toHaveBeenCalledWith(2, 'down'); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/SnippetViewer-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/SnippetViewer-test.tsx.snap new file mode 100644 index 00000000000..e2081bb97cb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/SnippetViewer-test.tsx.snap @@ -0,0 +1,930 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +
+ + + + + + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 5, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 6, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 5, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 7, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 6, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + + + + +
+ +
+ +
+
+`; + +exports[`should render correctly when at the bottom of the file 1`] = ` +
+ + + + + + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 10, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 11, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 10, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 12, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 11, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 13, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 12, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + + + + +
+ +
+ +
+
+`; + +exports[`should render correctly when at the top of the file 1`] = ` +
+ + + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 1, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 2, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 1, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 3, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 2, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 4, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 3, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 5, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 4, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 6, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 5, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 7, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + loadDuplications={[MockFunction]} + onIssueChange={[MockFunction]} + onIssuePopupToggle={[MockFunction]} + onIssueSelect={[Function]} + onIssueUnselect={[Function]} + onIssuesClose={[MockFunction]} + onIssuesOpen={[MockFunction]} + onLinePopupToggle={[MockFunction]} + onLocationSelect={[MockFunction]} + onSymbolClick={[MockFunction]} + previousLine={ + Object { + "code": "import java.util.ArrayList;", + "coverageStatus": "covered", + "coveredConditions": 2, + "duplicated": false, + "isNew": true, + "line": 6, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", + } + } + renderDuplicationPopup={[MockFunction]} + scroll={[Function]} + secondaryIssueLocations={Array []} + verticalBuffer={0} + /> + + + + +
+ +
+
+`; 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 0aa8aeecd54..a29b2181191 100644 --- a/server/sonar-web/src/main/js/apps/issues/styles.css +++ b/server/sonar-web/src/main/js/apps/issues/styles.css @@ -244,7 +244,12 @@ overflow-x: auto; } -.snippet > .expand-block { +.snippet > table { + width: 100%; +} + +.expand-block > td > button { + background: transparent; box-sizing: border-box; color: var(--secondFontColor); height: 20px; @@ -254,16 +259,16 @@ text-align: left; cursor: pointer; } -.snippet > .expand-block:hover, -.snippet > .expand-block:focus, -.snippet > .expand-block:active { +.expand-block > td > button:hover, +.expand-block > td > button:focus, +.expand-block > td > button:active { color: var(--darkBlue); outline: none; } -.snippet > .expand-block-above { +.expand-block-above { background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAADdJREFUCB1dzMEKADAIAlBd1v9/bcc2YgRjHh8qq2qTxCQzsX4wM6y30RARF3sy0Es1SIK7Y64OpCES1W69JS4AAAAASUVORK5CYII='); } -.snippet > .expand-block-below { +.expand-block-below { background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wQQBjQEQVd5jwAAADhJREFUCNddyTEKADEMA8GVA/7/Z+PGwUp1cGTaYe/tv5lxrLWoKj6SiMzkjZDEG7JtANt0N+ccLrB/KZxXTt7fAAAAAElFTkSuQmCC'); } diff --git a/server/sonar-web/src/main/js/helpers/__tests__/scrolling-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/scrolling-test.ts new file mode 100644 index 00000000000..05fc7d60a82 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/__tests__/scrolling-test.ts @@ -0,0 +1,194 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 { scrollToElement, scrollHorizontally } from '../scrolling'; + +jest.useFakeTimers(); + +describe('scrollToElement', () => { + it('should scroll parent up to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ top: 5, bottom: 20 }); + + const parent = document.createElement('div'); + parent.getBoundingClientRect = mockGetBoundingClientRect({ height: 30, top: 15 }); + parent.scrollTop = 10; + parent.scrollLeft = 12; + parent.appendChild(element); + + document.body.appendChild(parent); + scrollToElement(element, { parent, smooth: false }); + + expect(parent.scrollTop).toEqual(0); + expect(parent.scrollLeft).toEqual(12); + }); + + it('should scroll parent down to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ top: 25, bottom: 50 }); + + const parent = document.createElement('div'); + parent.getBoundingClientRect = mockGetBoundingClientRect({ height: 30, top: 15 }); + parent.scrollTop = 10; + parent.scrollLeft = 12; + parent.appendChild(element); + + document.body.appendChild(parent); + scrollToElement(element, { parent, smooth: false }); + + expect(parent.scrollTop).toEqual(15); + expect(parent.scrollLeft).toEqual(12); + }); + + it('should scroll window down to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ top: 840, bottom: 845 }); + + Object.defineProperty(window, 'innerHeight', { value: 400 }); + window.scrollTo = jest.fn(); + + document.body.appendChild(element); + + scrollToElement(element, { smooth: false }); + + expect(window.scrollTo).toBeCalledWith(0, 445); + }); + + it('should scroll window up to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ top: -10, bottom: 10 }); + + Object.defineProperty(window, 'innerHeight', { value: 50 }); + window.scrollTo = jest.fn(); + + document.body.appendChild(element); + + scrollToElement(element, { smooth: false }); + + expect(window.scrollTo).toBeCalledWith(0, -10); + }); + + it('should scroll window down to element smoothly', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ top: 840, bottom: 845 }); + + Object.defineProperty(window, 'innerHeight', { value: 400 }); + window.scrollTo = jest.fn(); + + document.body.appendChild(element); + + scrollToElement(element, {}); + + jest.runAllTimers(); + + expect(window.scrollTo).toBeCalledTimes(10); + }); +}); + +describe('scrollHorizontally', () => { + it('should scroll parent left to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ left: 25, right: 42 }); + + const parent = document.createElement('div'); + parent.getBoundingClientRect = mockGetBoundingClientRect({ width: 67, left: 46 }); + parent.scrollTop = 12; + parent.scrollLeft = 38; + parent.appendChild(element); + + document.body.appendChild(parent); + + scrollHorizontally(element, { parent, smooth: false }); + + expect(parent.scrollTop).toEqual(12); + expect(parent.scrollLeft).toEqual(17); + }); + + it('should scroll parent right to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ left: 25, right: 99 }); + + const parent = document.createElement('div'); + parent.getBoundingClientRect = mockGetBoundingClientRect({ width: 67, left: 20 }); + parent.scrollTop = 12; + parent.scrollLeft = 20; + parent.appendChild(element); + + document.body.appendChild(parent); + + scrollHorizontally(element, { parent, smooth: false }); + + expect(parent.scrollTop).toEqual(12); + expect(parent.scrollLeft).toEqual(32); + }); + + it('should scroll window right to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ left: 840, right: 845 }); + + Object.defineProperty(window, 'innerWidth', { value: 400 }); + window.scrollTo = jest.fn(); + + document.body.appendChild(element); + + scrollHorizontally(element, { smooth: false }); + + expect(window.scrollTo).toBeCalledWith(445, 0); + }); + + it('should scroll window left to element', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ left: -10, right: 10 }); + + Object.defineProperty(window, 'innerWidth', { value: 50 }); + window.scrollTo = jest.fn(); + + document.body.appendChild(element); + + scrollHorizontally(element, { smooth: false }); + + expect(window.scrollTo).toBeCalledWith(-10, 0); + }); + + it('should scroll window right to element smoothly', () => { + const element = document.createElement('a'); + element.getBoundingClientRect = mockGetBoundingClientRect({ left: 840, right: 845 }); + + Object.defineProperty(window, 'innerWidth', { value: 400 }); + window.scrollTo = jest.fn(); + + document.body.appendChild(element); + + scrollHorizontally(element, {}); + + jest.runAllTimers(); + + expect(window.scrollTo).toBeCalledTimes(10); + }); +}); + +const mockGetBoundingClientRect = (overrides: Partial) => () => ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + ...overrides +}); diff --git a/server/sonar-web/src/main/js/helpers/scrolling.ts b/server/sonar-web/src/main/js/helpers/scrolling.ts index 922b7dd4612..ce6b4c1d956 100644 --- a/server/sonar-web/src/main/js/helpers/scrolling.ts +++ b/server/sonar-web/src/main/js/helpers/scrolling.ts @@ -27,42 +27,53 @@ function isWindow(element: Element | Window): element is Window { return element === window; } -function getScrollPosition(element: Element | Window): number { - return isWindow(element) ? window.pageYOffset : element.scrollTop; +function getScroll(element: Element | Window) { + return isWindow(element) + ? { x: window.pageXOffset, y: window.pageYOffset } + : { x: element.scrollLeft, y: element.scrollTop }; } -function scrollElement(element: Element | Window, position: number): void { +function scrollElement(element: Element | Window, x: number, y: number): void { if (isWindow(element)) { - window.scrollTo(0, position); + window.scrollTo(x, y); } else { - element.scrollTop = position; + element.scrollLeft = x; + element.scrollTop = y; } } -let smoothScrollTop = (y: number, parent: Element | Window) => { - let scrollTop = getScrollPosition(parent); - const scrollingDown = y > scrollTop; - const step = Math.ceil(Math.abs(y - scrollTop) / SCROLLING_STEPS); +let smoothScroll = (target: number, current: number, scroll: (position: number) => void) => { + const positiveDirection = target > current; + const step = Math.ceil(Math.abs(target - current) / SCROLLING_STEPS); let stepsDone = 0; const interval = setInterval(() => { - if (scrollTop === y || SCROLLING_STEPS === stepsDone) { + if (current === target || SCROLLING_STEPS === stepsDone) { clearInterval(interval); } else { let goal; - if (scrollingDown) { - goal = Math.min(y, scrollTop + step); + if (positiveDirection) { + goal = Math.min(target, current + step); } else { - goal = Math.max(y, scrollTop - step); + goal = Math.max(target, current - step); } stepsDone++; - scrollTop = goal; - scrollElement(parent, goal); + current = goal; + scroll(goal); } }, SCROLLING_INTERVAL); }; +smoothScroll = debounce(smoothScroll, SCROLLING_DURATION, { leading: true }); -smoothScrollTop = debounce(smoothScrollTop, SCROLLING_DURATION, { leading: true }); +function smoothScrollTop(position: number, parent: Element | Window) { + const scroll = getScroll(parent); + smoothScroll(position, scroll.y, position => scrollElement(parent, scroll.x, position)); +} + +function smoothScrollLeft(position: number, parent: Element | Window) { + const scroll = getScroll(parent); + smoothScroll(position, scroll.x, position => scrollElement(parent, position, scroll.y)); +} export function scrollToElement( element: Element, @@ -78,7 +89,7 @@ export function scrollToElement( const { top, bottom } = element.getBoundingClientRect(); - const scrollTop = getScrollPosition(parent); + const scroll = getScroll(parent); const height: number = isWindow(parent) ? window.innerHeight @@ -87,20 +98,59 @@ export function scrollToElement( const parentTop = isWindow(parent) ? 0 : parent.getBoundingClientRect().top; if (top - parentTop < opts.topOffset) { - const goal = scrollTop - opts.topOffset + top - parentTop; + const goal = scroll.y - opts.topOffset + top - parentTop; if (opts.smooth) { smoothScrollTop(goal, parent); } else { - scrollElement(parent, goal); + scrollElement(parent, scroll.x, goal); } } if (bottom - parentTop > height - opts.bottomOffset) { - const goal = scrollTop + bottom - parentTop - height + opts.bottomOffset; + const goal = scroll.y + bottom - parentTop - height + opts.bottomOffset; if (opts.smooth) { smoothScrollTop(goal, parent); } else { - scrollElement(parent, goal); + scrollElement(parent, scroll.x, goal); + } + } +} + +export function scrollHorizontally( + element: Element, + options: { + leftOffset?: number; + rightOffset?: number; + parent?: Element; + smooth?: boolean; + } +): void { + const opts = { leftOffset: 0, rightOffset: 0, parent: window, smooth: true, ...options }; + const { parent } = opts; + + const { left, right } = element.getBoundingClientRect(); + + const scroll = getScroll(parent); + + const { left: parentLeft, width } = isWindow(parent) + ? { left: 0, width: window.innerWidth } + : parent.getBoundingClientRect(); + + if (left - parentLeft < opts.leftOffset) { + const goal = scroll.x - opts.leftOffset + left - parentLeft; + if (opts.smooth) { + smoothScrollLeft(goal, parent); + } else { + scrollElement(parent, goal, scroll.y); + } + } + + if (right - parentLeft > width - opts.rightOffset) { + const goal = scroll.x + right - parentLeft - width + opts.rightOffset; + if (opts.smooth) { + smoothScrollLeft(goal, parent); + } else { + scrollElement(parent, goal, scroll.y); } } } -- 2.39.5