aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/apps/issues
diff options
context:
space:
mode:
authorStas Vilchik <stas.vilchik@sonarsource.com>2018-03-21 14:18:13 +0100
committerSonarTech <sonartech@sonarsource.com>2018-03-27 20:22:33 +0200
commit16ae386379cdfd39ff29ae9e391ddea115fea1ef (patch)
tree0efce6d028aa2b3eb400025d8a9fa2b014fe2949 /server/sonar-web/src/main/js/apps/issues
parent3b9222096f56858f89867f2739a0807e68c83ae8 (diff)
downloadsonarqube-16ae386379cdfd39ff29ae9e391ddea115fea1ef.tar.gz
sonarqube-16ae386379cdfd39ff29ae9e391ddea115fea1ef.zip
SONAR-10489 Support cross file issue locations in web app
Diffstat (limited to 'server/sonar-web/src/main/js/apps/issues')
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/App.tsx18
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx40
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx58
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/__tests__/ComponentBreadcrumbs-test.tsx4
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx59
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/CrossFileLocationsNavigator.tsx190
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.tsx39
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx138
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/CrossFileLocationsNavigator-test.tsx108
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap136
-rw-r--r--server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap76
-rw-r--r--server/sonar-web/src/main/js/apps/issues/styles.css87
-rw-r--r--server/sonar-web/src/main/js/apps/issues/utils.ts29
15 files changed, 881 insertions, 105 deletions
diff --git a/server/sonar-web/src/main/js/apps/issues/components/App.tsx b/server/sonar-web/src/main/js/apps/issues/components/App.tsx
index 8eb5c82a6a9..f6ba42afa59 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/App.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/App.tsx
@@ -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()
diff --git a/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx b/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx
index ffeaae34231..b5f7a6fe21a 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx
@@ -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>
);
diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx
index 930976c7cac..6266c18322c 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx
@@ -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>
);
diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/ComponentBreadcrumbs-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/ComponentBreadcrumbs-test.tsx
index a6e4e7971ba..0e6fd7f9652 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/ComponentBreadcrumbs-test.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/ComponentBreadcrumbs-test.tsx
@@ -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', () => {
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx
index 12782ff6b57..3a2cdb9885f 100644
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.tsx
@@ -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;
}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx
index d6ff0d7b32b..60946f692f5 100644
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.tsx
@@ -18,11 +18,14 @@
* 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>
+ );
+ }
}
}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx
index 455b597b9ee..1fe90aa029e 100644
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.tsx
@@ -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
index 00000000000..a2826a6b47b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/CrossFileLocationsNavigator.tsx
@@ -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>
+ );
+ }
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.tsx b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.tsx
index d9aee370385..ad7d541f740 100644
--- a/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.tsx
@@ -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
index 00000000000..9ae00c5b9c4
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocationsNavigator-test.tsx
@@ -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
index 00000000000..f09e6d090d6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/CrossFileLocationsNavigator-test.tsx
@@ -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
index 00000000000..f2512176f96
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationsNavigator-test.tsx.snap
@@ -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
index 00000000000..e1b734ee3e5
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/CrossFileLocationsNavigator-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/styles.css b/server/sonar-web/src/main/js/apps/issues/styles.css
index 9a285c2c33e..0add575a878 100644
--- a/server/sonar-web/src/main/js/apps/issues/styles.css
+++ b/server/sonar-web/src/main/js/apps/issues/styles.css
@@ -123,12 +123,95 @@
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;
diff --git a/server/sonar-web/src/main/js/apps/issues/utils.ts b/server/sonar-web/src/main/js/apps/issues/utils.ts
index a8ca8b1754e..f148547d9e1 100644
--- a/server/sonar-web/src/main/js/apps/issues/utils.ts
+++ b/server/sonar-web/src/main/js/apps/issues/utils.ts
@@ -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;
+ }
+}