diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2018-03-21 14:18:13 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-03-27 20:22:33 +0200 |
commit | 16ae386379cdfd39ff29ae9e391ddea115fea1ef (patch) | |
tree | 0efce6d028aa2b3eb400025d8a9fa2b014fe2949 /server/sonar-web/src/main/js/apps/issues | |
parent | 3b9222096f56858f89867f2739a0807e68c83ae8 (diff) | |
download | sonarqube-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')
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; + } +} |