]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12112 horizontal scrolling
authorJeremy Davis <jeremy.davis@sonarsource.com>
Mon, 20 May 2019 15:58:59 +0000 (17:58 +0200)
committerSonarTech <sonartech@sonarsource.com>
Thu, 23 May 2019 18:21:11 +0000 (20:21 +0200)
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/SnippetViewer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/styles.css
server/sonar-web/src/main/js/helpers/__tests__/scrolling-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/scrolling.ts

index fe4b99a31fdb63b2d935b19dc869ca004759a366..bdbe163a429e652edf0bd68926ff61932a87db66 100644 (file)
  */
 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<Pr
     return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
   };
 
-  renderLine({
-    displayDuplications,
+  renderSnippet({
     index,
-    issuesForLine,
-    issueLocations,
-    line,
-    snippet,
-    symbols,
-    verticalBuffer
+    issuesByLine,
+    last,
+    locationsByLine,
+    snippet
   }: {
-    displayDuplications: boolean;
     index: number;
-    issuesForLine: T.Issue[];
-    issueLocations: T.LinearIssueLocation[];
-    line: T.SourceLine;
+    issuesByLine: T.IssuesByLine;
+    last: boolean;
+    locationsByLine: { [line: number]: T.LinearIssueLocation[] };
     snippet: T.SourceLine[];
-    symbols: string[];
-    verticalBuffer: number;
   }) {
-    const { openIssuesByLine } = this.state;
-    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 (
-      <Line
+      <SnippetViewer
         branchLike={this.props.branchLike}
-        displayAllIssues={false}
-        displayCoverage={true}
-        displayDuplications={displayDuplications}
-        displayIssues={!isSinkLine || issuesForLine.length > 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 (
-      <div className="source-viewer-code snippet" key={index}>
-        {snippet[0].line > 1 && (
-          <button
-            aria-label={translate('source_viewer.expand_above')}
-            className="expand-block expand-block-above"
-            onClick={expandBlock('up')}
-            type="button">
-            <ExpandSnippetIcon />
-          </button>
-        )}
-        <table className="source-table">
-          <tbody>
-            {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
-              })
-            )}
-          </tbody>
-        </table>
-        {(!lastLine || snippet[snippet.length - 1].line < lastLine) && (
-          <button
-            aria-label={translate('source_viewer.expand_below')}
-            className="expand-block expand-block-below"
-            onClick={expandBlock('down')}
-            type="button">
-            <ExpandSnippetIcon />
-          </button>
-        )}
-      </div>
-    );
-  }
-
   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<Pr
           this.renderSnippet({
             snippet,
             index,
-            issue,
             issuesByLine: last ? issuesByLine : {},
             locationsByLine: last && index === snippets.length - 1 ? locations : {},
             last: last && index === snippets.length - 1
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
new file mode 100644 (file)
index 0000000..75ef569
--- /dev/null
@@ -0,0 +1,237 @@
+/*
+ * 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 { inSnippet, LINES_BELOW_LAST } from './utils';
+import ExpandSnippetIcon from '../../../components/icons-components/ExpandSnippetIcon';
+import Line from '../../../components/SourceViewer/components/Line';
+import { symbolsByLine } 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 { scrollHorizontally } from '../../../helpers/scrolling';
+
+interface Props {
+  branchLike: T.BranchLike | undefined;
+  component: T.SourceViewerFile;
+  duplications?: T.Duplication[];
+  duplicationsByLine?: { [line: number]: number[] };
+  expandBlock: (snippetIndex: number, direction: T.ExpandDirection) => 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<boolean>;
+  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<Props> {
+  node: React.RefObject<HTMLDivElement>;
+
+  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 (
+      <Line
+        branchLike={this.props.branchLike}
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={displayDuplications}
+        displayIssues={!isSinkLine || issuesForLine.length > 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 (
+      <div className="source-viewer-code snippet" ref={this.node}>
+        <table className="source-table">
+          <tbody>
+            {snippet[0].line > 1 && (
+              <tr className="expand-block expand-block-above">
+                <td colSpan={5}>
+                  <button
+                    aria-label={translate('source_viewer.expand_above')}
+                    onClick={this.expandBlock('up')}
+                    type="button">
+                    <ExpandSnippetIcon />
+                  </button>
+                </td>
+              </tr>
+            )}
+            {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) && (
+              <tr className="expand-block expand-block-below">
+                <td colSpan={5}>
+                  <button
+                    aria-label={translate('source_viewer.expand_below')}
+                    onClick={this.expandBlock('down')}
+                    type="button">
+                    <ExpandSnippetIcon />
+                  </button>
+                </td>
+              </tr>
+            )}
+          </tbody>
+        </table>
+      </div>
+    );
+  }
+}
index 6ec7748b8e2a449fecb740f5fd2caf97a1f7af69..a1e7fdd29ff60426541d5b9c170ececa30145f91 100644 (file)
@@ -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<Function>('loadDuplications')(line);
   expect(loadDuplications).toHaveBeenCalledWith('a', line);
 
   wrapper
-    .find('Line')
+    .find('SnippetViewer')
     .first()
-    .prop<Function>('onLinePopupToggle')({ line: 13, name: 'foo' });
+    .prop<Function>('handleLinePopupToggle')({ line: 13, name: 'foo' });
   expect(onLinePopupToggle).toHaveBeenCalledWith({ component: 'a', line: 13, name: 'foo' });
 
   wrapper
-    .find('Line')
+    .find('SnippetViewer')
     .first()
     .prop<Function>('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 (file)
index 0000000..2424539
--- /dev/null
@@ -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<SnippetViewer['props']> = {}) {
+  return shallow<SnippetViewer>(
+    <SnippetViewer
+      branchLike={mockMainBranch()}
+      component={mockSourceViewerFile()}
+      duplications={undefined}
+      duplicationsByLine={undefined}
+      expandBlock={jest.fn()}
+      handleCloseIssues={jest.fn()}
+      handleLinePopupToggle={jest.fn()}
+      handleOpenIssues={jest.fn()}
+      handleSymbolClick={jest.fn()}
+      highlightedLocationMessage={{ index: 0, text: '' }}
+      highlightedSymbols={[]}
+      index={0}
+      issue={mockIssue()}
+      issuesByLine={{}}
+      last={false}
+      linePopup={undefined}
+      loadDuplications={jest.fn()}
+      locations={[]}
+      locationsByLine={{}}
+      onIssueChange={jest.fn()}
+      onIssuePopupToggle={jest.fn()}
+      onLocationSelect={jest.fn()}
+      openIssuesByLine={{}}
+      renderDuplicationPopup={jest.fn()}
+      scroll={jest.fn()}
+      snippet={[]}
+      {...props}
+    />
+  );
+}
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 (file)
index 0000000..e2081bb
--- /dev/null
@@ -0,0 +1,930 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+  className="source-viewer-code snippet"
+>
+  <table
+    className="source-table"
+  >
+    <tbody>
+      <tr
+        className="expand-block expand-block-above"
+      >
+        <td
+          colSpan={5}
+        >
+          <button
+            aria-label="source_viewer.expand_above"
+            onClick={[Function]}
+            type="button"
+          >
+            <ExpandSnippetIcon />
+          </button>
+        </td>
+      </tr>
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="5"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="6"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="7"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <tr
+        className="expand-block expand-block-below"
+      >
+        <td
+          colSpan={5}
+        >
+          <button
+            aria-label="source_viewer.expand_below"
+            onClick={[Function]}
+            type="button"
+          >
+            <ExpandSnippetIcon />
+          </button>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+`;
+
+exports[`should render correctly when at the bottom of the file 1`] = `
+<div
+  className="source-viewer-code snippet"
+>
+  <table
+    className="source-table"
+  >
+    <tbody>
+      <tr
+        className="expand-block expand-block-above"
+      >
+        <td
+          colSpan={5}
+        >
+          <button
+            aria-label="source_viewer.expand_above"
+            onClick={[Function]}
+            type="button"
+          >
+            <ExpandSnippetIcon />
+          </button>
+        </td>
+      </tr>
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="10"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="11"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="12"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="13"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <tr
+        className="expand-block expand-block-below"
+      >
+        <td
+          colSpan={5}
+        >
+          <button
+            aria-label="source_viewer.expand_below"
+            onClick={[Function]}
+            type="button"
+          >
+            <ExpandSnippetIcon />
+          </button>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+`;
+
+exports[`should render correctly when at the top of the file 1`] = `
+<div
+  className="source-viewer-code snippet"
+>
+  <table
+    className="source-table"
+  >
+    <tbody>
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="1"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="2"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="3"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="4"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="5"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="6"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <Line
+        branchLike={
+          Object {
+            "analysisDate": "2018-01-01",
+            "isMain": true,
+            "name": "master",
+          }
+        }
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={true}
+        displayLocationMarkers={true}
+        duplications={Array []}
+        duplicationsCount={0}
+        highlighted={false}
+        issueLocations={Array []}
+        issues={Array []}
+        key="7"
+        last={false}
+        line={
+          Object {
+            "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
+            "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}
+      />
+      <tr
+        className="expand-block expand-block-below"
+      >
+        <td
+          colSpan={5}
+        >
+          <button
+            aria-label="source_viewer.expand_below"
+            onClick={[Function]}
+            type="button"
+          >
+            <ExpandSnippetIcon />
+          </button>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+`;
index 0aa8aeecd544d9d91fcb9e82290ae5a87e83dd79..a29b2181191173f721f301e2dcd0c89d77e3f08e 100644 (file)
   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;
   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 (file)
index 0000000..05fc7d6
--- /dev/null
@@ -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<ClientRect>) => () => ({
+  bottom: 0,
+  height: 0,
+  left: 0,
+  right: 0,
+  top: 0,
+  width: 0,
+  ...overrides
+});
index 922b7dd46129db6c821f090d7385760c276dd957..ce6b4c1d956131d2010604a9de75c1677f224ce0 100644 (file)
@@ -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);
     }
   }
 }