]> source.dussan.org Git - sonarqube.git/commitdiff
MMF-703 More efficient UX for issue multiple locations (#1749)
authorStas Vilchik <stas-vilchik@users.noreply.github.com>
Tue, 7 Mar 2017 08:08:11 +0000 (09:08 +0100)
committerGitHub <noreply@github.com>
Tue, 7 Mar 2017 08:08:11 +0000 (09:08 +0100)
21 files changed:
server/sonar-web/package.json
server/sonar-web/src/main/js/apps/issues/controller.js
server/sonar-web/src/main/js/apps/issues/models/issues.js
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js
server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js
server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js
server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js
server/sonar-web/src/main/js/components/SourceViewer/styles.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/Issue.js
server/sonar-web/src/main/js/components/issue/types.js
server/sonar-web/src/main/js/components/issue/views/changelog-view.js
server/sonar-web/src/main/js/helpers/issues.js
server/sonar-web/src/main/js/helpers/scrolling.js [new file with mode: 0644]
server/sonar-web/src/main/less/components/source.less
server/sonar-web/src/main/less/components/ui.less
server/sonar-web/src/main/less/pages/issues.less
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 1a993a9e7428d8d418052d2508587d0682617caa..2b942f126a4d98b7323bc143184fd491a591210e 100644 (file)
     "lodash": "4.6.1",
     "moment": "2.10.6",
     "numeral": "1.5.3",
-    "react": "15.3.2",
-    "react-addons-shallow-compare": "15.3.2",
-    "react-dom": "15.3.2",
+    "react": "15.4.2",
+    "react-addons-shallow-compare": "15.4.2",
+    "react-dom": "15.4.2",
+    "react-draggable": "2.2.3",
     "react-helmet": "3.1.0",
     "react-modal": "^1.6.4",
     "react-redux": "4.4.1",
     "react-router": "2.8.1",
     "react-router-redux": "4.0.2",
     "react-select": "^1.0.0-rc.2",
+    "react-virtualized": "^9.1.0",
     "redux": "3.3.1",
     "redux-logger": "2.2.1",
     "redux-thunk": "1.0.2",
@@ -82,7 +84,7 @@
     "less-loader": "2.2.3",
     "path-exists": "2.1.0",
     "postcss-loader": "0.8.0",
-    "react-addons-test-utils": "15.3.2",
+    "react-addons-test-utils": "15.4.2",
     "react-dev-utils": "0.2.1",
     "react-transform-hmr": "1.0.4",
     "recursive-readdir": "2.1.0",
index dd03639f5b053d49fed8b84190727d704139af01..156a7f3632a38b0c116aafc5b9c376ed8ec1f0a9 100644 (file)
@@ -63,8 +63,10 @@ export default Controller.extend({
       const issues = that.options.app.list.parseIssues(r);
       this.receiveIssues(issues);
       if (firstPage) {
+        const issues = that.options.app.list.parseIssues(r);
         that.options.app.list.reset(issues);
       } else {
+        const issues = that.options.app.list.parseIssues(r, that.options.app.list.length);
         that.options.app.list.add(issues);
       }
       that.options.app.list.setIndex();
index 6106c81404f0491fa019e16d66f043d9ac505f9a..d7340187c0ad4dc33deb113db8ff4a7be2eabcc8 100644 (file)
@@ -76,10 +76,10 @@ export default Backbone.Collection.extend({
     return issue;
   },
 
-  parseIssues (r) {
+  parseIssues (r, startIndex = 0) {
     const that = this;
     return r.issues.map((issue, index) => {
-      Object.assign(issue, { index });
+      Object.assign(issue, { index: startIndex + index });
       issue = that._injectRelational(issue, r.components, 'component', 'key');
       issue = that._injectRelational(issue, r.components, 'project', 'key');
       issue = that._injectRelational(issue, r.components, 'subProject', 'key');
index 2ad750e71206cdbe03ed2c56a723da21ab3a5188..4a011e163c3ad903d0d59a610e71f89f0731704c 100644 (file)
@@ -23,12 +23,12 @@ import classNames from 'classnames';
 import uniqBy from 'lodash/uniqBy';
 import SourceViewerHeader from './SourceViewerHeader';
 import SourceViewerCode from './SourceViewerCode';
+import SourceViewerIssueLocations from './SourceViewerIssueLocations';
 import CoveragePopupView from '../source-viewer/popups/coverage-popup';
 import DuplicationPopupView from '../source-viewer/popups/duplication-popup';
 import LineActionsPopupView from '../source-viewer/popups/line-actions-popup';
 import SCMPopupView from '../source-viewer/popups/scm-popup';
 import MeasuresOverlay from '../source-viewer/measures-overlay';
-import { TooltipsContainer } from '../mixins/tooltips-mixin';
 import Source from '../source-viewer/source';
 import loadIssues from './helpers/loadIssues';
 import getCoverageStatus from './helpers/getCoverageStatus';
@@ -38,12 +38,21 @@ import {
   locationsByIssueAndLine,
   locationMessagesByIssueAndLine,
   duplicationsByLine,
-  symbolsByLine
+  symbolsByLine,
+  findLocationByIndex
+} from './helpers/indexing';
+import type {
+  LinearIssueLocation,
+  IndexedIssueLocation,
+  IndexedIssueLocationsByIssueAndLine,
+  IndexedIssueLocationMessagesByIssueAndLine
 } from './helpers/indexing';
 import { getComponentForSourceViewer, getSources, getDuplications, getTests } from '../../api/components';
 import { translate } from '../../helpers/l10n';
+import { scrollToElement } from '../../helpers/scrolling';
 import type { SourceLine } from './types';
 import type { Issue } from '../issue/types';
+import './styles.css';
 
 // TODO react-virtualized
 
@@ -81,28 +90,25 @@ type State = {
   highlightedSymbol: string | null,
   issues?: Array<Issue>,
   issuesByLine: { [number]: Array<string> },
-  issueLocationsByLine: { [number]: Array<{ from: number, to: number }> },
-  issueSecondaryLocationsByIssueByLine: {
-    [string]: {
-      [number]: Array<{ from: number, to: number }>
-    }
-  },
-  issueSecondaryLocationMessagesByIssueByLine: {
-    [issueKey: string]: {
-      [line: number]: Array<{ msg: string, index?: number }>
-    }
-  },
+  issueLocationsByLine: { [number]: Array<LinearIssueLocation> },
+  issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine,
+  issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine,
   loading: boolean,
   loadingSourcesAfter: boolean,
   loadingSourcesBefore: boolean,
+  locationsPanelHeight: number,
   notAccessible: boolean,
   notExist: boolean,
+  selectedIssueLocation: IndexedIssueLocation | null,
   sources?: Array<SourceLine>,
   symbolsByLine: { [number]: Array<string> }
 };
 
 const LINES = 500;
 
+const LOCATIONS_PANEL_DEFAULT_HEIGHT = 200;
+const LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY = 'sonarqube.locations.height';
+
 const loadComponent = (key: string): Promise<*> => {
   return getComponentForSourceViewer(key);
 };
@@ -141,9 +147,11 @@ export default class SourceViewerBase extends React.Component {
       loading: true,
       loadingSourcesAfter: false,
       loadingSourcesBefore: false,
+      locationsPanelHeight: this.getInitialLocationsPanelHeight(),
       notAccessible: false,
       notExist: false,
       selectedIssue: props.defaultSelectedIssue || null,
+      selectedIssueLocation: null,
       symbolsByLine: {}
     };
   }
@@ -153,19 +161,33 @@ export default class SourceViewerBase extends React.Component {
     this.fetchComponent();
   }
 
-  componentDidUpdate (prevProps: Props) {
+  componentDidUpdate (prevProps: Props, prevState: State) {
     if (prevProps.component !== this.props.component) {
       this.fetchComponent();
     } else if (this.props.aroundLine != null && prevProps.aroundLine !== this.props.aroundLine &&
       this.isLineOutsideOfRange(this.props.aroundLine)) {
       this.fetchSources();
     }
+
+    if (prevState.selectedIssueLocation !== this.state.selectedIssueLocation &&
+      this.state.selectedIssueLocation != null) {
+      this.scrollToLine(this.state.selectedIssueLocation.line);
+    }
   }
 
   componentWillUnmount () {
     this.mounted = false;
   }
 
+  scrollToLine (line: number) {
+    const lineElement = this.node.querySelector(
+      `.source-line-code[data-line-number="${line}"] .source-line-issue-locations`
+    );
+    if (lineElement) {
+      scrollToElement(lineElement, 125, this.state.locationsPanelHeight + 75);
+    }
+  }
+
   computeCoverageStatus (lines: Array<SourceLine>): Array<SourceLine> {
     return lines.map(line => ({ ...line, coverageStatus: getCoverageStatus(line) }));
   }
@@ -342,6 +364,23 @@ export default class SourceViewerBase extends React.Component {
     });
   };
 
+  getInitialLocationsPanelHeight () {
+    try {
+      const rawValue = window.localStorage.getItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY);
+      if (!rawValue) {
+        return LOCATIONS_PANEL_DEFAULT_HEIGHT;
+      }
+      const intValue = Number(rawValue);
+      return !isNaN(intValue) ? intValue : LOCATIONS_PANEL_DEFAULT_HEIGHT;
+    } catch (e) {
+      return LOCATIONS_PANEL_DEFAULT_HEIGHT;
+    }
+  }
+
+  storeLocationsPanelHeight (height: number) {
+    window.localStorage.setItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY, height);
+  }
+
   openNewWindow = () => {
     const { component } = this.state;
     if (component != null) {
@@ -424,41 +463,57 @@ export default class SourceViewerBase extends React.Component {
     popup.render();
   };
 
+  handleSelectIssueLocation = (flowIndex: number, locationIndex: number) => {
+    this.setState(prevState => {
+      const selectedIssueLocation = findLocationByIndex(
+        prevState.issueSecondaryLocationsByIssueByLine,
+        flowIndex,
+        locationIndex
+      );
+      return { selectedIssueLocation };
+    });
+  };
+
+  handleLocationsPanelResize = (height: number) => {
+    this.setState({ locationsPanelHeight: height });
+    this.storeLocationsPanelHeight(height);
+  };
+
   renderCode (sources: Array<SourceLine>) {
     const hasSourcesBefore = sources.length > 0 && sources[0].line > 1;
     return (
-      <TooltipsContainer>
-        <SourceViewerCode
-          displayAllIssues={this.props.displayAllIssues}
-          duplications={this.state.duplications}
-          duplicationsByLine={this.state.duplicationsByLine}
-          duplicatedFiles={this.state.duplicatedFiles}
-          hasSourcesBefore={hasSourcesBefore}
-          hasSourcesAfter={this.state.hasSourcesAfter}
-          filterLine={this.props.filterLine}
-          highlightedLine={this.state.highlightedLine}
-          highlightedSymbol={this.state.highlightedSymbol}
-          issues={this.state.issues}
-          issuesByLine={this.state.issuesByLine}
-          issueLocationsByLine={this.state.issueLocationsByLine}
-          issueSecondaryLocationsByIssueByLine={this.state.issueSecondaryLocationsByIssueByLine}
-          issueSecondaryLocationMessagesByIssueByLine={this.state.issueSecondaryLocationMessagesByIssueByLine}
-          loadDuplications={this.loadDuplications}
-          loadSourcesAfter={this.loadSourcesAfter}
-          loadSourcesBefore={this.loadSourcesBefore}
-          loadingSourcesAfter={this.state.loadingSourcesAfter}
-          loadingSourcesBefore={this.state.loadingSourcesBefore}
-          onCoverageClick={this.handleCoverageClick}
-          onDuplicationClick={this.handleDuplicationClick}
-          onIssueSelect={this.props.onIssueSelect}
-          onIssueUnselect={this.props.onIssueUnselect}
-          onLineClick={this.handleLineClick}
-          onSCMClick={this.handleSCMClick}
-          onSymbolClick={this.handleSymbolClick}
-          selectedIssue={this.props.selectedIssue}
-          sources={sources}
-          symbolsByLine={this.state.symbolsByLine}/>
-      </TooltipsContainer>
+      <SourceViewerCode
+        displayAllIssues={this.props.displayAllIssues}
+        duplications={this.state.duplications}
+        duplicationsByLine={this.state.duplicationsByLine}
+        duplicatedFiles={this.state.duplicatedFiles}
+        hasSourcesBefore={hasSourcesBefore}
+        hasSourcesAfter={this.state.hasSourcesAfter}
+        filterLine={this.props.filterLine}
+        highlightedLine={this.state.highlightedLine}
+        highlightedSymbol={this.state.highlightedSymbol}
+        issues={this.state.issues}
+        issuesByLine={this.state.issuesByLine}
+        issueLocationsByLine={this.state.issueLocationsByLine}
+        issueSecondaryLocationsByIssueByLine={this.state.issueSecondaryLocationsByIssueByLine}
+        issueSecondaryLocationMessagesByIssueByLine={this.state.issueSecondaryLocationMessagesByIssueByLine}
+        loadDuplications={this.loadDuplications}
+        loadSourcesAfter={this.loadSourcesAfter}
+        loadSourcesBefore={this.loadSourcesBefore}
+        loadingSourcesAfter={this.state.loadingSourcesAfter}
+        loadingSourcesBefore={this.state.loadingSourcesBefore}
+        onCoverageClick={this.handleCoverageClick}
+        onDuplicationClick={this.handleDuplicationClick}
+        onIssueSelect={this.props.onIssueSelect}
+        onIssueUnselect={this.props.onIssueUnselect}
+        onLineClick={this.handleLineClick}
+        onSCMClick={this.handleSCMClick}
+        onSelectLocation={this.handleSelectIssueLocation}
+        onSymbolClick={this.handleSymbolClick}
+        selectedIssue={this.props.selectedIssue}
+        selectedIssueLocation={this.state.selectedIssueLocation}
+        sources={sources}
+        symbolsByLine={this.state.symbolsByLine}/>
     );
   }
 
@@ -481,6 +536,10 @@ export default class SourceViewerBase extends React.Component {
 
     const className = classNames('source-viewer', { 'source-duplications-expanded': this.state.displayDuplications });
 
+    const selectedIssueObj = this.props.selectedIssue && this.state.issues != null ?
+      this.state.issues.find(issue => issue.key === this.props.selectedIssue) :
+      null;
+
     return (
       <div className={className} ref={node => this.node = node}>
         <SourceViewerHeader
@@ -493,6 +552,14 @@ export default class SourceViewerBase extends React.Component {
           </div>
         )}
         {this.state.sources != null && this.renderCode(this.state.sources)}
+        {selectedIssueObj != null && selectedIssueObj.flows.length > 0 && (
+          <SourceViewerIssueLocations
+            height={this.state.locationsPanelHeight}
+            issue={selectedIssueObj}
+            onResize={this.handleLocationsPanelResize}
+            onSelectLocation={this.handleSelectIssueLocation}
+            selectedLocation={this.state.selectedIssueLocation}/>
+        )}
       </div>
     );
   }
index 32092dd47c510674b9032c2466e29d2e019d5963..fb98c06ab157182e300f76731f4ee89eec236e56 100644 (file)
 // @flow
 import React from 'react';
 import SourceViewerLine from './SourceViewerLine';
+import { TooltipsContainer } from '../mixins/tooltips-mixin';
 import { translate } from '../../helpers/l10n';
 import type { Duplication, SourceLine } from './types';
 import type { Issue } from '../issue/types';
+import type {
+  LinearIssueLocation,
+  IndexedIssueLocation,
+  IndexedIssueLocationsByIssueAndLine,
+  IndexedIssueLocationMessagesByIssueAndLine
+} from './helpers/indexing';
 
 const EMPTY_ARRAY = [];
 
@@ -32,7 +39,7 @@ const ZERO_LINE = {
   line: 0
 };
 
-export default class SourceViewerCode extends React.Component {
+export default class SourceViewerCode extends React.PureComponent {
   props: {
     displayAllIssues: boolean,
     duplications?: Array<Duplication>,
@@ -45,17 +52,9 @@ export default class SourceViewerCode extends React.Component {
     highlightedSymbol: string | null,
     issues: Array<Issue>,
     issuesByLine: { [number]: Array<string> },
-    issueLocationsByLine: { [number]: Array<{ from: number, to: number }> },
-    issueSecondaryLocationsByIssueByLine: {
-      [string]: {
-        [number]: Array<{ from: number, to: number }>
-      }
-    },
-    issueSecondaryLocationMessagesByIssueByLine: {
-      [issueKey: string]: {
-        [line: number]: Array<{ msg: string, index?: number }>
-      }
-    },
+    issueLocationsByLine: { [number]: Array<LinearIssueLocation> },
+    issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine,
+    issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine,
     loadDuplications: (SourceLine, HTMLElement) => void,
     loadSourcesAfter: () => void,
     loadSourcesBefore: () => void,
@@ -67,8 +66,10 @@ export default class SourceViewerCode extends React.Component {
     onIssueUnselect: () => void,
     onLineClick: (number, HTMLElement) => void,
     onSCMClick: (SourceLine, HTMLElement) => void,
+    onSelectLocation: (flowIndex: number, locationIndex: number) => void,
     onSymbolClick: (string) => void,
     selectedIssue: string | null,
+    selectedIssueLocation: IndexedIssueLocation | null,
     sources: Array<SourceLine>,
     symbolsByLine: { [number]: Array<string> }
   };
@@ -133,6 +134,14 @@ export default class SourceViewerCode extends React.Component {
     const optimizedSelectedIssue = selectedIssue != null && issuesForLine.includes(selectedIssue) ?
       selectedIssue : null;
 
+    const { selectedIssueLocation } = this.props;
+    const optimizedSelectedIssueLocation =
+      selectedIssueLocation != null &&
+        secondaryIssueLocations.some(location =>
+          location.flowIndex === selectedIssueLocation.flowIndex &&
+          location.locationIndex === selectedIssueLocation.locationIndex
+        ) ? selectedIssueLocation : null;
+
     return (
       <SourceViewerLine
         displayAllIssues={this.props.displayAllIssues}
@@ -157,10 +166,12 @@ export default class SourceViewerCode extends React.Component {
         onIssueSelect={this.props.onIssueSelect}
         onIssueUnselect={this.props.onIssueUnselect}
         onSCMClick={this.props.onSCMClick}
+        onSelectLocation={this.props.onSelectLocation}
         onSymbolClick={this.props.onSymbolClick}
         secondaryIssueLocations={secondaryIssueLocations}
         secondaryIssueLocationMessages={secondaryIssueLocationMessages}
-        selectedIssue={optimizedSelectedIssue}/>
+        selectedIssue={optimizedSelectedIssue}
+        selectedIssueLocation={optimizedSelectedIssueLocation}/>
     );
   };
 
@@ -191,16 +202,18 @@ export default class SourceViewerCode extends React.Component {
           </div>
         )}
 
-        <table className="source-table">
-          <tbody>
-            {hasFileIssues && (
-              this.renderLine(ZERO_LINE, -1, hasCoverage, hasDuplications, displayFiltered, hasIssues)
-            )}
-            {sources.map((line, index) => (
-              this.renderLine(line, index, hasCoverage, hasDuplications, displayFiltered, hasIssues)
-            ))}
-          </tbody>
-        </table>
+        <TooltipsContainer>
+          <table className="source-table">
+            <tbody>
+              {hasFileIssues && (
+                this.renderLine(ZERO_LINE, -1, hasCoverage, hasDuplications, displayFiltered, hasIssues)
+              )}
+              {sources.map((line, index) => (
+                this.renderLine(line, index, hasCoverage, hasDuplications, displayFiltered, hasIssues)
+              ))}
+            </tbody>
+          </table>
+        </TooltipsContainer>
 
         {this.props.hasSourcesAfter && (
           <div className="source-viewer-more-code">
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js
new file mode 100644 (file)
index 0000000..d488134
--- /dev/null
@@ -0,0 +1,319 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+// @flow
+import React from 'react';
+import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer';
+import { DraggableCore } from 'react-draggable';
+import classNames from 'classnames';
+import throttle from 'lodash/throttle';
+import { scrollToElement } from '../../helpers/scrolling';
+import { translate } from '../../helpers/l10n';
+import type { Issue, FlowLocation } from '../issue/types';
+import type { IndexedIssueLocation } from './helpers/indexing';
+
+type Props = {
+  height: number,
+  issue: Issue,
+  onResize: (height: number) => void,
+  onSelectLocation: (flowIndex: number, locationIndex: number) => void,
+  selectedLocation: IndexedIssueLocation | null
+};
+
+type State = {
+  fixed: boolean,
+  locationBlink: boolean
+};
+
+export default class SourceViewerIssueLocations extends React.Component {
+  fixedNode: HTMLElement;
+  locations: { [string]: HTMLElement } = {};
+  node: HTMLElement;
+  props: Props;
+  rootNode: HTMLElement;
+  state: State;
+
+  constructor (props: Props) {
+    super(props);
+    this.state = { fixed: true, locationBlink: false };
+    this.handleScroll = throttle(this.handleScroll, 50);
+  }
+
+  componentDidMount () {
+    this.bindShortcuts();
+    this.listenScroll();
+  }
+
+  componentWillReceiveProps (nextProps: Props) {
+    /* eslint-disable no-console */
+    console.log('foo');
+
+    if (nextProps.selectedLocation !== this.props.selectedLocation) {
+      this.setState({ locationBlink: false });
+    }
+  }
+
+  componentDidUpdate (prevProps: Props) {
+    if (
+      prevProps.selectedLocation !== this.props.selectedLocation &&
+      this.props.selectedLocation != null
+    ) {
+      this.scrollToLocation();
+    }
+  }
+
+  componentWillUnmount () {
+    this.unbindShortcuts();
+    this.unlistenScroll();
+  }
+
+  bindShortcuts () {
+    document.addEventListener('keydown', this.handleKeyPress);
+  }
+
+  unbindShortcuts () {
+    document.removeEventListener('keydown', this.handleKeyPress);
+  }
+
+  listenScroll () {
+    window.addEventListener('scroll', this.handleScroll);
+  }
+
+  unlistenScroll () {
+    window.removeEventListener('scroll', this.handleScroll);
+  }
+
+  blinkLocation = () => {
+    this.setState({ locationBlink: true });
+    setTimeout(() => this.setState({ locationBlink: false }), 1000);
+  };
+
+  handleScroll = () => {
+    const rootNodeTop = this.rootNode.getBoundingClientRect().top;
+    const fixedNodeRect = this.fixedNode.getBoundingClientRect();
+    const fixedNodeTop = fixedNodeRect.top;
+    const fixedNodeBottom = fixedNodeRect.bottom;
+    this.setState((state: State) => {
+      if (state.fixed) {
+        if (rootNodeTop <= fixedNodeTop) {
+          return { fixed: false };
+        }
+      } else if (fixedNodeBottom >= window.innerHeight) {
+        return { fixed: true };
+      }
+    });
+  };
+
+  handleDrag = (e: Event, data: { deltaY: number }) => {
+    let height = this.props.height - data.deltaY;
+    if (height < 100) {
+      height = 100;
+    }
+    if (height > window.innerHeight / 2) {
+      height = window.innerHeight / 2;
+    }
+    this.props.onResize(height);
+  };
+
+  scrollToLocation () {
+    const { selectedLocation } = this.props;
+    if (selectedLocation != null) {
+      const key = `${selectedLocation.flowIndex}-${selectedLocation.locationIndex}`;
+      const locationElement = this.locations[key];
+      if (locationElement) {
+        scrollToElement(locationElement, 15, 15, this.node);
+      }
+    }
+  }
+
+  handleSelectPrev () {
+    const { issue, selectedLocation } = this.props;
+    if (!selectedLocation) {
+      if (issue.flows.length > 0) {
+        // move to the first location of the first flow
+        this.props.onSelectLocation(0, 0);
+      }
+    } else {
+      const currentFlow = issue.flows[selectedLocation.flowIndex];
+      if (
+        currentFlow.locations != null &&
+        currentFlow.locations.length > selectedLocation.locationIndex + 1
+      ) {
+        // move to the next location for the same flow
+        this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex + 1);
+      } else if (selectedLocation.flowIndex > 0) {
+        // move to the first location of the previous flow
+        this.props.onSelectLocation(selectedLocation.flowIndex - 1, 0);
+      } else {
+        this.blinkLocation();
+      }
+    }
+  }
+
+  handleSelectNext () {
+    const { issue, selectedLocation } = this.props;
+    if (!selectedLocation) {
+      if (issue.flows.length > 0) {
+        // move to the last location of the first flow
+        const firstFlow = issue.flows[0];
+        if (firstFlow.locations != null) {
+          this.props.onSelectLocation(0, firstFlow.locations.length - 1);
+        }
+      }
+    } else if (selectedLocation.locationIndex > 0) {
+      // move to the previous location for the same flow
+      this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex - 1);
+    } else if (issue.flows.length > selectedLocation.flowIndex + 1) {
+      // move to the last location of the next flow
+      const nextFlow = issue.flows[selectedLocation.flowIndex + 1];
+      if (nextFlow.locations) {
+        this.props.onSelectLocation(selectedLocation.flowIndex + 1, nextFlow.locations.length - 1);
+      }
+    } else {
+      this.blinkLocation();
+    }
+  }
+
+  handleKeyPress = (e: Object) => {
+    const tagName = e.target.tagName.toUpperCase();
+    const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON';
+
+    if (shouldHandle) {
+      const selectNext = e.keyCode === 40 && e.altKey;
+      const selectPrev = e.keyCode === 38 && e.altKey;
+
+      if (selectNext) {
+        e.preventDefault();
+        this.handleSelectNext();
+      }
+
+      if (selectPrev) {
+        e.preventDefault();
+        this.handleSelectPrev();
+      }
+    }
+  };
+
+  reverseLocations (locations: Array<*>) {
+    return [...locations].reverse();
+  }
+
+  isLocationSelected (flowIndex: number, locationIndex: number) {
+    const { selectedLocation } = this.props;
+    if (selectedLocation == null) {
+      return false;
+    } else {
+      return selectedLocation.flowIndex === flowIndex &&
+        selectedLocation.locationIndex === locationIndex;
+    }
+  }
+
+  handleLocationClick (flowIndex: number, locationIndex: number, e: SyntheticInputEvent) {
+    e.preventDefault();
+    this.props.onSelectLocation(flowIndex, locationIndex);
+  }
+
+  renderLocation = (
+    location: FlowLocation,
+    flowIndex: number,
+    locationIndex: number,
+    locations: Array<*>
+  ) => {
+    const displayIndex = locations.length > 1;
+    const line = location.textRange ? location.textRange.startLine : null;
+    const key = `${flowIndex}-${locationIndex}`;
+    // note that locations order is reversed
+    const selected = this.isLocationSelected(flowIndex, locations.length - locationIndex - 1);
+
+    return (
+      <li key={key} ref={node => this.locations[key] = node} className="spacer-bottom">
+        {line != null && <code className="source-issue-locations-line">L{line}</code>}
+        <a
+          className={classNames('issue-location-message', 'flash', 'flash-heavy', {
+            selected,
+            in: selected && this.state.locationBlink
+          })}
+          href="#"
+          onClick={this.handleLocationClick.bind(
+            this,
+            flowIndex,
+            locations.length - locationIndex - 1
+          )}>
+          {displayIndex && <strong>{locationIndex + 1}: </strong>}
+          {location.msg}
+        </a>
+      </li>
+    );
+  };
+
+  render () {
+    const { flows } = this.props.issue;
+    const { height } = this.props;
+
+    const className = classNames('source-issue-locations-panel', { fixed: this.state.fixed });
+
+    return (
+      <AutoSizer disableHeight={true}>
+        {({ width }) => (
+          <div
+            ref={node => this.rootNode = node}
+            className="source-issue-locations"
+            style={{ width, height }}>
+            <div
+              ref={node => this.fixedNode = node}
+              className={className}
+              style={{ width, height }}>
+              <header className="source-issue-locations-header"/>
+              <div className="source-issue-locations-shortcuts">
+                <span className="shortcut-button">Alt</span>
+                {' + '}
+                <span className="shortcut-button">&uarr;</span>
+                {' '}
+                <span className="shortcut-button">&darr;</span>
+                {' '}
+                {translate('source_viewer.to_navigate_issue_locations')}
+              </div>
+              <ul
+                ref={node => this.node = node}
+                className="source-issue-locations-list"
+                style={{ height: height - 15 }}>
+                {flows.map(
+                  (flow, flowIndex) =>
+                    flow.locations != null &&
+                      this
+                          .reverseLocations(flow.locations)
+                          .map((location, locationIndex) =>
+                          this.renderLocation(
+                            location,
+                            flowIndex,
+                            locationIndex,
+                            flow.locations || []
+                          ))
+                )}
+              </ul>
+              <DraggableCore axis="y" onDrag={this.handleDrag} offsetParent={document.body}>
+                <div className="workspace-viewer-resize"/>
+              </DraggableCore>
+            </div>
+          </div>
+        )}
+      </AutoSizer>
+    );
+  }
+}
index 72cb0d5c053317c79851d11407ed8b2d41ee71b4..5a53275c7af6b779d919915814483fefb3607354 100644 (file)
@@ -26,6 +26,7 @@ import SourceViewerIssuesIndicator from './SourceViewerIssuesIndicator';
 import { translate } from '../../helpers/l10n';
 import { splitByTokens, highlightSymbol, highlightIssueLocations, generateHTML } from './helpers/highlight';
 import type { SourceLine } from './types';
+import type { LinearIssueLocation, IndexedIssueLocation, IndexedIssueLocationMessage } from './helpers/indexing';
 
 type Props = {
   displayAllIssues: boolean,
@@ -39,7 +40,7 @@ type Props = {
   filtered: boolean | null,
   highlighted: boolean,
   highlightedSymbol: string | null,
-  issueLocations: Array<{ from: number, to: number }>,
+  issueLocations: Array<LinearIssueLocation>,
   issues: Array<string>,
   line: SourceLine,
   loadDuplications: (SourceLine, HTMLElement) => void,
@@ -49,12 +50,13 @@ type Props = {
   onIssueSelect: (string) => void,
   onIssueUnselect: () => void,
   onSCMClick: (SourceLine, HTMLElement) => void,
+  onSelectLocation: (flowIndex: number, locationIndex: number) => void,
   onSymbolClick: (string) => void,
   selectedIssue: string | null,
+  secondaryIssueLocations: Array<IndexedIssueLocation>,
   // $FlowFixMe
-  secondaryIssueLocations: Array<{ from: number, to: number }>,
-  // $FlowFixMe
-  secondaryIssueLocationMessages: Array<{ msg: string, index?: number }>
+  secondaryIssueLocationMessages: Array<IndexedIssueLocationMessage>,
+  selectedIssueLocation: IndexedIssueLocation | null
 };
 
 type State = {
@@ -77,16 +79,7 @@ export default class SourceViewerLine extends React.PureComponent {
     this.detachEvents();
   }
 
-  componentDidUpdate (prevProps: Props) {
-    /* eslint-disable no-console */
-    console.log('re-render line', this.props.line.line, 'because they are not equal:');
-    Object.keys(this.props).forEach(prop => {
-      if (this.props[prop] !== prevProps[prop]) {
-        console.log(prop);
-      }
-    });
-    console.log('');
-
+  componentDidUpdate () {
     this.attachEvents();
   }
 
@@ -276,21 +269,49 @@ export default class SourceViewerLine extends React.PureComponent {
     );
   }
 
-  renderSecondaryIssueLocationMessages (locationMessages: Array<{ msg: string, index?: number }>) {
+  isSecondaryIssueLocationSelected (location: IndexedIssueLocation | IndexedIssueLocationMessage) {
+    const { selectedIssueLocation } = this.props;
+    if (selectedIssueLocation == null) {
+      return false;
+    } else {
+      return selectedIssueLocation.flowIndex === location.flowIndex &&
+        selectedIssueLocation.locationIndex === location.locationIndex;
+    }
+  }
+
+  handleLocationMessageClick (flowIndex: number, locationIndex: number, e: SyntheticInputEvent) {
+    e.preventDefault();
+    this.props.onSelectLocation(flowIndex, locationIndex);
+  }
+
+  renderSecondaryIssueLocationMessage = (location: IndexedIssueLocationMessage) => {
+    const className = classNames('source-viewer-issue-location', 'issue-location-message', {
+      'selected': this.isSecondaryIssueLocationSelected(location)
+    });
+
     const limitString = (str: string) => (
       str.length > 30 ? str.substr(0, 30) + '...' : str
     );
 
+    return (
+      <a
+        key={`${location.flowIndex}-${location.locationIndex}`}
+        href="#"
+        className={className}
+        title={location.msg}
+        onClick={e => this.handleLocationMessageClick(location.flowIndex, location.locationIndex, e)}>
+        {location.index && (
+          <strong>{location.index}: </strong>
+        )}
+        {limitString(location.msg)}
+      </a>
+    );
+  };
+
+  renderSecondaryIssueLocationMessages (locations: Array<IndexedIssueLocationMessage>) {
     return (
       <div className="source-line-issue-locations">
-        {locationMessages.map((locationMessage, index) => (
-          <div key={index} className="source-viewer-issue-location" title={locationMessage.msg}>
-            {locationMessage.index && (
-              <strong>{locationMessage.index}: </strong>
-            )}
-            {limitString(locationMessage.msg)}
-          </div>
-        ))}
+        {locations.map(this.renderSecondaryIssueLocationMessage)}
       </div>
     );
   }
@@ -312,7 +333,19 @@ export default class SourceViewerLine extends React.PureComponent {
     }
 
     if (secondaryIssueLocations) {
-      tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'source-line-code-secondary-issue');
+      const linearLocations = secondaryIssueLocations.map(location => ({
+        from: location.from,
+        line: location.line,
+        to: location.to
+      }));
+      tokens = highlightIssueLocations(tokens, linearLocations, 'issue-location');
+      const { selectedIssueLocation } = this.props;
+      if (selectedIssueLocation != null) {
+        const x = secondaryIssueLocations.find(location => this.isSecondaryIssueLocationSelected(location));
+        if (x) {
+          tokens = highlightIssueLocations(tokens, [x], 'selected');
+        }
+      }
     }
 
     const finalCode = generateHTML(tokens);
index 0adc3f0d31fa299339fb043f44dea9669da02c04..c0ca46bb9d19d15a46819fd16d78b03edc8430cb 100644 (file)
@@ -19,6 +19,7 @@
  */
 // @flow
 import escapeHtml from 'escape-html';
+import type { LinearIssueLocation } from './indexing';
 
 type Token = { className: string, text: string };
 type Tokens = Array<Token>;
@@ -78,7 +79,7 @@ const part = (str: string, from: number, to: number, acc: number): string => {
  */
 export const highlightIssueLocations = (
   tokens: Tokens,
-  issueLocations: Array<{ from: number, to: number }>,
+  issueLocations: Array<LinearIssueLocation>,
   rootClassName: string = ISSUE_LOCATION_CLASS
 ): Tokens => {
   issueLocations.forEach(location => {
index a9016ef0c7c68b9ed30c75dcc25bffd42044ad26..dcfe2f273faa402b3cab4433c1a0d7ceccd19578 100644 (file)
@@ -23,6 +23,39 @@ import { getLinearLocations, getIssueLocations } from './issueLocations';
 import type { Issue } from '../../issue/types';
 import type { SourceLine } from '../types';
 
+export type LinearIssueLocation = {
+  from: number,
+  line: number,
+  to: number
+};
+
+export type IndexedIssueLocation = {
+  flowIndex: number,
+  from: number,
+  line: number,
+  locationIndex: number,
+  to: number,
+};
+
+export type IndexedIssueLocationMessage = {
+  flowIndex: number,
+  locationIndex: number,
+  msg: string
+};
+
+export type IndexedIssueLocationsByIssueAndLine = {
+  [issueKey: string]: {
+    // $FlowFixMe
+    [lineNumber: number]: Array<IndexedIssueLocation>
+  }
+};
+
+export type IndexedIssueLocationMessagesByIssueAndLine = {
+  [issueKey: string]: {
+    [lineNumber: number]: Array<IndexedIssueLocationMessage>
+  }
+};
+
 export const issuesByLine = (issues: Array<Issue>) => {
   const index = {};
   issues.forEach(issue => {
@@ -35,7 +68,7 @@ export const issuesByLine = (issues: Array<Issue>) => {
   return index;
 };
 
-export const locationsByLine = (issues: Array<Issue>) => {
+export const locationsByLine = (issues: Array<Issue>): { [number]: Array<LinearIssueLocation> } => {
   const index = {};
   issues.forEach(issue => {
     getLinearLocations(issue.textRange).forEach(location => {
@@ -48,7 +81,7 @@ export const locationsByLine = (issues: Array<Issue>) => {
   return index;
 };
 
-export const locationsByIssueAndLine = (issues: Array<Issue>) => {
+export const locationsByIssueAndLine = (issues: Array<Issue>): IndexedIssueLocationsByIssueAndLine => {
   const index = {};
   issues.forEach(issue => {
     const byLine = {};
@@ -57,7 +90,11 @@ export const locationsByIssueAndLine = (issues: Array<Issue>) => {
         if (!(linearLocation.line in byLine)) {
           byLine[linearLocation.line] = [];
         }
-        byLine[linearLocation.line].push({ from: linearLocation.from, to: linearLocation.to });
+        byLine[linearLocation.line].push({
+          ...linearLocation,
+          flowIndex: location.flowIndex,
+          locationIndex: location.locationIndex
+        });
       });
     });
     index[issue.key] = byLine;
@@ -65,7 +102,7 @@ export const locationsByIssueAndLine = (issues: Array<Issue>) => {
   return index;
 };
 
-export const locationMessagesByIssueAndLine = (issues: Array<Issue>) => {
+export const locationMessagesByIssueAndLine = (issues: Array<Issue>): IndexedIssueLocationMessagesByIssueAndLine => {
   const index = {};
   issues.forEach(issue => {
     const byLine = {};
@@ -74,7 +111,7 @@ export const locationMessagesByIssueAndLine = (issues: Array<Issue>) => {
       if (!(line in byLine)) {
         byLine[line] = [];
       }
-      byLine[line].push({ msg: location.msg, index: location.index });
+      byLine[line].push(location);
     });
     index[issue.key] = byLine;
   });
@@ -117,3 +154,23 @@ export const symbolsByLine = (sources: Array<SourceLine>) => {
   });
   return index;
 };
+
+export const findLocationByIndex = (
+  locations: IndexedIssueLocationsByIssueAndLine,
+  flowIndex: number,
+  locationIndex: number) => {
+  const issueKeys = Object.keys(locations);
+  for (const issueKey of issueKeys) {
+    const lineNumbers = Object.keys(locations[issueKey]);
+    for (let lineIndex = 0; lineIndex < lineNumbers.length; lineIndex++) {
+      for (let i = 0; i < locations[issueKey][lineNumbers[lineIndex]].length; i++) {
+        const location = locations[issueKey][lineNumbers[lineIndex]][i];
+        if (location.flowIndex === flowIndex && location.locationIndex === locationIndex) {
+          return location;
+        }
+      }
+    }
+  }
+
+  return null;
+};
index d2c8991fc3cddeec7be1e12ba13eb953cf138157..70af97af1a568ddd38693e093a99ff4dafcbafc7 100644 (file)
@@ -36,18 +36,22 @@ export const getLinearLocations = (textRange?: TextRange): Array<{ line: number,
   return locations;
 };
 
-export const getIssueLocations = (issue: Issue): Array<{ msg: string, textRange: TextRange, index?: number }> => {
-  const primaryLocation = {
-    msg: issue.message,
-    textRange: issue.textRange
-  };
-  const allLocations = [primaryLocation];
-  issue.flows.forEach(({ locations }) => {
+export const getIssueLocations = (issue: Issue): Array<{
+  msg: string,
+  flowIndex: number,
+  locationIndex: number,
+  textRange?: TextRange,
+  index?: number
+}> => {
+  const allLocations = [];
+  issue.flows.forEach(({ locations }, flowIndex) => {
     if (locations) {
       const locationsCount = locations.length;
       locations.forEach((location, index) => {
         const flowLocation = {
           ...location,
+          flowIndex,
+          locationIndex: index,
           // set index only for real flows, do not set for just secondary locations
           index: locationsCount > 1 ? locationsCount - index : undefined
         };
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/styles.css b/server/sonar-web/src/main/js/components/SourceViewer/styles.css
new file mode 100644 (file)
index 0000000..6371f9e
--- /dev/null
@@ -0,0 +1,92 @@
+.source-issue-locations {
+  position: relative;
+}
+
+.source-issue-locations-panel {
+  background-color: #fff;
+  box-shadow: 0 -6px 12px rgba(0, 0, 0, .175);
+}
+
+.source-issue-locations-panel.fixed {
+  position: fixed;
+  bottom: 0;
+  margin-left: -1px;
+  border-left: 1px solid #e6e6e6;
+  border-right: 1px solid #e6e6e6;
+}
+
+.source-issue-locations-header {
+  height: 15px;
+  padding: 0 15px;
+  box-sizing: border-box;
+  background-color: #404040;
+  color: #fff;
+}
+
+.source-issue-locations-shortcuts {
+  position: absolute;
+  top: 18px;
+  right: 18px;
+  padding: 6px;
+  background-color: #fff;
+  color: #777;
+  font-size: 11px;
+}
+
+.source-issue-locations-list {
+  height: 185px;
+  padding: 15px;
+  box-sizing: border-box;
+  overflow: auto;
+}
+
+.source-issue-locations-line {
+  display: inline-block;
+  min-width: 25px;
+  margin-right: 15px;
+  color: #777;
+  font-size: 12px;
+  text-align: right;
+}
+
+.issue-location,
+.issue-location-message {
+  display: inline-block;
+  vertical-align: top;
+  line-height: 16px;
+  height: 17px;
+  border: 1px solid #ffeaea;
+  box-sizing: border-box;
+  background-color: #ffeaea;
+}
+
+.issue-location {
+  /* nothing so far */
+}
+
+.issue-location.highlighted {
+  border-color: #e1e1f2;
+  background-color: #e1e1f2;
+}
+
+.issue-location.selected {
+  border-color: #f4b1b0;
+  background-color: #f4b1b0;
+}
+
+.issue-location-message {
+  padding: 0 10px;
+  color: #444 !important;
+  font-size: 12px;
+  white-space: nowrap;
+  transition: all 0.3s ease;
+}
+
+.issue-location-message:hover {
+  border-color: #f4b1b0;
+  background-color: #f4b1b0;
+}
+
+.issue-location-message.selected {
+  border-color: #dd4040;
+}
\ No newline at end of file
index c437b8f41af2cda48c5217ef07eef754e104ba58..ce7781caabb0832bc036e3d4df711e145988e89e 100644 (file)
@@ -42,6 +42,10 @@ class Issue extends React.PureComponent {
   node: HTMLElement;
   props: Props;
 
+  static defaultProps = {
+    selected: false
+  };
+
   componentDidMount () {
     this.renderIssueView();
     if (this.props.selected) {
index dd0bbc1d2e627721b6354942c8218a14b6cfdf40..9d3982f8f2859620db2873d602c8e9a8159fd2b9 100644 (file)
@@ -25,13 +25,15 @@ export type TextRange = {
   endOffset: number
 };
 
+export type FlowLocation = {
+  msg: string,
+  textRange?: TextRange
+};
+
 export type Issue = {
   key: string,
   flows: Array<{
-    locations?: Array<{
-      msg: string,
-      textRange?: TextRange
-    }>
+    locations?: Array<FlowLocation>
   }>,
   line?: number,
   message: string,
index 95c7c6da6648369ca1d49298d47035cee5990464..f57d968f14df2dcfeb17db4854e23969c5403ef9 100644 (file)
@@ -34,4 +34,3 @@ export default PopupView.extend({
     };
   }
 });
-
index 3a1e509f790d98387249433890dd959cfded1c4a..6410fe3e25c1fc67344a3b75918020b0baa1e3c9 100644 (file)
@@ -41,6 +41,13 @@ type RawIssue = {
   author: string,
   comments?: Array<Comment>,
   component: string,
+  flows: Array<{
+    locations: Array<{
+      msg: string,
+      textRange: TextRange
+    }>
+  }>,
+  key: string,
   line?: number,
   project: string,
   rule: string,
diff --git a/server/sonar-web/src/main/js/helpers/scrolling.js b/server/sonar-web/src/main/js/helpers/scrolling.js
new file mode 100644 (file)
index 0000000..e456eb3
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+// @flow
+import debounce from 'lodash/debounce';
+
+const SCROLLING_DURATION = 100;
+const SCROLLING_INTERVAL = 10;
+const SCROLLING_STEPS = SCROLLING_DURATION / SCROLLING_INTERVAL;
+
+const getScrollPosition = (element: HTMLElement): number => {
+  return element === window ? window.scrollY : element.scrollTop;
+};
+
+const scrollElement = (element: HTMLElement, position: number) => {
+  if (element === window) {
+    window.scrollTo(0, position);
+  } else {
+    element.scrollTop = position;
+  }
+};
+
+let smoothScrollTop = (y: number, parent) => {
+  const scrollTop = getScrollPosition(parent);
+  const scrollingDown = y > scrollTop;
+  const step = Math.ceil(Math.abs(y - scrollTop) / SCROLLING_STEPS);
+  let stepsDone = 0;
+
+  const interval = setInterval(() => {
+    const scrollTop = getScrollPosition(parent);
+    if (scrollTop === y || SCROLLING_STEPS === stepsDone) {
+      clearInterval(interval);
+    } else {
+      let goal;
+      if (scrollingDown) {
+        goal = Math.min(y, scrollTop + step);
+      } else {
+        goal = Math.max(y, scrollTop - step);
+      }
+      stepsDone++;
+      scrollElement(parent, goal);
+    }
+  }, SCROLLING_INTERVAL);
+};
+
+smoothScrollTop = debounce(smoothScrollTop, SCROLLING_DURATION, { leading: true });
+
+export const scrollToElement = (
+  element: HTMLElement,
+  topOffset: number = 0,
+  bottomOffset: number = 0,
+  parent: HTMLElement = window
+) => {
+  const { top, bottom } = element.getBoundingClientRect();
+  const scrollTop = getScrollPosition(parent);
+  const height: number = parent === window ? window.innerHeight : parent.getBoundingClientRect().height;
+
+  const parentTop = parent === window ? 0 : parent.getBoundingClientRect().top;
+
+  if (top - parentTop < topOffset) {
+    smoothScrollTop(scrollTop - topOffset + top - parentTop, parent);
+  }
+
+  if (bottom - parentTop > height - bottomOffset) {
+    smoothScrollTop(scrollTop + bottom - parentTop - height + bottomOffset, parent);
+  }
+};
index 9a89d87959ac3a61410dda5b4ef362842e0f2023..07485b239d2b56141cd0a0e4b9c843f2143a8b71 100644 (file)
   background-position: bottom;
 }
 
-.source-line-code-secondary-issue {
-  display: inline-block;
-  background-color: @issueBackgroundColor;
-
-  &.highlighted {
-    background-color: mix(#B3D4FF, @issueBackgroundColor, 40%);
-  }
-}
-
 .source-meta {
   vertical-align: top;
   width: 1px;
 }
 
 .source-viewer-issue-location {
-  float: right;
   max-width: 200px;
-  height: @source-line-height - 1px;
-  line-height: @source-line-height - 1px;
   margin-right: 10px;
-  padding: 0 10px;
-  background-color: #ffeaea;
-  font-size: 12px;
   .text-ellipsis;
 }
 
 .source-line-issue-locations {
   float: right;
   margin-right: -10px;
-  padding-bottom: 1px;
 
   &:empty {
     display: none;
index a0a852de9ad76c999010ef1cfe661f42080211e0..357e37ebb9f045b81142c6d094ef7b280ea2ce08 100644 (file)
@@ -95,7 +95,7 @@
   display: inline-block;
   min-width: 24px;
   height: 24px;
-  line-height: 24px;
+  line-height: 21px;
   padding: 0 4px;
   box-sizing: border-box;
   border: 1px solid #ccc;
   background-image: linear-gradient(to bottom, #f5f5f5, #eee);
   box-shadow: inset 0 1px 0 #fff, 0 1px 0 #ccc;
   color: @secondFontColor;
-  font-size: @baseFontSize;
-  font-weight: 600;
+  font-size: 11px;
   text-align: center;
 }
 
index 98bf375c2ea53e7c311ed2e169204015df2fda64..3c25afc7d905ee92304a40658f3c7aeb3555a564 100644 (file)
@@ -59,7 +59,7 @@
 
 .issues-workspace-component-viewer {
   display: none;
-  padding: 1px 10px;
+  padding: 1px 10px 10px;
   min-height: 100vh;
 
   .code-issue-modern {
index 16f6f693dc4302185f2eb9d73243730ffbd1cc0f..45ba783706ecc664ef66dc7760ff4c26e3070673 100644 (file)
@@ -1114,7 +1114,7 @@ classnames@2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.0.tgz#8f61df81f356c45d18a31d83fde4dfb194ea8722"
 
-classnames@^2.2.4:
+classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
 
@@ -1604,6 +1604,10 @@ dom-converter@~0.1:
   dependencies:
     utila "~0.3"
 
+"dom-helpers@^2.4.0 || ^3.0.0":
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
+
 dom-serializer@0, dom-serializer@~0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
@@ -2117,7 +2121,7 @@ fbjs@0.1.0-alpha.10:
     promise "^7.0.3"
     whatwg-fetch "^0.9.0"
 
-fbjs@^0.8.4:
+fbjs@^0.8.1, fbjs@^0.8.4:
   version "0.8.8"
   resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.8.tgz#02f1b6e0ea0d46c24e0b51a2d24df069563a5ad6"
   dependencies:
@@ -3571,7 +3575,7 @@ longest@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
 
-loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0:
+loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.0.tgz#6b26248c42f6d4fa4b0d8542f78edfcde35642a8"
   dependencies:
@@ -4526,13 +4530,19 @@ rc@~1.1.6:
     minimist "^1.2.0"
     strip-json-comments "~1.0.4"
 
-react-addons-shallow-compare@15.3.2:
-  version "15.3.2"
-  resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.3.2.tgz#c9edba49b9eab44d0c59024d289beb1ab97318b5"
+react-addons-shallow-compare@15.4.2:
+  version "15.4.2"
+  resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.4.2.tgz#027ffd9720e3a1e0b328dcd8fc62e214a0d174a5"
+  dependencies:
+    fbjs "^0.8.4"
+    object-assign "^4.1.0"
 
-react-addons-test-utils@15.3.2:
-  version "15.3.2"
-  resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6"
+react-addons-test-utils@15.4.2:
+  version "15.4.2"
+  resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.4.2.tgz#93bcaa718fcae7360d42e8fb1c09756cc36302a2"
+  dependencies:
+    fbjs "^0.8.4"
+    object-assign "^4.1.0"
 
 react-deep-force-update@^1.0.0:
   version "1.0.1"
@@ -4550,9 +4560,19 @@ react-dev-utils@0.2.1:
     sockjs-client "1.0.3"
     strip-ansi "3.0.1"
 
-react-dom@15.3.2:
-  version "15.3.2"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.2.tgz#c46b0aa5380d7b838e7a59c4a7beff2ed315531f"
+react-dom@15.4.2:
+  version "15.4.2"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.4.2.tgz#015363f05b0a1fd52ae9efdd3a0060d90695208f"
+  dependencies:
+    fbjs "^0.8.1"
+    loose-envify "^1.1.0"
+    object-assign "^4.1.0"
+
+react-draggable@2.2.3:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-2.2.3.tgz#17628cb8aaefed639d38e0021b978a685d80b08b"
+  dependencies:
+    classnames "^2.2.5"
 
 react-helmet@3.1.0:
   version "3.1.0"
@@ -4626,9 +4646,18 @@ react-transform-hmr@1.0.4:
     global "^4.3.0"
     react-proxy "^1.1.7"
 
-react@15.3.2:
-  version "15.3.2"
-  resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e"
+react-virtualized@^9.1.0:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.1.0.tgz#ac022281860f832ffecaf7863c099813681be521"
+  dependencies:
+    babel-runtime "^6.11.6"
+    classnames "^2.2.3"
+    dom-helpers "^2.4.0 || ^3.0.0"
+    loose-envify "^1.3.0"
+
+react@15.4.2:
+  version "15.4.2"
+  resolved "https://registry.yarnpkg.com/react/-/react-15.4.2.tgz#41f7991b26185392ba9bae96c8889e7e018397ef"
   dependencies:
     fbjs "^0.8.4"
     loose-envify "^1.1.0"
index 981e04287209b2ae4df2b46dbc8aebfd114d56c7..7fef5712f703df259d0771e11cfaf2b43e3ef10d 100644 (file)
@@ -2500,6 +2500,7 @@ source_viewer.tooltip.new_code=New {0}.
 
 source_viewer.load_more_code=Load More Code
 source_viewer.loading_more_code=Loading More Code...
+source_viewer.to_navigate_issue_locations=to quicky navigate issue locations