]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10489 Support cross file issue locations in web app
authorStas Vilchik <stas.vilchik@sonarsource.com>
Wed, 21 Mar 2018 13:18:13 +0000 (14:18 +0100)
committerSonarTech <sonartech@sonarsource.com>
Tue, 27 Mar 2018 18:22:33 +0000 (20:22 +0200)
21 files changed:
server/sonar-web/src/main/js/app/types.ts
server/sonar-web/src/main/js/apps/issues/components/App.tsx
server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/ComponentBreadcrumbs-test.tsx
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/CrossFileLocationsNavigator.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.tsx
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/CrossFileLocationsNavigator-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/styles.css
server/sonar-web/src/main/js/apps/issues/utils.ts
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
server/sonar-web/src/main/js/helpers/__tests__/issues-test.ts
server/sonar-web/src/main/js/helpers/issues.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 922cbf8cf692aa77df6cea35cd0c969bc744baa9..8155161aca040d276cbf6e7d4bfb0e48aa765feb 100644 (file)
@@ -150,6 +150,8 @@ export interface FacetValue {
 }
 
 export interface FlowLocation {
+  component: string;
+  componentName?: string;
   msg: string;
   textRange: TextRange;
 }
index 8eb5c82a6a949b40509ddcfa8cb41802232b9633..f6ba42afa5977696f894dbc08031ae83221f728d 100644 (file)
@@ -347,7 +347,14 @@ export default class App extends React.PureComponent<Props, State> {
     };
     if (this.state.openIssue) {
       if (path.query.open && path.query.open === this.state.openIssue.key) {
-        this.scrollToSelectedIssue();
+        this.setState(
+          {
+            locationsNavigator: false,
+            selectedFlowIndex: undefined,
+            selectedLocationIndex: undefined
+          },
+          this.scrollToSelectedIssue
+        );
       } else {
         this.context.router.replace(path);
       }
@@ -384,7 +391,7 @@ export default class App extends React.PureComponent<Props, State> {
     if (selected) {
       const element = document.querySelector(`[data-issue="${selected}"]`);
       if (element) {
-        scrollToElement(element, { topOffset: 150, bottomOffset: 100, smooth });
+        scrollToElement(element, { topOffset: 250, bottomOffset: 100, smooth });
       }
     }
   };
@@ -993,6 +1000,8 @@ export default class App extends React.PureComponent<Props, State> {
                       component={component}
                       issue={openIssue}
                       organization={this.props.organization}
+                      selectedFlowIndex={this.state.selectedFlowIndex}
+                      selectedLocationIndex={this.state.selectedLocationIndex}
                     />
                   </div>
                 ) : (
@@ -1020,14 +1029,13 @@ export default class App extends React.PureComponent<Props, State> {
                 <IssuesSourceViewer
                   branchLike={this.props.branchLike}
                   loadIssues={this.fetchIssuesForComponent}
+                  locationsNavigator={this.state.locationsNavigator}
                   onIssueChange={this.handleIssueChange}
                   onIssueSelect={this.openIssue}
                   onLocationSelect={this.selectLocation}
                   openIssue={openIssue}
                   selectedFlowIndex={this.state.selectedFlowIndex}
-                  selectedLocationIndex={
-                    this.state.locationsNavigator ? this.state.selectedLocationIndex : undefined
-                  }
+                  selectedLocationIndex={this.state.selectedLocationIndex}
                 />
               ) : (
                 this.renderList()
index ffeaae34231efd04bf78a2262e9c44eb84a93e9a..b5f7a6fe21ac0231774df82d1eafa954a9606a29 100644 (file)
@@ -19,7 +19,8 @@
  */
 import * as React from 'react';
 import { Link } from 'react-router';
-import { BranchLike, Component } from '../../../app/types';
+import { getSelectedLocation } from '../utils';
+import { BranchLike, Component, Issue } from '../../../app/types';
 import Organization from '../../../components/shared/Organization';
 import { collapsePath, limitComponentName } from '../../../helpers/path';
 import { getBranchLikeUrl, getCodeUrl } from '../../../helpers/urls';
@@ -27,29 +28,40 @@ import { getBranchLikeUrl, getCodeUrl } from '../../../helpers/urls';
 interface Props {
   branchLike?: BranchLike;
   component?: Component;
-  issue: {
-    component: string;
-    componentLongName: string;
-    organization: string;
-    project: string;
-    projectName: string;
-    subProject?: string;
-    subProjectName?: string;
-  };
+  issue: Pick<
+    Issue,
+    | 'component'
+    | 'componentLongName'
+    | 'flows'
+    | 'organization'
+    | 'project'
+    | 'projectName'
+    | 'secondaryLocations'
+    | 'subProject'
+    | 'subProjectName'
+  >;
   organization: { key: string } | undefined;
+  selectedFlowIndex?: number;
+  selectedLocationIndex?: number;
 }
 
 export default function ComponentBreadcrumbs({
   branchLike,
   component,
   issue,
-  organization
+  organization,
+  selectedFlowIndex,
+  selectedLocationIndex
 }: Props) {
   const displayOrganization =
     !organization && (!component || ['VW', 'SVW'].includes(component.qualifier));
   const displayProject = !component || !['TRK', 'BRC', 'DIR'].includes(component.qualifier);
   const displaySubProject = !component || !['BRC', 'DIR'].includes(component.qualifier);
 
+  const selectedLocation = getSelectedLocation(issue, selectedFlowIndex, selectedLocationIndex);
+  const componentKey = selectedLocation ? selectedLocation.component : issue.component;
+  const componentName = selectedLocation ? selectedLocation.componentName : issue.componentLongName;
+
   return (
     <div className="component-name text-ellipsis">
       {displayOrganization && (
@@ -76,10 +88,8 @@ export default function ComponentBreadcrumbs({
           </span>
         )}
 
-      <Link
-        className="link-no-underline"
-        to={getCodeUrl(issue.project, branchLike, issue.component)}>
-        <span title={issue.componentLongName}>{collapsePath(issue.componentLongName)}</span>
+      <Link className="link-no-underline" to={getCodeUrl(issue.project, branchLike, componentKey)}>
+        <span title={componentName}>{collapsePath(componentName || '')}</span>
       </Link>
     </div>
   );
index 930976c7cac8a5fa9acb6da8243fa0a18c515ec5..6266c18322c72303da536d6543d4ded74cc417a8 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { getLocations, getSelectedLocation } from '../utils';
 import { BranchLike, Issue } from '../../../app/types';
 import SourceViewer from '../../../components/SourceViewer/SourceViewer';
 import { scrollToElement } from '../../../helpers/scrolling';
@@ -25,6 +26,7 @@ import { scrollToElement } from '../../../helpers/scrolling';
 interface Props {
   branchLike: BranchLike | undefined;
   loadIssues: (component: string, from: number, to: number) => Promise<Issue[]>;
+  locationsNavigator: boolean;
   onIssueChange: (issue: Issue) => void;
   onIssueSelect: (issueKey: string) => void;
   onLocationSelect: (index: number) => void;
@@ -71,53 +73,57 @@ export default class IssuesSourceViewer extends React.PureComponent<Props> {
   render() {
     const { openIssue, selectedFlowIndex, selectedLocationIndex } = this.props;
 
-    const locations =
-      selectedFlowIndex !== undefined
-        ? openIssue.flows[selectedFlowIndex]
-        : openIssue.flows.length > 0 ? openIssue.flows[0] : openIssue.secondaryLocations;
-
-    let locationMessage = undefined;
-    let locationLine = undefined;
+    const locations = getLocations(openIssue, selectedFlowIndex);
+    const selectedLocation = getSelectedLocation(
+      openIssue,
+      selectedFlowIndex,
+      selectedLocationIndex
+    );
 
-    // We don't want to display a location message when selected location is -1
-    if (
-      locations !== undefined &&
-      selectedLocationIndex !== undefined &&
-      selectedLocationIndex >= 0 &&
-      locations.length >= selectedLocationIndex
-    ) {
-      locationMessage = {
-        index: selectedLocationIndex,
-        text: locations[selectedLocationIndex].msg
-      };
-      locationLine = locations[selectedLocationIndex].textRange.startLine;
-    }
+    const component = selectedLocation ? selectedLocation.component : openIssue.component;
 
     // if location is selected, show (and load) code around it
     // otherwise show code around the open issue
-    const aroundLine = locationLine || (openIssue.textRange && openIssue.textRange.endLine);
+    const aroundLine = selectedLocation
+      ? selectedLocation.textRange.startLine
+      : openIssue.textRange && openIssue.textRange.endLine;
+
+    // replace locations in another file with `undefined` to keep the same location indexes
+    const highlightedLocations = locations.map(
+      location => (location.component === component ? location : undefined)
+    );
+
+    const highlightedLocationMessage =
+      this.props.locationsNavigator && selectedLocationIndex !== undefined
+        ? selectedLocation && { index: selectedLocationIndex, text: selectedLocation.msg }
+        : undefined;
 
     const allMessagesEmpty = locations !== undefined && locations.every(location => !location.msg);
 
+    // do not load issues when open another file for a location
+    const loadIssues =
+      component === openIssue.component ? this.props.loadIssues : () => Promise.resolve([]);
+    const selectedIssue = component === openIssue.component ? openIssue.key : undefined;
+
     return (
       <div ref={node => (this.node = node)}>
         <SourceViewer
           aroundLine={aroundLine}
           branchLike={this.props.branchLike}
-          component={openIssue.component}
+          component={component}
           displayAllIssues={true}
           displayIssueLocationsCount={false}
           displayIssueLocationsLink={false}
           displayLocationMarkers={!allMessagesEmpty}
-          highlightedLocationMessage={locationMessage}
-          highlightedLocations={locations}
-          loadIssues={this.props.loadIssues}
+          highlightedLocationMessage={highlightedLocationMessage}
+          highlightedLocations={highlightedLocations}
+          loadIssues={loadIssues}
           onIssueChange={this.props.onIssueChange}
           onIssueSelect={this.props.onIssueSelect}
           onLoaded={this.handleLoaded}
           onLocationSelect={this.props.onLocationSelect}
           scroll={this.handleScroll}
-          selectedIssue={openIssue.key}
+          selectedIssue={selectedIssue}
         />
       </div>
     );
index a6e4e7971bac4675ccb6f435b4d4d794f727644c..0e6fd7f96528f15b879357e41eb37adfec83257c 100644 (file)
@@ -25,9 +25,11 @@ import { ShortLivingBranch, BranchType } from '../../../../app/types';
 const baseIssue = {
   component: 'comp',
   componentLongName: 'comp-name',
+  flows: [],
   organization: 'org',
   project: 'proj',
-  projectName: 'proj-name'
+  projectName: 'proj-name',
+  secondaryLocations: []
 };
 
 it('renders', () => {
index 12782ff6b5713287036ffb8d9076c6f0f6a183a7..3a2cdb9885ff30eca1ac0f62491e62b2ee8fb4e9 100644 (file)
@@ -22,7 +22,7 @@ import ConciseIssueLocationBadge from './ConciseIssueLocationBadge';
 import { Issue } from '../../../app/types';
 
 interface Props {
-  issue: Issue;
+  issue: Pick<Issue, 'flows' | 'secondaryLocations'>;
   onFlowSelect: (index: number) => void;
   selectedFlowIndex: number | undefined;
 }
index d6ff0d7b32b258d725112c49ac799a24ad6151a7..60946f692f58e037a67a6848b2ba3cd9a9f67385 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { uniq } from 'lodash';
 import ConciseIssueLocationsNavigatorLocation from './ConciseIssueLocationsNavigatorLocation';
+import CrossFileLocationsNavigator from './CrossFileLocationsNavigator';
+import { getLocations } from '../utils';
 import { Issue } from '../../../app/types';
 
 interface Props {
-  issue: Issue;
+  issue: Pick<Issue, 'component' | 'key' | 'flows' | 'secondaryLocations'>;
   onLocationSelect: (index: number) => void;
   scroll: (element: Element) => void;
   selectedFlowIndex: number | undefined;
@@ -31,31 +34,43 @@ interface Props {
 
 export default class ConciseIssueLocationsNavigator extends React.PureComponent<Props> {
   render() {
-    const { selectedFlowIndex, selectedLocationIndex } = this.props;
-    const { flows, secondaryLocations } = this.props.issue;
-
-    const locations =
-      selectedFlowIndex !== undefined
-        ? flows[selectedFlowIndex]
-        : flows.length > 0 ? flows[0] : secondaryLocations;
+    const locations = getLocations(this.props.issue, this.props.selectedFlowIndex);
 
     if (!locations || locations.length === 0 || locations.every(location => !location.msg)) {
       return null;
     }
 
-    return (
-      <div className="spacer-top">
-        {locations.map((location, index) => (
-          <ConciseIssueLocationsNavigatorLocation
-            index={index}
-            key={index}
-            message={location.msg}
-            onClick={this.props.onLocationSelect}
-            scroll={this.props.scroll}
-            selected={index === selectedLocationIndex}
-          />
-        ))}
-      </div>
-    );
+    const locationComponents = [
+      this.props.issue.component,
+      ...locations.map(location => location.component)
+    ];
+    const isCrossFile = uniq(locationComponents).length > 1;
+
+    if (isCrossFile) {
+      return (
+        <CrossFileLocationsNavigator
+          issue={this.props.issue}
+          locations={locations}
+          onLocationSelect={this.props.onLocationSelect}
+          scroll={this.props.scroll}
+          selectedLocationIndex={this.props.selectedLocationIndex}
+        />
+      );
+    } else {
+      return (
+        <div className="concise-issue-locations-navigator spacer-top">
+          {locations.map((location, index) => (
+            <ConciseIssueLocationsNavigatorLocation
+              index={index}
+              key={index}
+              message={location.msg}
+              onClick={this.props.onLocationSelect}
+              scroll={this.props.scroll}
+              selected={index === this.props.selectedLocationIndex}
+            />
+          ))}
+        </div>
+      );
+    }
   }
 }
index 455b597b9ee88d55648bcf17d8c90804b07942b9..1fe90aa029e08caeaf006cf4dc2f1d06131d9be7 100644 (file)
@@ -53,7 +53,7 @@ export default class ConciseIssueLocationsNavigatorLocation extends React.PureCo
     return (
       <div className="little-spacer-top" ref={node => (this.node = node)}>
         <a
-          className="consice-issue-locations-navigator-location"
+          className="concise-issue-locations-navigator-location"
           href="#"
           onClick={this.handleClick}>
           <LocationIndex selected={this.props.selected}>{this.props.index + 1}</LocationIndex>
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/CrossFileLocationsNavigator.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/CrossFileLocationsNavigator.tsx
new file mode 100644 (file)
index 0000000..a2826a6
--- /dev/null
@@ -0,0 +1,190 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 ConciseIssueLocationsNavigatorLocation from './ConciseIssueLocationsNavigatorLocation';
+import { Issue, FlowLocation } from '../../../app/types';
+import { translateWithParameters } from '../../../helpers/l10n';
+import { collapsePath } from '../../../helpers/path';
+
+interface Props {
+  issue: Pick<Issue, 'key'>;
+  locations: FlowLocation[];
+  onLocationSelect: (index: number) => void;
+  scroll: (element: Element) => void;
+  selectedLocationIndex: number | undefined;
+}
+
+interface State {
+  collapsed: boolean;
+}
+
+interface LocationGroup {
+  component: string | undefined;
+  componentName: string | undefined;
+  firstLocationIndex: number;
+  locations: FlowLocation[];
+}
+
+export default class CrossFileLocationsNavigator extends React.PureComponent<Props, State> {
+  state: State = { collapsed: true };
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (nextProps.issue.key !== this.props.issue.key) {
+      this.setState({ collapsed: true });
+    }
+
+    // expand locations list as soon as a location in the middle of the list is selected
+    const { locations: nextLocations } = nextProps;
+    if (
+      nextProps.selectedLocationIndex &&
+      nextProps.selectedLocationIndex > 0 &&
+      nextLocations !== undefined &&
+      nextProps.selectedLocationIndex < nextLocations.length - 1
+    ) {
+      this.setState({ collapsed: false });
+    }
+  }
+
+  handleMoreLocationsClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState({ collapsed: false });
+  };
+
+  groupByFile = (locations: FlowLocation[]) => {
+    const groups: LocationGroup[] = [];
+
+    let currentLocations: FlowLocation[] = [];
+    let currentComponent: string | undefined;
+    let currentComponentName: string | undefined;
+    let currentFirstLocationIndex = 0;
+
+    for (let index = 0; index < locations.length; index++) {
+      const location = locations[index];
+      if (location.component === currentComponent) {
+        currentLocations.push(location);
+      } else {
+        if (currentLocations.length > 0) {
+          groups.push({
+            component: currentComponent,
+            componentName: currentComponentName,
+            firstLocationIndex: currentFirstLocationIndex,
+            locations: currentLocations
+          });
+        }
+        currentLocations = [location];
+        currentComponent = location.component;
+        currentComponentName = location.componentName;
+        currentFirstLocationIndex = index;
+      }
+    }
+
+    if (currentLocations.length > 0) {
+      groups.push({
+        component: currentComponent,
+        componentName: currentComponentName,
+        firstLocationIndex: currentFirstLocationIndex,
+        locations: currentLocations
+      });
+    }
+
+    return groups;
+  };
+
+  renderLocation = (index: number, message: string) => {
+    return (
+      <ConciseIssueLocationsNavigatorLocation
+        index={index}
+        key={index}
+        message={message}
+        onClick={this.props.onLocationSelect}
+        scroll={this.props.scroll}
+        selected={index === this.props.selectedLocationIndex}
+      />
+    );
+  };
+
+  renderGroup = (
+    group: LocationGroup,
+    groupIndex: number,
+    { onlyFirst = false, onlyLast = false } = {}
+  ) => {
+    const { firstLocationIndex } = group;
+    const lastLocationIndex = group.locations.length - 1;
+    return (
+      <div className="concise-issue-locations-navigator-file" key={groupIndex}>
+        <div className="concise-issue-location-file">
+          <i className="concise-issue-location-file-circle little-spacer-right" />
+          {collapsePath(group.componentName || '', 15)}
+        </div>
+        {group.locations.length > 0 && (
+          <div className="concise-issue-location-file-locations">
+            {onlyFirst && this.renderLocation(firstLocationIndex, group.locations[0].msg)}
+
+            {onlyLast &&
+              this.renderLocation(
+                firstLocationIndex + lastLocationIndex,
+                group.locations[lastLocationIndex].msg
+              )}
+
+            {!onlyFirst &&
+              !onlyLast &&
+              group.locations.map((location, index) =>
+                this.renderLocation(firstLocationIndex + index, location.msg)
+              )}
+          </div>
+        )}
+      </div>
+    );
+  };
+
+  render() {
+    const { locations } = this.props;
+    const groups = this.groupByFile(locations);
+
+    if (locations.length > 2 && groups.length > 1 && this.state.collapsed) {
+      const firstGroup = groups[0];
+      const lastGroup = groups[groups.length - 1];
+      return (
+        <div className="concise-issue-locations-navigator spacer-top">
+          {this.renderGroup(firstGroup, 0, { onlyFirst: true })}
+          <div className="concise-issue-locations-navigator-file">
+            <div className="concise-issue-location-file">
+              <i className="concise-issue-location-file-circle-multiple little-spacer-right" />
+              <a
+                className="concise-issue-location-file-more"
+                href="#"
+                onClick={this.handleMoreLocationsClick}>
+                {translateWithParameters('issues.x_more_locations', locations.length - 2)}
+              </a>
+            </div>
+          </div>
+          {this.renderGroup(lastGroup, groups.length - 1, { onlyLast: true })}
+        </div>
+      );
+    } else {
+      return (
+        <div className="concise-issue-locations-navigator spacer-top">
+          {groups.map((group, groupIndex) => this.renderGroup(group, groupIndex))}
+        </div>
+      );
+    }
+  }
+}
index d9aee3703859b1f1ea2c0b0a78c66a14eea972c3..ad7d541f74006b9650d038d349035931d4444511 100644 (file)
@@ -23,32 +23,12 @@ import ConciseIssueLocations from '../ConciseIssueLocations';
 
 const textRange = { startLine: 1, startOffset: 1, endLine: 1, endOffset: 1 };
 
-const baseIssue = {
-  component: '',
-  componentLongName: '',
-  componentQualifier: '',
-  componentUuid: '',
-  creationDate: '',
-  key: '',
-  message: '',
-  organization: '',
-  project: '',
-  projectName: '',
-  projectOrganization: '',
-  projectUuid: '',
-  rule: '',
-  ruleName: '',
-  severity: '',
-  status: '',
-  type: '',
-  secondaryLocations: [],
-  flows: []
-};
+const loc = { component: '', msg: '', textRange };
 
 it('should render secondary locations', () => {
   const issue = {
-    ...baseIssue,
-    secondaryLocations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }]
+    flows: [],
+    secondaryLocations: [loc, loc, loc]
   };
   expect(
     shallow(
@@ -59,9 +39,8 @@ it('should render secondary locations', () => {
 
 it('should render one flow', () => {
   const issue = {
-    ...baseIssue,
-    secondaryLocations: [],
-    flows: [[{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }]]
+    flows: [[loc, loc, loc]],
+    secondaryLocations: []
   };
   expect(
     shallow(
@@ -72,12 +51,8 @@ it('should render one flow', () => {
 
 it('should render several flows', () => {
   const issue = {
-    ...baseIssue,
-    flows: [
-      [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }],
-      [{ msg: '', textRange }, { msg: '', textRange }],
-      [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }]
-    ]
+    flows: [[loc, loc, loc], [loc, loc], [loc, loc, loc]],
+    secondaryLocations: []
   };
   expect(
     shallow(
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx
new file mode 100644 (file)
index 0000000..9ae00c5
--- /dev/null
@@ -0,0 +1,138 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { shallow } from 'enzyme';
+import ConciseIssueLocationsNavigator from '../ConciseIssueLocationsNavigator';
+import { FlowLocation } from '../../../../app/types';
+
+const location1: FlowLocation = {
+  component: 'foo',
+  componentName: 'src/foo.js',
+  msg: 'Do not use foo',
+  textRange: { startLine: 7, endLine: 7, startOffset: 5, endOffset: 8 }
+};
+
+const location2: FlowLocation = {
+  component: 'foo',
+  componentName: 'src/foo.js',
+  msg: 'Do not use foo',
+  textRange: { startLine: 8, endLine: 8, startOffset: 0, endOffset: 5 }
+};
+
+const location3: FlowLocation = {
+  component: 'bar',
+  componentName: 'src/bar.js',
+  msg: 'Do not use bar',
+  textRange: { startLine: 15, endLine: 16, startOffset: 4, endOffset: 6 }
+};
+
+it('should render secondary locations in the same file', () => {
+  const issue = {
+    component: 'foo',
+    key: '',
+    flows: [],
+    secondaryLocations: [location1, location2]
+  };
+  expect(
+    shallow(
+      <ConciseIssueLocationsNavigator
+        issue={issue}
+        onLocationSelect={jest.fn()}
+        scroll={jest.fn()}
+        selectedFlowIndex={undefined}
+        selectedLocationIndex={undefined}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should render flow locations in the same file', () => {
+  const issue = {
+    component: 'foo',
+    key: '',
+    flows: [[location1, location2]],
+    secondaryLocations: []
+  };
+  expect(
+    shallow(
+      <ConciseIssueLocationsNavigator
+        issue={issue}
+        onLocationSelect={jest.fn()}
+        scroll={jest.fn()}
+        selectedFlowIndex={undefined}
+        selectedLocationIndex={undefined}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should render selected flow locations in the same file', () => {
+  const issue = {
+    component: 'foo',
+    key: '',
+    flows: [[location1, location2]],
+    secondaryLocations: [location1]
+  };
+  expect(
+    shallow(
+      <ConciseIssueLocationsNavigator
+        issue={issue}
+        onLocationSelect={jest.fn()}
+        scroll={jest.fn()}
+        selectedFlowIndex={0}
+        selectedLocationIndex={undefined}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should render flow locations in different file', () => {
+  const issue = {
+    component: 'foo',
+    key: '',
+    flows: [[location1, location3]],
+    secondaryLocations: []
+  };
+  expect(
+    shallow(
+      <ConciseIssueLocationsNavigator
+        issue={issue}
+        onLocationSelect={jest.fn()}
+        scroll={jest.fn()}
+        selectedFlowIndex={undefined}
+        selectedLocationIndex={undefined}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should not render locations', () => {
+  const issue = { component: 'foo', key: '', flows: [], secondaryLocations: [] };
+  const wrapper = shallow(
+    <ConciseIssueLocationsNavigator
+      issue={issue}
+      onLocationSelect={jest.fn()}
+      scroll={jest.fn()}
+      selectedFlowIndex={undefined}
+      selectedLocationIndex={undefined}
+    />
+  );
+  expect(wrapper.type()).toBeNull();
+});
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/CrossFileLocationsNavigator-test.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/CrossFileLocationsNavigator-test.tsx
new file mode 100644 (file)
index 0000000..f09e6d0
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { shallow } from 'enzyme';
+import CrossFileLocationsNavigator from '../CrossFileLocationsNavigator';
+import { FlowLocation } from '../../../../app/types';
+import { click } from '../../../../helpers/testUtils';
+
+const location1: FlowLocation = {
+  component: 'foo',
+  componentName: 'src/foo.js',
+  msg: 'Do not use foo',
+  textRange: { startLine: 7, endLine: 7, startOffset: 5, endOffset: 8 }
+};
+
+const location2: FlowLocation = {
+  component: 'foo',
+  componentName: 'src/foo.js',
+  msg: 'Do not use foo',
+  textRange: { startLine: 8, endLine: 8, startOffset: 0, endOffset: 5 }
+};
+
+const location3: FlowLocation = {
+  component: 'bar',
+  componentName: 'src/bar.js',
+  msg: 'Do not use bar',
+  textRange: { startLine: 15, endLine: 16, startOffset: 4, endOffset: 6 }
+};
+
+it('should render', () => {
+  const wrapper = shallow(
+    <CrossFileLocationsNavigator
+      issue={{ key: 'abcd' }}
+      locations={[location1, location2, location3]}
+      onLocationSelect={jest.fn()}
+      scroll={jest.fn()}
+      selectedLocationIndex={undefined}
+    />
+  );
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2);
+
+  click(wrapper.find('.concise-issue-location-file-more'));
+  expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(3);
+});
+
+it('should render all locations', () => {
+  const wrapper = shallow(
+    <CrossFileLocationsNavigator
+      issue={{ key: 'abcd' }}
+      locations={[location1, location2]}
+      onLocationSelect={jest.fn()}
+      scroll={jest.fn()}
+      selectedLocationIndex={undefined}
+    />
+  );
+  expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2);
+});
+
+it('should expand all locations', () => {
+  const wrapper = shallow(
+    <CrossFileLocationsNavigator
+      issue={{ key: 'abcd' }}
+      locations={[location1, location2, location3]}
+      onLocationSelect={jest.fn()}
+      scroll={jest.fn()}
+      selectedLocationIndex={undefined}
+    />
+  );
+  expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2);
+
+  wrapper.setProps({ selectedLocationIndex: 1 });
+  expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(3);
+});
+
+it('should collapse locations when issue changes', () => {
+  const wrapper = shallow(
+    <CrossFileLocationsNavigator
+      issue={{ key: 'abcd' }}
+      locations={[location1, location2, location3]}
+      onLocationSelect={jest.fn()}
+      scroll={jest.fn()}
+      selectedLocationIndex={undefined}
+    />
+  );
+  wrapper.setProps({ selectedLocationIndex: 1 });
+  expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(3);
+
+  wrapper.setProps({ issue: { key: 'def' }, selectedLocationIndex: undefined });
+  expect(wrapper.find('ConciseIssueLocationsNavigatorLocation').length).toBe(2);
+});
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap
new file mode 100644 (file)
index 0000000..f251217
--- /dev/null
@@ -0,0 +1,136 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render flow locations in different file 1`] = `
+<CrossFileLocationsNavigator
+  issue={
+    Object {
+      "component": "foo",
+      "flows": Array [
+        Array [
+          Object {
+            "component": "foo",
+            "componentName": "src/foo.js",
+            "msg": "Do not use foo",
+            "textRange": Object {
+              "endLine": 7,
+              "endOffset": 8,
+              "startLine": 7,
+              "startOffset": 5,
+            },
+          },
+          Object {
+            "component": "bar",
+            "componentName": "src/bar.js",
+            "msg": "Do not use bar",
+            "textRange": Object {
+              "endLine": 16,
+              "endOffset": 6,
+              "startLine": 15,
+              "startOffset": 4,
+            },
+          },
+        ],
+      ],
+      "key": "",
+      "secondaryLocations": Array [],
+    }
+  }
+  locations={
+    Array [
+      Object {
+        "component": "foo",
+        "componentName": "src/foo.js",
+        "msg": "Do not use foo",
+        "textRange": Object {
+          "endLine": 7,
+          "endOffset": 8,
+          "startLine": 7,
+          "startOffset": 5,
+        },
+      },
+      Object {
+        "component": "bar",
+        "componentName": "src/bar.js",
+        "msg": "Do not use bar",
+        "textRange": Object {
+          "endLine": 16,
+          "endOffset": 6,
+          "startLine": 15,
+          "startOffset": 4,
+        },
+      },
+    ]
+  }
+  onLocationSelect={[MockFunction]}
+  scroll={[MockFunction]}
+/>
+`;
+
+exports[`should render flow locations in the same file 1`] = `
+<div
+  className="concise-issue-locations-navigator spacer-top"
+>
+  <ConciseIssueLocationsNavigatorLocation
+    index={0}
+    key="0"
+    message="Do not use foo"
+    onClick={[MockFunction]}
+    scroll={[MockFunction]}
+    selected={false}
+  />
+  <ConciseIssueLocationsNavigatorLocation
+    index={1}
+    key="1"
+    message="Do not use foo"
+    onClick={[MockFunction]}
+    scroll={[MockFunction]}
+    selected={false}
+  />
+</div>
+`;
+
+exports[`should render secondary locations in the same file 1`] = `
+<div
+  className="concise-issue-locations-navigator spacer-top"
+>
+  <ConciseIssueLocationsNavigatorLocation
+    index={0}
+    key="0"
+    message="Do not use foo"
+    onClick={[MockFunction]}
+    scroll={[MockFunction]}
+    selected={false}
+  />
+  <ConciseIssueLocationsNavigatorLocation
+    index={1}
+    key="1"
+    message="Do not use foo"
+    onClick={[MockFunction]}
+    scroll={[MockFunction]}
+    selected={false}
+  />
+</div>
+`;
+
+exports[`should render selected flow locations in the same file 1`] = `
+<div
+  className="concise-issue-locations-navigator spacer-top"
+>
+  <ConciseIssueLocationsNavigatorLocation
+    index={0}
+    key="0"
+    message="Do not use foo"
+    onClick={[MockFunction]}
+    scroll={[MockFunction]}
+    selected={false}
+  />
+  <ConciseIssueLocationsNavigatorLocation
+    index={1}
+    key="1"
+    message="Do not use foo"
+    onClick={[MockFunction]}
+    scroll={[MockFunction]}
+    selected={false}
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap
new file mode 100644 (file)
index 0000000..e1b734e
--- /dev/null
@@ -0,0 +1,76 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<div
+  className="concise-issue-locations-navigator spacer-top"
+>
+  <div
+    className="concise-issue-locations-navigator-file"
+    key="0"
+  >
+    <div
+      className="concise-issue-location-file"
+    >
+      <i
+        className="concise-issue-location-file-circle little-spacer-right"
+      />
+      src/foo.js
+    </div>
+    <div
+      className="concise-issue-location-file-locations"
+    >
+      <ConciseIssueLocationsNavigatorLocation
+        index={0}
+        key="0"
+        message="Do not use foo"
+        onClick={[MockFunction]}
+        scroll={[MockFunction]}
+        selected={false}
+      />
+    </div>
+  </div>
+  <div
+    className="concise-issue-locations-navigator-file"
+  >
+    <div
+      className="concise-issue-location-file"
+    >
+      <i
+        className="concise-issue-location-file-circle-multiple little-spacer-right"
+      />
+      <a
+        className="concise-issue-location-file-more"
+        href="#"
+        onClick={[Function]}
+      >
+        issues.x_more_locations.1
+      </a>
+    </div>
+  </div>
+  <div
+    className="concise-issue-locations-navigator-file"
+    key="1"
+  >
+    <div
+      className="concise-issue-location-file"
+    >
+      <i
+        className="concise-issue-location-file-circle little-spacer-right"
+      />
+      src/bar.js
+    </div>
+    <div
+      className="concise-issue-location-file-locations"
+    >
+      <ConciseIssueLocationsNavigatorLocation
+        index={2}
+        key="2"
+        message="Do not use bar"
+        onClick={[MockFunction]}
+        scroll={[MockFunction]}
+        selected={false}
+      />
+    </div>
+  </div>
+</div>
+`;
index 9a285c2c33eb2fdc5b7f32faf91854ac2bead2d2..0add575a8784e33b9bf1e08bbca974890eeaac91 100644 (file)
   margin-bottom: 4px;
 }
 
-.consice-issue-locations-navigator-location {
-  display: flex;
+.concise-issue-locations-navigator-location {
+  position: relative;
+  z-index: var(--aboveNormalZIndex);
+  display: inline-flex;
   align-items: flex-start;
+  max-width: 100%;
   border: none;
 }
 
+.concise-issue-locations-navigator-file {
+  position: relative;
+}
+
+.concise-issue-locations-navigator-file + .concise-issue-locations-navigator-file {
+  margin-top: calc(1.5 * var(--gridSize));
+}
+
+.concise-issue-locations-navigator-file:not(:last-child)::before {
+  position: absolute;
+  display: block;
+  width: 0;
+  top: 13px;
+  bottom: calc(-2 * var(--gridSize));
+  left: 4px;
+  border-left: 1px dotted #d18582;
+  content: '';
+}
+
+.concise-issue-location-file {
+  height: calc(2 * var(--gridSize));
+  padding-bottom: calc(0.5 * var(--gridSize));
+  font-size: var(--smallFontSize);
+  font-weight: bold;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.concise-issue-location-file-circle,
+.concise-issue-location-file-circle-multiple,
+.concise-issue-location-file-circle-multiple::before,
+.concise-issue-location-file-circle-multiple::after {
+  position: relative;
+  top: 1px;
+  display: inline-block;
+  width: calc(1px + var(--gridSize));
+  height: calc(1px + var(--gridSize));
+  border: 1px solid #d18582;
+  border-radius: 100%;
+  box-sizing: border-box;
+  background-color: #ffeaea;
+}
+
+.concise-issue-location-file-circle-multiple {
+  top: -2px;
+}
+
+.concise-issue-location-file-circle-multiple::before {
+  position: absolute;
+  z-index: calc(5 + var(--normalZIndex));
+  top: 2px;
+  left: -1px;
+  content: '';
+}
+
+.concise-issue-location-file-circle-multiple::after {
+  position: absolute;
+  z-index: calc(5 + var(--aboveNormalZIndex));
+  top: 5px;
+  left: -1px;
+  content: '';
+}
+
+.concise-issue-location-file-locations {
+  padding-left: calc(2 * var(--gridSize));
+}
+
+.concise-issue-location-file-more {
+  border-color: rgba(209, 133, 130, 0.2);
+  color: rgb(209, 133, 130) !important;
+  font-style: italic;
+  font-weight: normal;
+}
+
+.concise-issue-location-file-more:hover,
+.concise-issue-location-file-more:focus {
+  border-color: rgba(209, 133, 130, 0.6);
+}
+
 .issues-my-issues-filter {
   margin-bottom: 24px;
   text-align: center;
index a8ca8b1754e88bd05ad9dc44d99a3e1ba7b04222..f148547d9e11ca5045c4571b10888a9a55acbad8 100644 (file)
@@ -19,6 +19,7 @@
  */
 import { searchMembers } from '../../api/organizations';
 import { searchUsers } from '../../api/users';
+import { Issue } from '../../app/types';
 import { formatMeasure } from '../../helpers/measures';
 import {
   queriesEqual,
@@ -227,3 +228,31 @@ const save = (value: string) => {
 
 export const saveMyIssues = (myIssues: boolean) =>
   save(myIssues ? LOCALSTORAGE_MY : LOCALSTORAGE_ALL);
+
+export function getLocations(
+  { flows, secondaryLocations }: Pick<Issue, 'flows' | 'secondaryLocations'>,
+  selectedFlowIndex: number | undefined
+) {
+  if (selectedFlowIndex !== undefined) {
+    return flows[selectedFlowIndex] || [];
+  } else {
+    return flows.length > 0 ? flows[0] : secondaryLocations;
+  }
+}
+
+export function getSelectedLocation(
+  issue: Pick<Issue, 'flows' | 'secondaryLocations'>,
+  selectedFlowIndex: number | undefined,
+  selectedLocationIndex: number | undefined
+) {
+  const locations = getLocations(issue, selectedFlowIndex);
+  if (
+    selectedLocationIndex !== undefined &&
+    selectedLocationIndex >= 0 &&
+    locations.length >= selectedLocationIndex
+  ) {
+    return locations[selectedLocationIndex];
+  } else {
+    return undefined;
+  }
+}
index 101bc211a1a0de953bc47f052b42c2e0d7a2307c..629b4d86971163d2eb6ebbb0dbeae50c882ae0a6 100644 (file)
@@ -63,7 +63,9 @@ interface Props {
   displayIssueLocationsLink?: boolean;
   displayLocationMarkers?: boolean;
   highlightedLine?: number;
-  highlightedLocations?: FlowLocation[];
+  // `undefined` elements mean they are located in a different file,
+  // but kept to maintaint the location indexes
+  highlightedLocations?: (FlowLocation | undefined)[];
   highlightedLocationMessage?: { index: number; text: string };
   loadComponent?: (
     component: string,
@@ -158,6 +160,14 @@ export default class SourceViewerBase extends React.PureComponent<Props, State>
   }
 
   componentWillReceiveProps(nextProps: Props) {
+    // if a component or a branch has changed,
+    // set `loading: true` immediately to avoid unwanted scrolling in `LineCode`
+    if (
+      nextProps.component !== this.props.component ||
+      !isSameBranchLike(nextProps.branchLike, this.props.branchLike)
+    ) {
+      this.setState({ loading: true });
+    }
     if (
       nextProps.onIssueSelect !== undefined &&
       nextProps.selectedIssue !== this.props.selectedIssue
index 96bd3512e3962735b3b6e1f9c744c0caa4ce4890..ef1abb5af32e07e69860f4ed31d8cfdabe19ca23 100644 (file)
@@ -54,7 +54,9 @@ interface Props {
   hasSourcesBefore: boolean;
   highlightedLine: number | undefined;
   highlightedLocationMessage: { index: number; text: string } | undefined;
-  highlightedLocations: FlowLocation[] | undefined;
+  // `undefined` elements mean they are located in a different file,
+  // but kept to maintain the location indexes
+  highlightedLocations: (FlowLocation | undefined)[] | undefined;
   highlightedSymbols: string[];
   issueLocationsByLine: { [line: number]: LinearIssueLocation[] };
   issuePopup: { issue: string; name: string } | undefined;
@@ -102,9 +104,11 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
       return EMPTY_ARRAY;
     }
     return highlightedLocations.reduce((locations, location, index) => {
-      const linearLocations: LinearIssueLocation[] = getLinearLocations(location.textRange)
-        .filter(l => l.line === line.line)
-        .map(l => ({ ...l, startLine: location.textRange.startLine, index }));
+      const linearLocations: LinearIssueLocation[] = location
+        ? getLinearLocations(location.textRange)
+            .filter(l => l.line === line.line)
+            .map(l => ({ ...l, startLine: location.textRange.startLine, index }))
+        : [];
       return [...locations, ...linearLocations];
     }, []);
   };
index fa64839dfa48b6dfb02935da456971f24801e3a0..2db7dff1534b338428169c4deae35ad7beef59f8 100644 (file)
@@ -60,16 +60,61 @@ it('should populate comments data', () => {
 it('orders secondary locations', () => {
   const issue = {
     flows: [
-      { locations: [{ textRange: { startLine: 68, startOffset: 5, endLine: 68, endOffset: 7 } }] },
-      { locations: [{ textRange: { startLine: 43, startOffset: 8, endLine: 43, endOffset: 12 } }] },
-      { locations: [{ textRange: { startLine: 43, startOffset: 6, endLine: 43, endOffset: 8 } }] },
-      { locations: [{ textRange: { startLine: 70, startOffset: 12, endLine: 70, endOffset: 16 } }] }
+      {
+        locations: [
+          {
+            component: 'foo',
+            textRange: { startLine: 68, startOffset: 5, endLine: 68, endOffset: 7 }
+          }
+        ]
+      },
+      {
+        locations: [
+          {
+            component: 'unknown',
+            textRange: { startLine: 43, startOffset: 8, endLine: 43, endOffset: 12 }
+          }
+        ]
+      },
+      {
+        locations: [
+          {
+            component: 'bar',
+            textRange: { startLine: 43, startOffset: 6, endLine: 43, endOffset: 8 }
+          }
+        ]
+      },
+      {
+        locations: [
+          {
+            component: 'foo',
+            textRange: { startLine: 70, startOffset: 12, endLine: 70, endOffset: 16 }
+          }
+        ]
+      }
     ]
   } as any;
-  expect(parseIssueFromResponse(issue).secondaryLocations).toEqual([
-    { textRange: { startLine: 43, startOffset: 6, endLine: 43, endOffset: 8 } },
-    { textRange: { startLine: 43, startOffset: 8, endLine: 43, endOffset: 12 } },
-    { textRange: { startLine: 68, startOffset: 5, endLine: 68, endOffset: 7 } },
-    { textRange: { startLine: 70, startOffset: 12, endLine: 70, endOffset: 16 } }
+  const components = [{ key: 'foo', name: 'src/foo.js' }, { key: 'bar', name: 'src/bar.js' }];
+  expect(parseIssueFromResponse(issue, components).secondaryLocations).toEqual([
+    {
+      component: 'bar',
+      componentName: 'src/bar.js',
+      textRange: { endLine: 43, endOffset: 8, startLine: 43, startOffset: 6 }
+    },
+    {
+      component: 'unknown',
+      componentName: undefined,
+      textRange: { endLine: 43, endOffset: 12, startLine: 43, startOffset: 8 }
+    },
+    {
+      component: 'foo',
+      componentName: 'src/foo.js',
+      textRange: { endLine: 68, endOffset: 7, startLine: 68, startOffset: 5 }
+    },
+    {
+      component: 'foo',
+      componentName: 'src/foo.js',
+      textRange: { endLine: 70, endOffset: 16, startLine: 70, startOffset: 12 }
+    }
   ]);
 });
index a1ccedd665c0f34da76c8b18118af5c79fdc263d..3ea7c2386284ab7f1fdaf76cb42e00a94d732fa8 100644 (file)
  */
 import { flatten, sortBy } from 'lodash';
 import { SEVERITIES } from './constants';
-import { Issue } from '../app/types';
-
-interface TextRange {
-  startLine: number;
-  endLine: number;
-  startOffset: number;
-  endOffset: number;
-}
-
-interface FlowLocation {
-  msg: string;
-  textRange?: TextRange;
-}
+import { Issue, FlowLocation, TextRange, Omit } from '../app/types';
 
 interface Comment {
   login: string;
@@ -44,7 +32,10 @@ interface User {
 
 interface Rule {}
 
-interface Component {}
+interface Component {
+  key: string;
+  name: string;
+}
 
 interface IssueBase {
   severity: string;
@@ -57,7 +48,8 @@ export interface RawIssue extends IssueBase {
   comments?: Array<Comment>;
   component: string;
   flows?: Array<{
-    locations?: FlowLocation[];
+    // `componentName` is not available in RawIssue
+    locations?: Array<Omit<FlowLocation, 'componentName'>>;
   }>;
   key: string;
   line?: number;
@@ -132,11 +124,18 @@ function reverseLocations(locations: FlowLocation[]): FlowLocation[] {
 }
 
 function splitFlows(
-  issue: RawIssue
+  issue: RawIssue,
+  components: Component[] = []
 ): { secondaryLocations: FlowLocation[]; flows: FlowLocation[][] } {
-  const parsedFlows = (issue.flows || [])
-    .filter(flow => flow.locations != null)
-    .map(flow => flow.locations!.filter(location => location.textRange != null));
+  const parsedFlows: FlowLocation[][] = (issue.flows || [])
+    .filter(flow => flow.locations !== undefined)
+    .map(flow => flow.locations!.filter(location => location.textRange != null))
+    .map(flow =>
+      flow.map(location => {
+        const component = components.find(component => component.key === location.component);
+        return { ...location, componentName: component && component.name };
+      })
+    );
 
   const onlySecondaryLocations = parsedFlows.every(flow => flow.length === 1);
 
@@ -159,7 +158,7 @@ export function parseIssueFromResponse(
   users?: User[],
   rules?: Rule[]
 ): Issue {
-  const { secondaryLocations, flows } = splitFlows(issue);
+  const { secondaryLocations, flows } = splitFlows(issue, components);
   return {
     ...issue,
     ...injectRelational(issue, components, 'component', 'key'),
index 7e76dfebc6f77a37a69923b3db76477903aef4f7..397cb2e3bd75b60ffae69aff6f1b1554a6875afe 100644 (file)
@@ -620,6 +620,7 @@ issues.to_switch_flows=to switch flows
 issues.leak_period=Leak Period
 issues.my_issues=My Issues
 issues.no_my_issues=There are no issues assigned to you.
+issues.x_more_locations=+ {0} more location(s)
 
 
 #------------------------------------------------------------------------------