@@ -150,6 +150,8 @@ export interface FacetValue { | |||
} | |||
export interface FlowLocation { | |||
component: string; | |||
componentName?: string; | |||
msg: string; | |||
textRange: TextRange; | |||
} |
@@ -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() |
@@ -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> | |||
); |
@@ -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> | |||
); |
@@ -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', () => { |
@@ -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; | |||
} |
@@ -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> | |||
); | |||
} | |||
} | |||
} |
@@ -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> |
@@ -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> | |||
); | |||
} | |||
} | |||
} |
@@ -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( |
@@ -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(); | |||
}); |
@@ -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); | |||
}); |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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; |
@@ -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; | |||
} | |||
} |
@@ -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 |
@@ -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]; | |||
}, []); | |||
}; |
@@ -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 } | |||
} | |||
]); | |||
}); |
@@ -19,19 +19,7 @@ | |||
*/ | |||
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'), |
@@ -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) | |||
#------------------------------------------------------------------------------ |