Browse Source

SONAR-9066 Display secondary locations in the issues list (#1965)

tags/6.4-RC1
Stas Vilchik 7 years ago
parent
commit
46337152f4
40 changed files with 846 additions and 954 deletions
  1. 65
    0
      server/sonar-web/src/main/js/apps/issues/actions.js
  2. 98
    29
      server/sonar-web/src/main/js/apps/issues/components/App.js
  3. 26
    4
      server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.js
  4. 3
    1
      server/sonar-web/src/main/js/apps/issues/components/SearchSelect.js
  5. 13
    4
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssue.js
  6. 49
    15
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.js
  7. 3
    2
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationBadge.js
  8. 6
    19
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.js
  9. 63
    0
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.js
  10. 68
    0
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.js
  11. 10
    32
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesList.js
  12. 20
    18
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.js
  13. 2
    1
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssue-test.js.snap
  14. 3
    3
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationBadge-test.js.snap
  15. 1
    1
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocations-test.js.snap
  16. 2
    2
      server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssuesList-test.js.snap
  17. 0
    1
      server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js
  18. 1
    1
      server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetFooter.js
  19. 1
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetFooter-test.js.snap
  20. 1
    11
      server/sonar-web/src/main/js/apps/issues/styles.css
  21. 23
    87
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
  22. 35
    43
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js
  23. 0
    312
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js
  24. 15
    14
      server/sonar-web/src/main/js/components/SourceViewer/components/Line.js
  25. 63
    83
      server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.js
  26. 0
    35
      server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.js
  27. 18
    26
      server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.js.snap
  28. 18
    18
      server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.js
  29. 15
    7
      server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js
  30. 3
    79
      server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js
  31. 1
    1
      server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js
  32. 2
    75
      server/sonar-web/src/main/js/components/SourceViewer/styles.css
  33. 45
    0
      server/sonar-web/src/main/js/components/common/LocationIndex.css
  34. 51
    0
      server/sonar-web/src/main/js/components/common/LocationIndex.js
  35. 43
    0
      server/sonar-web/src/main/js/components/common/LocationMessage.css
  36. 36
    0
      server/sonar-web/src/main/js/components/common/LocationMessage.js
  37. 3
    4
      server/sonar-web/src/main/js/components/issue/types.js
  38. 15
    17
      server/sonar-web/src/main/js/helpers/__tests__/issues-test.js
  39. 24
    8
      server/sonar-web/src/main/js/helpers/issues.js
  40. 1
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 65
- 0
server/sonar-web/src/main/js/apps/issues/actions.js View File

@@ -0,0 +1,65 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import type { State } from './components/App';

export const enableLocationsNavigator = (state: State) => ({
locationsNavigator: true,
selectedLocationIndex: state.selectedLocationIndex || 0
});

export const disableLocationsNavigator = () => ({
locationsNavigator: false
});

export const selectLocation = (nextIndex: ?number) => (state: State) => {
const { selectedLocationIndex: index, openIssue } = state;
if (openIssue) {
if (!state.locationsNavigator) {
if (nextIndex != null) {
return { locationsNavigator: true, selectedLocationIndex: nextIndex };
}
} else if (index != null) {
// disable locations when selecting (clicking) the same location
return {
locationsNavigator: nextIndex !== index,
selectedLocationIndex: nextIndex
};
}
}
};

export const selectNextLocation = (state: State) => {
const { selectedLocationIndex: index, openIssue } = state;
if (openIssue) {
return {
selectedLocationIndex: index != null && openIssue.secondaryLocations.length > index + 1
? index + 1
: index
};
}
};

export const selectPreviousLocation = (state: State) => {
const { selectedLocationIndex: index, openIssue } = state;
if (openIssue) {
return { selectedLocationIndex: index != null && index > 0 ? index - 1 : index };
}
};

+ 98
- 29
server/sonar-web/src/main/js/apps/issues/components/App.js View File

@@ -32,6 +32,7 @@ import IssuesSourceViewer from './IssuesSourceViewer';
import BulkChangeModal from './BulkChangeModal';
import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList';
import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader';
import * as actions from '../actions';
import {
parseQuery,
areMyIssuesSelected,
@@ -62,7 +63,7 @@ import { scrollToElement } from '../../../helpers/scrolling';
import type { Issue } from '../../../components/issue/types';
import '../styles.css';

type Props = {
export type Props = {
component?: Component,
currentUser: CurrentUser,
fetchIssues: () => Promise<*>,
@@ -71,21 +72,24 @@ type Props = {
router: { push: () => void, replace: () => void }
};

type State = {
export type State = {
bulkChange: 'all' | 'selected' | null,
checked: Array<string>,
facets: { [string]: Facet },
issues: Array<Issue>,
loading: boolean,
locationsNavigator: boolean,
myIssues: boolean,
openFacets: { [string]: boolean },
openIssue: ?Issue,
paging?: Paging,
query: Query,
referencedComponents: { [string]: ReferencedComponent },
referencedLanguages: { [string]: ReferencedLanguage },
referencedRules: { [string]: { name: string } },
referencedUsers: { [string]: ReferencedUser },
selected?: string
selected?: string,
selectedLocationIndex: ?number
};

const DEFAULT_QUERY = { resolved: 'false' };
@@ -103,14 +107,17 @@ export default class App extends React.PureComponent {
facets: {},
issues: [],
loading: true,
locationsNavigator: false,
myIssues: areMyIssuesSelected(props.location.query),
openFacets: { resolutions: true, types: true },
openIssue: null,
query: parseQuery(props.location.query),
referencedComponents: {},
referencedLanguages: {},
referencedRules: {},
referencedUsers: {},
selected: getOpen(props.location.query)
selected: getOpen(props.location.query),
selectedLocationIndex: null
};
}

@@ -127,12 +134,19 @@ export default class App extends React.PureComponent {
}

componentWillReceiveProps(nextProps: Props) {
const open = getOpen(nextProps.location.query);
if (open != null && open !== this.state.selected) {
this.setState({ selected: open });
const openIssue = this.getOpenIssue(nextProps, this.state.issues);

if (openIssue != null && openIssue.key !== this.state.selected) {
this.setState({ selected: openIssue.key, selectedLocationIndex: null });
}

if (openIssue == null) {
this.setState({ selectedLocationIndex: null });
}

this.setState({
myIssues: areMyIssuesSelected(nextProps.location.query),
openIssue,
query: parseQuery(nextProps.location.query)
});
}
@@ -146,8 +160,7 @@ export default class App extends React.PureComponent {
) {
this.fetchFirstIssues();
} else if (prevState.selected !== this.state.selected) {
const open = getOpen(query);
if (!open) {
if (!this.state.openIssue) {
this.scrollToSelectedIssue();
}
}
@@ -182,26 +195,64 @@ export default class App extends React.PureComponent {
this.closeIssue();
return false;
});
window.addEventListener('keydown', this.handleKeyDown);
window.addEventListener('keyup', this.handleKeyUp);
}

detachShortcuts() {
key.deleteScope('issues');
window.removeEventListener('keydown', this.handleKeyDown);
window.removeEventListener('keyup', this.handleKeyUp);
}

handleKeyDown = (event: KeyboardEvent) => {
if (key.getScope() !== 'issues') {
return;
}
if (event.keyCode === 18) {
// alt
event.preventDefault();
this.setState(actions.enableLocationsNavigator);
} else if (event.keyCode === 40 && event.altKey) {
// alt + up
event.preventDefault();
this.selectNextLocation();
} else if (event.keyCode === 38 && event.altKey) {
// alt + down
event.preventDefault();
this.selectPreviousLocation();
}
};

handleKeyUp = (event: KeyboardEvent) => {
if (key.getScope() !== 'issues') {
return;
}
if (event.keyCode === 18) {
// alt
this.setState(actions.disableLocationsNavigator);
}
};

getSelectedIndex(): ?number {
const { issues, selected } = this.state;
const index = issues.findIndex(issue => issue.key === selected);
return index !== -1 ? index : null;
}

getOpenIssue = (props: Props, issues: Array<Issue>): ?Issue => {
const open = getOpen(props.location.query);
return open ? issues.find(issue => issue.key === open) : null;
};

selectNextIssue = () => {
const { issues } = this.state;
const selectedIndex = this.getSelectedIndex();
if (issues != null && selectedIndex != null && selectedIndex < issues.length - 1) {
if (getOpen(this.props.location.query)) {
if (this.state.openIssue) {
this.openIssue(issues[selectedIndex + 1].key);
} else {
this.setState({ selected: issues[selectedIndex + 1].key });
this.setState({ selected: issues[selectedIndex + 1].key, selectedLocationIndex: null });
}
}
};
@@ -210,10 +261,10 @@ export default class App extends React.PureComponent {
const { issues } = this.state;
const selectedIndex = this.getSelectedIndex();
if (issues != null && selectedIndex != null && selectedIndex > 0) {
if (getOpen(this.props.location.query)) {
if (this.state.openIssue) {
this.openIssue(issues[selectedIndex - 1].key);
} else {
this.setState({ selected: issues[selectedIndex - 1].key });
this.setState({ selected: issues[selectedIndex - 1].key, selectedLocationIndex: null });
}
}
};
@@ -228,8 +279,7 @@ export default class App extends React.PureComponent {
open: issue
}
};
const open = getOpen(this.props.location.query);
if (open) {
if (this.state.openIssue) {
this.props.router.replace(path);
} else {
this.props.router.push(path);
@@ -308,19 +358,21 @@ export default class App extends React.PureComponent {
this.setState({ loading: true });
return this.fetchIssues({}, true).then(({ facets, issues, paging, ...other }) => {
if (this.mounted) {
const open = getOpen(this.props.location.query);
const openIssue = this.getOpenIssue(this.props, issues);
this.setState({
facets: parseFacets(facets),
loading: false,
issues,
openIssue,
paging,
referencedComponents: keyBy(other.components, 'uuid'),
referencedLanguages: keyBy(other.languages, 'key'),
referencedRules: keyBy(other.rules, 'key'),
referencedUsers: keyBy(other.users, 'login'),
selected: issues.length > 0
? issues.find(issue => issue.key === open) != null ? open : issues[0].key
: undefined
? openIssue != null ? openIssue.key : issues[0].key
: undefined,
selectedLocationIndex: null
});
}
return issues;
@@ -368,10 +420,7 @@ export default class App extends React.PureComponent {
};

fetchIssuesForComponent = (): Promise<Array<Issue>> => {
const { issues, paging } = this.state;

const open = getOpen(this.props.location.query);
const openIssue = issues.find(issue => issue.key === open);
const { issues, openIssue, paging } = this.state;

if (!openIssue || !paging) {
return Promise.reject();
@@ -508,7 +557,11 @@ export default class App extends React.PureComponent {
});
};

renderBulkChange(openIssue?: Issue) {
selectLocation = (index: ?number) => this.setState(actions.selectLocation(index));
selectNextLocation = () => this.setState(actions.selectNextLocation);
selectPreviousLocation = () => this.setState(actions.selectPreviousLocation);

renderBulkChange(openIssue: ?Issue) {
const { component, currentUser } = this.props;
const { bulkChange, checked, paging } = this.state;

@@ -597,7 +650,9 @@ export default class App extends React.PureComponent {
<ConciseIssuesList
issues={issues}
onIssueSelect={this.openIssue}
onLocationSelect={this.selectLocation}
selected={this.state.selected}
selectedLocationIndex={this.state.selectedLocationIndex}
/>
{paging != null &&
paging.total > 0 &&
@@ -606,7 +661,7 @@ export default class App extends React.PureComponent {
);
}

renderSide(openIssue?: Issue) {
renderSide(openIssue: ?Issue) {
const top = this.props.component ? 95 : 30;

return (
@@ -616,7 +671,7 @@ export default class App extends React.PureComponent {
);
}

renderList(openIssue?: Issue) {
renderList(openIssue: ?Issue) {
const { component, currentUser } = this.props;
const { issues, paging } = this.state;
const selectedIndex = this.getSelectedIndex();
@@ -648,12 +703,21 @@ export default class App extends React.PureComponent {
);
}

renderShortcutsForLocations() {
return (
<div className="pull-right note">
<span className="shortcut-button little-spacer-right">alt</span>
<span className="little-spacer-right">{'+'}</span>
<span className="shortcut-button little-spacer-right">↑</span>
<span className="shortcut-button little-spacer-right">↓</span>
{translate('issues.to_navigate_issue_locations')}
</div>
);
}

render() {
const { component } = this.props;
const { issues, paging } = this.state;

const open = getOpen(this.props.location.query);
const openIssue = issues.find(issue => issue.key === open);
const { openIssue, paging } = this.state;

const selectedIndex = this.getSelectedIndex();

@@ -677,6 +741,7 @@ export default class App extends React.PureComponent {
paging={paging}
selectedIndex={selectedIndex}
/>}
{openIssue != null && this.renderShortcutsForLocations()}
</PageMainInner>
</div>
</div>
@@ -689,6 +754,10 @@ export default class App extends React.PureComponent {
loadIssues={this.fetchIssuesForComponent}
onIssueChange={this.handleIssueChange}
onIssueSelect={this.openIssue}
onLocationSelect={this.selectLocation}
selectedLocationIndex={
this.state.locationsNavigator ? this.state.selectedLocationIndex : null
}
/>}

{this.renderList(openIssue)}

+ 26
- 4
server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.js View File

@@ -27,7 +27,9 @@ type Props = {|
loadIssues: () => Promise<*>,
onIssueChange: Issue => void,
onIssueSelect: string => void,
openIssue: Issue
onLocationSelect: number => void,
openIssue: Issue,
selectedLocationIndex: ?number
|};

export default class IssuesSourceViewer extends React.PureComponent {
@@ -35,7 +37,10 @@ export default class IssuesSourceViewer extends React.PureComponent {
props: Props;

componentDidUpdate(prevProps: Props) {
if (prevProps.openIssue.component === this.props.openIssue.component) {
if (
prevProps.openIssue !== this.props.openIssue &&
prevProps.openIssue.component === this.props.openIssue.component
) {
this.scrollToIssue();
}
}
@@ -43,12 +48,25 @@ export default class IssuesSourceViewer extends React.PureComponent {
scrollToIssue = () => {
const element = this.node.querySelector(`[data-issue="${this.props.openIssue.key}"]`);
if (element) {
scrollToElement(element, 100, 100);
this.handleScroll(element);
}
};

handleScroll = (element: HTMLElement) => {
const offset = window.innerHeight / 2;
scrollToElement(element, offset - 100, offset);
};

render() {
const { openIssue } = this.props;
const { openIssue, selectedLocationIndex } = this.props;

const locations = openIssue.secondaryLocations;

const locationMessage = locations != null &&
selectedLocationIndex != null &&
locations.length >= selectedLocationIndex
? { index: selectedLocationIndex, text: locations[selectedLocationIndex].msg }
: undefined;

return (
<div ref={node => (this.node = node)}>
@@ -56,10 +74,14 @@ export default class IssuesSourceViewer extends React.PureComponent {
aroundLine={openIssue.line}
component={openIssue.component}
displayAllIssues={true}
highlightedLocations={locations}
highlightedLocationMessage={locationMessage}
loadIssues={this.props.loadIssues}
onLoaded={this.scrollToIssue}
onLocationSelect={this.props.onLocationSelect}
onIssueChange={this.props.onIssueChange}
onIssueSelect={this.props.onIssueSelect}
scroll={this.handleScroll}
selectedIssue={openIssue.key}
/>
</div>

+ 3
- 1
server/sonar-web/src/main/js/apps/issues/components/SearchSelect.js View File

@@ -26,6 +26,7 @@ import { translate, translateWithParameters } from '../../../helpers/l10n';
type Option = { label: string, value: string };

type Props = {|
autofocus: boolean,
minimumQueryLength: number,
onSearch: (query: string) => Promise<Array<Option>>,
onSelect: (value: string) => void,
@@ -46,6 +47,7 @@ export default class SearchSelect extends React.PureComponent {
state: State;

static defaultProps = {
autofocus: true,
minimumQueryLength: 2,
resetOnBlur: true
};
@@ -95,7 +97,7 @@ export default class SearchSelect extends React.PureComponent {
render() {
return (
<Select
autofocus={true}
autofocus={this.props.autofocus}
cache={false}
className="input-super-large"
clearable={false}

+ 13
- 4
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssue.js View File

@@ -24,11 +24,13 @@ import ConciseIssueComponent from './ConciseIssueComponent';
import type { Issue } from '../../../components/issue/types';

type Props = {|
innerRef: HTMLElement => void,
issue: Issue,
onLocationSelect: number => void,
onSelect: string => void,
previousIssue: ?Issue,
selected: boolean
scroll: HTMLElement => void,
selected: boolean,
selectedLocationIndex: ?number
|};

export default class ConciseIssue extends React.PureComponent {
@@ -40,9 +42,16 @@ export default class ConciseIssue extends React.PureComponent {
const displayComponent = previousIssue == null || previousIssue.component !== issue.component;

return (
<div ref={this.props.innerRef}>
<div>
{displayComponent && <ConciseIssueComponent path={issue.componentLongName} />}
<ConciseIssueBox issue={issue} onClick={this.props.onSelect} selected={selected} />
<ConciseIssueBox
issue={issue}
onClick={this.props.onSelect}
onLocationSelect={this.props.onLocationSelect}
scroll={this.props.scroll}
selected={selected}
selectedLocationIndex={selected ? this.props.selectedLocationIndex : null}
/>
</div>
);
}

+ 49
- 15
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueBox.js View File

@@ -21,6 +21,7 @@
import React from 'react';
import classNames from 'classnames';
import ConciseIssueLocations from './ConciseIssueLocations';
import ConciseIssueLocationsNavigator from './ConciseIssueLocationsNavigator';
import SeverityHelper from '../../../components/shared/SeverityHelper';
import TypeHelper from '../../../components/shared/TypeHelper';
import type { Issue } from '../../../components/issue/types';
@@ -28,27 +29,60 @@ import type { Issue } from '../../../components/issue/types';
type Props = {|
issue: Issue,
onClick: string => void,
selected: boolean
onLocationSelect: number => void,
scroll: HTMLElement => void,
selected: boolean,
selectedLocationIndex: ?number
|};

export default function ConciseIssueBox(props: Props) {
const { issue, selected } = props;
export default class ConciseIssueBox extends React.PureComponent {
node: HTMLElement;
props: Props;

const handleClick = (event: Event) => {
componentDidMount() {
// scroll to the message element and not to the root element,
// because the root element can be huge and exceed the window height
if (this.props.selected) {
this.props.scroll(this.node);
}
}

componentDidUpdate(prevProps: Props) {
if (this.props.selected && prevProps.selected !== this.props.selected) {
this.props.scroll(this.node);
}
}

handleClick = (event: Event) => {
event.preventDefault();
props.onClick(issue.key);
this.props.onClick(this.props.issue.key);
};

const clickAttributes = selected ? {} : { onClick: handleClick, role: 'listitem', tabIndex: 0 };
render() {
const { issue, selected } = this.props;

const clickAttributes = selected
? {}
: { onClick: this.handleClick, role: 'listitem', tabIndex: 0 };

return (
<div className={classNames('concise-issue-box', { selected })} {...clickAttributes}>
<div className="concise-issue-box-message">{issue.message}</div>
<div className="concise-issue-box-attributes">
<TypeHelper type={issue.type} />
<SeverityHelper className="big-spacer-left" severity={issue.severity} />
<ConciseIssueLocations flows={issue.flows} />
return (
<div className={classNames('concise-issue-box', { selected })} {...clickAttributes}>
<div className="concise-issue-box-message" ref={node => (this.node = node)}>
{issue.message}
</div>
<div className="concise-issue-box-attributes">
<TypeHelper type={issue.type} />
<SeverityHelper className="big-spacer-left" severity={issue.severity} />
<ConciseIssueLocations issue={issue} />
</div>
{selected &&
<ConciseIssueLocationsNavigator
issue={issue}
onLocationSelect={this.props.onLocationSelect}
scroll={this.props.scroll}
selectedLocationIndex={this.props.selectedLocationIndex}
/>}
</div>
</div>
);
);
}
}

+ 3
- 2
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationBadge.js View File

@@ -20,6 +20,7 @@
// @flow
import React from 'react';
import Tooltip from '../../../components/controls/Tooltip';
import LocationIndex from '../../../components/common/LocationIndex';
import { translateWithParameters } from '../../../helpers/l10n';
import { formatMeasure } from '../../../helpers/measures';

@@ -34,9 +35,9 @@ export default function ConciseIssueLocationBadge(props: Props) {
'issue.this_issue_involves_x_code_locations',
formatMeasure(props.count)
)}>
<div className="concise-issue-location-badge">
<LocationIndex>
{'+'}{props.count}
</div>
</LocationIndex>
</Tooltip>
);
}

+ 6
- 19
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocations.js View File

@@ -20,37 +20,24 @@
// @flow
import React from 'react';
import ConciseIssueLocationBadge from './ConciseIssueLocationBadge';
import type { FlowLocation } from '../../../components/issue/types';
import type { Issue } from '../../../components/issue/types';

type Props = {|
flows: Array<{
locations?: Array<FlowLocation>
}>
issue: Issue
|};

export default class ConciseIssueLocations extends React.PureComponent {
props: Props;

render() {
const { flows } = this.props;

const secondaryLocations = flows.filter(
flow => flow.locations != null && flow.locations.length === 1
).length;

const realFlows = flows.filter(flow => flow.locations != null && flow.locations.length > 1);
const { secondaryLocations, flows } = this.props.issue;

return (
<div className="pull-right">
{secondaryLocations > 0 && <ConciseIssueLocationBadge count={secondaryLocations} />}
{secondaryLocations.length > 0 &&
<ConciseIssueLocationBadge count={secondaryLocations.length} />}

{realFlows.map((flow, index) => (
<ConciseIssueLocationBadge
// $FlowFixMe locations are not null
count={flow.locations.length}
key={index}
/>
))}
{flows.map((flow, index) => <ConciseIssueLocationBadge key={index} count={flow.length} />)}
</div>
);
}

+ 63
- 0
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigator.js View File

@@ -0,0 +1,63 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import ConciseIssueLocationsNavigatorLocation from './ConciseIssueLocationsNavigatorLocation';
import type { Issue } from '../../../components/issue/types';

type Props = {|
issue: Issue,
onLocationSelect: number => void,
scroll: HTMLElement => void,
selectedLocationIndex: ?number
|};

export default class ConciseIssueLocationsNavigator extends React.PureComponent {
props: Props;

handleClick = (index: number) => (event: Event) => {
event.preventDefault();
this.props.onLocationSelect(index);
};

render() {
const { selectedLocationIndex } = this.props;
const { secondaryLocations } = this.props.issue;

if (secondaryLocations.length === 0) {
return null;
}

return (
<div className="spacer-top">
{secondaryLocations.map((location, index) => (
<ConciseIssueLocationsNavigatorLocation
key={index}
index={index}
message={location.msg}
onClick={this.props.onLocationSelect}
scroll={this.props.scroll}
selected={index === selectedLocationIndex}
/>
))}
</div>
);
}
}

+ 68
- 0
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssueLocationsNavigatorLocation.js View File

@@ -0,0 +1,68 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import LocationIndex from '../../../components/common/LocationIndex';
import LocationMessage from '../../../components/common/LocationMessage';

type Props = {
index: number,
message: string,
onClick: number => void,
scroll: HTMLElement => void,
selected: boolean
};

export default class ConciseIssueLocationsNavigatorLocation extends React.PureComponent {
node: HTMLElement;
props: Props;

componentDidMount() {
if (this.props.selected) {
this.props.scroll(this.node);
}
}

componentDidUpdate(prevProps: Props) {
if (this.props.selected && prevProps.selected !== this.props.selected) {
this.props.scroll(this.node);
}
}

handleClick = (event: Event) => {
event.preventDefault();
this.props.onClick(this.props.index);
};

render() {
return (
<div className="little-spacer-top" ref={node => (this.node = node)}>
<a className="link-no-underline" href="#" onClick={this.handleClick}>
<LocationIndex selected={this.props.selected}>
{this.props.index + 1}
</LocationIndex>
<LocationMessage selected={this.props.selected}>
{this.props.message}
</LocationMessage>
</a>
</div>
);
}
}

+ 10
- 32
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/ConciseIssuesList.js View File

@@ -26,43 +26,19 @@ import type { Issue } from '../../../components/issue/types';
type Props = {|
issues: Array<Issue>,
onIssueSelect: string => void,
selected?: string
onLocationSelect: number => void,
selected?: string,
selectedLocationIndex: ?number
|};

export default class ConciseIssuesList extends React.PureComponent {
nodes: { [string]: HTMLElement };
props: Props;

constructor(props: Props) {
super(props);
this.nodes = {};
}

componentDidMount() {
if (this.props.selected) {
this.ensureSelectedVisible();
}
}

componentDidUpdate(prevProps: Props) {
if (this.props.selected && prevProps.selected !== this.props.selected) {
this.ensureSelectedVisible();
handleScroll = (element: HTMLElement) => {
const scrollableElement = document.querySelector('.layout-page-side');
if (element && scrollableElement) {
scrollToElement(element, 150, 100, scrollableElement);
}
}

ensureSelectedVisible() {
const { selected } = this.props;
if (selected) {
const scrollableElement = document.querySelector('.layout-page-side');
const element = this.nodes[selected];
if (element && scrollableElement) {
scrollToElement(element, 150, 100, scrollableElement);
}
}
}

innerRef = (issue: string) => (node: HTMLElement) => {
this.nodes[issue] = node;
};

render() {
@@ -71,11 +47,13 @@ export default class ConciseIssuesList extends React.PureComponent {
{this.props.issues.map((issue, index) => (
<ConciseIssue
key={issue.key}
innerRef={this.innerRef(issue.key)}
issue={issue}
onLocationSelect={this.props.onLocationSelect}
onSelect={this.props.onIssueSelect}
previousIssue={index > 0 ? this.props.issues[index - 1] : null}
scroll={this.handleScroll}
selected={issue.key === this.props.selected}
selectedLocationIndex={this.props.selectedLocationIndex}
/>
))}
</div>

+ 20
- 18
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/ConciseIssueLocations-test.js View File

@@ -17,34 +17,36 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import { shallow } from 'enzyme';
import ConciseIssueLocations from '../ConciseIssueLocations';

const textRange = { startLine: 1, startOffset: 1, endLine: 1, endOffset: 1 };

it('should render only secondary locations', () => {
const flows = [
{ locations: [{ msg: '', textRange }] },
{ locations: [{ msg: '', textRange }] },
{ locations: [{ msg: '', textRange }] }
];
expect(shallow(<ConciseIssueLocations flows={flows} />)).toMatchSnapshot();
it('should render secondary locations', () => {
const issue = {
secondaryLocations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }],
flows: []
};
expect(shallow(<ConciseIssueLocations issue={issue} />)).toMatchSnapshot();
});

it('should render one flow', () => {
const flows = [
{ locations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }] }
];
expect(shallow(<ConciseIssueLocations flows={flows} />)).toMatchSnapshot();
const issue = {
secondaryLocations: [],
flows: [[{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }]]
};
expect(shallow(<ConciseIssueLocations issue={issue} />)).toMatchSnapshot();
});

it('should render several flows', () => {
const flows = [
{ locations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }] },
{ locations: [{ msg: '', textRange }, { msg: '', textRange }] },
{ locations: [{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }] }
];
expect(shallow(<ConciseIssueLocations flows={flows} />)).toMatchSnapshot();
const issue = {
secondaryLocations: [],
flows: [
[{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }],
[{ msg: '', textRange }, { msg: '', textRange }],
[{ msg: '', textRange }, { msg: '', textRange }, { msg: '', textRange }]
]
};
expect(shallow(<ConciseIssueLocations issue={issue} />)).toMatchSnapshot();
});

+ 2
- 1
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssue-test.js.snap View File

@@ -4,6 +4,7 @@ exports[`test should render 1`] = `
<ConciseIssueBox
issue={Object {}}
onClick={[Function]}
selected={false} />
selected={false}
selectedLocationIndex={null} />
</div>
`;

+ 3
- 3
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocationBadge-test.js.snap View File

@@ -2,10 +2,10 @@ exports[`test should render 1`] = `
<Tooltip
overlay="issue.this_issue_involves_x_code_locations.7"
placement="bottom">
<div
className="concise-issue-location-badge">
<LocationIndex
selected={false}>
+
7
</div>
</LocationIndex>
</Tooltip>
`;

+ 1
- 1
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssueLocations-test.js.snap View File

@@ -6,7 +6,7 @@ exports[`test should render one flow 1`] = `
</div>
`;

exports[`test should render only secondary locations 1`] = `
exports[`test should render secondary locations 1`] = `
<div
className="pull-right">
<ConciseIssueLocationBadge

+ 2
- 2
server/sonar-web/src/main/js/apps/issues/conciseIssuesList/__tests__/__snapshots__/ConciseIssuesList-test.js.snap View File

@@ -1,16 +1,15 @@
exports[`test should render 1`] = `
<div>
<ConciseIssue
innerRef={[Function]}
issue={
Object {
"key": "foo",
}
}
previousIssue={null}
scroll={[Function]}
selected={false} />
<ConciseIssue
innerRef={[Function]}
issue={
Object {
"key": "bar",
@@ -21,6 +20,7 @@ exports[`test should render 1`] = `
"key": "foo",
}
}
scroll={[Function]}
selected={false} />
</div>
`;

+ 0
- 1
server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js View File

@@ -47,7 +47,6 @@ class LanguageFacetFooter extends React.PureComponent {
return (
<div className="search-navigator-facet-footer">
<Select
autofocus={true}
className="input-super-large"
clearable={false}
noResultsText={translate('select2.noMatches')}

+ 1
- 1
server/sonar-web/src/main/js/apps/issues/sidebar/components/FacetFooter.js View File

@@ -36,7 +36,7 @@ export default class FacetFooter extends React.PureComponent {
render() {
return (
<div className="search-navigator-facet-footer">
<SearchSelect {...this.props} />
<SearchSelect autofocus={false} {...this.props} />
</div>
);
}

+ 1
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/components/__tests__/__snapshots__/FacetFooter-test.js.snap View File

@@ -2,6 +2,7 @@ exports[`test should render 1`] = `
<div
className="search-navigator-facet-footer">
<SearchSelect
autofocus={false}
minimumQueryLength={2}
onSearch={[Function]}
onSelect={[Function]}

+ 1
- 11
server/sonar-web/src/main/js/apps/issues/styles.css View File

@@ -111,16 +111,6 @@
font-size: 12px;
}

.concise-issue-location-badge {
display: inline-block;
padding-left: 4px;
padding-right: 4px;
border-radius: 2px;
.concise-issue-box:not(.selected) .location-index {
background-color: #ccc;
color: #fff;
transition: background-color 0.3s ease;
}

.concise-issue-box.selected .concise-issue-location-badge {
background-color: #d18582;
}

+ 23
- 87
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js View File

@@ -23,7 +23,6 @@ import classNames from 'classnames';
import { intersection, uniqBy } from 'lodash';
import SourceViewerHeader from './SourceViewerHeader';
import SourceViewerCode from './SourceViewerCode';
import SourceViewerIssueLocations from './SourceViewerIssueLocations';
import CoveragePopupView from './popups/coverage-popup';
import DuplicationPopupView from './popups/duplication-popup';
import LineActionsPopupView from './popups/line-actions-popup';
@@ -34,18 +33,10 @@ import getCoverageStatus from './helpers/getCoverageStatus';
import {
issuesByLine,
locationsByLine,
locationsByIssueAndLine,
locationMessagesByIssueAndLine,
duplicationsByLine,
symbolsByLine,
findLocationByIndex
} from './helpers/indexing';
import type {
LinearIssueLocation,
IndexedIssueLocation,
IndexedIssueLocationsByIssueAndLine,
IndexedIssueLocationMessagesByIssueAndLine
symbolsByLine
} from './helpers/indexing';
import type { LinearIssueLocation } from './helpers/indexing';
import {
getComponentForSourceViewer,
getSources,
@@ -55,7 +46,7 @@ import {
import { translate } from '../../helpers/l10n';
import { scrollToElement } from '../../helpers/scrolling';
import type { SourceLine } from './types';
import type { Issue } from '../issue/types';
import type { Issue, FlowLocation } from '../issue/types';
import './styles.css';

// TODO react-virtualized
@@ -66,14 +57,18 @@ type Props = {
displayAllIssues: boolean,
filterLine?: (line: SourceLine) => boolean,
highlightedLine?: number,
highlightedLocations?: Array<FlowLocation>,
highlightedLocationMessage?: { index: number, text: string },
loadComponent: string => Promise<*>,
loadIssues: (string, number, number) => Promise<*>,
loadSources: (string, number, number) => Promise<*>,
onLoaded?: (component: Object, sources: Array<*>, issues: Array<*>) => void,
onLocationSelect?: number => void,
onIssueChange?: Issue => void,
onIssueSelect?: string => void,
onIssueUnselect?: () => void,
onReceiveComponent: ({ canMarkAsFavorite: boolean, fav: boolean, key: string }) => void,
scroll?: HTMLElement => void,
selectedIssue?: string
};

@@ -95,26 +90,19 @@ type State = {
issues?: Array<Issue>,
issuesByLine: { [number]: Array<Issue> },
issueLocationsByLine: { [number]: Array<LinearIssueLocation> },
issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine,
issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine,
loading: boolean,
loadingSourcesAfter: boolean,
loadingSourcesBefore: boolean,
locationsPanelHeight: number,
notAccessible: boolean,
notExist: boolean,
openIssuesByLine: { [number]: boolean },
selectedIssue?: string,
selectedIssueLocation: IndexedIssueLocation | null,
sources?: Array<SourceLine>,
symbolsByLine: { [number]: Array<string> }
};

const LINES = 500;

const LOCATIONS_PANEL_DEFAULT_HEIGHT = 200;
const LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY = 'sonarqube.locations.height';

const loadComponent = (key: string): Promise<*> => {
return getComponentForSourceViewer(key);
};
@@ -151,7 +139,6 @@ export default class SourceViewerBase extends React.PureComponent {
loading: true,
loadingSourcesAfter: false,
loadingSourcesBefore: false,
locationsPanelHeight: this.getInitialLocationsPanelHeight(),
notAccessible: false,
notExist: false,
openIssuesByLine: {},
@@ -168,11 +155,11 @@ export default class SourceViewerBase extends React.PureComponent {

componentWillReceiveProps(nextProps: Props) {
if (nextProps.onIssueSelect != null && nextProps.selectedIssue !== this.props.selectedIssue) {
this.setState({ selectedIssue: nextProps.selectedIssue, selectedIssueLocation: null });
this.setState({ selectedIssue: nextProps.selectedIssue });
}
}

componentDidUpdate(prevProps: Props, prevState: State) {
componentDidUpdate(prevProps: Props) {
if (prevProps.component !== this.props.component) {
this.fetchComponent();
} else if (
@@ -182,13 +169,6 @@ export default class SourceViewerBase extends React.PureComponent {
) {
this.fetchSources();
}

if (
prevState.selectedIssueLocation !== this.state.selectedIssueLocation &&
this.state.selectedIssueLocation != null
) {
this.scrollToLine(this.state.selectedIssueLocation.line);
}
}

componentWillUnmount() {
@@ -200,7 +180,7 @@ export default class SourceViewerBase extends React.PureComponent {
`.source-line-code[data-line-number="${line}"] .source-line-issue-locations`
);
if (lineElement) {
scrollToElement(lineElement, 125, this.state.locationsPanelHeight + 75);
scrollToElement(lineElement, 125, 75);
}
}

@@ -231,8 +211,6 @@ export default class SourceViewerBase extends React.PureComponent {
issues,
issuesByLine: issuesByLine(issues),
issueLocationsByLine: locationsByLine(issues),
issueSecondaryLocationsByIssueByLine: locationsByIssueAndLine(issues),
issueSecondaryLocationMessagesByIssueByLine: locationMessagesByIssueAndLine(issues),
loading: false,
hasSourcesAfter: sources.length > LINES,
sources: this.computeCoverageStatus(finalSources),
@@ -309,8 +287,14 @@ export default class SourceViewerBase extends React.PureComponent {
};

const from = this.props.aroundLine ? Math.max(1, this.props.aroundLine - LINES / 2 + 1) : 1;

let to = this.props.aroundLine ? this.props.aroundLine + LINES / 2 + 1 : LINES + 1;
// make sure we try to download `LINES` lines
if (from === 1 && to < LINES) {
to = LINES;
}
// request one additional line to define `hasSourcesAfter`
const to = this.props.aroundLine ? this.props.aroundLine + LINES / 2 + 1 : LINES + 1;
to++;

return this.props
.loadSources(this.props.component, from, to)
@@ -384,23 +368,6 @@ export default class SourceViewerBase extends React.PureComponent {
});
};

getInitialLocationsPanelHeight() {
try {
const rawValue = window.localStorage.getItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY);
if (!rawValue) {
return LOCATIONS_PANEL_DEFAULT_HEIGHT;
}
const intValue = Number(rawValue);
return !isNaN(intValue) ? intValue : LOCATIONS_PANEL_DEFAULT_HEIGHT;
} catch (e) {
return LOCATIONS_PANEL_DEFAULT_HEIGHT;
}
}

storeLocationsPanelHeight(height: number) {
window.localStorage.setItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY, height);
}

openNewWindow = () => {
const { component } = this.state;
if (component != null) {
@@ -486,27 +453,11 @@ export default class SourceViewerBase extends React.PureComponent {
popup.render();
};

handleSelectIssueLocation = (flowIndex: number, locationIndex: number) => {
this.setState(prevState => {
const selectedIssueLocation = findLocationByIndex(
prevState.issueSecondaryLocationsByIssueByLine,
flowIndex,
locationIndex
);
return { selectedIssueLocation };
});
};

handleLocationsPanelResize = (height: number) => {
this.setState({ locationsPanelHeight: height });
this.storeLocationsPanelHeight(height);
};

handleIssueSelect = (issue: string) => {
if (this.props.onIssueSelect) {
this.props.onIssueSelect(issue);
} else {
this.setState({ selectedIssue: issue, selectedIssueLocation: null });
this.setState({ selectedIssue: issue });
}
};

@@ -514,7 +465,7 @@ export default class SourceViewerBase extends React.PureComponent {
if (this.props.onIssueUnselect) {
this.props.onIssueUnselect();
} else {
this.setState({ selectedIssue: undefined, selectedIssueLocation: null });
this.setState({ selectedIssue: undefined });
}
};

@@ -554,14 +505,12 @@ export default class SourceViewerBase extends React.PureComponent {
hasSourcesAfter={this.state.hasSourcesAfter}
filterLine={this.props.filterLine}
highlightedLine={this.state.highlightedLine}
highlightedLocations={this.props.highlightedLocations}
highlightedLocationMessage={this.props.highlightedLocationMessage}
highlightedSymbols={this.state.highlightedSymbols}
issues={this.state.issues}
issuesByLine={this.state.issuesByLine}
issueLocationsByLine={this.state.issueLocationsByLine}
issueSecondaryLocationsByIssueByLine={this.state.issueSecondaryLocationsByIssueByLine}
issueSecondaryLocationMessagesByIssueByLine={
this.state.issueSecondaryLocationMessagesByIssueByLine
}
loadDuplications={this.loadDuplications}
loadSourcesAfter={this.loadSourcesAfter}
loadSourcesBefore={this.loadSourcesBefore}
@@ -575,12 +524,12 @@ export default class SourceViewerBase extends React.PureComponent {
onIssuesOpen={this.handleOpenIssues}
onIssuesClose={this.handleCloseIssues}
onLineClick={this.handleLineClick}
onLocationSelect={this.props.onLocationSelect}
onSCMClick={this.handleSCMClick}
onLocationSelect={this.handleSelectIssueLocation}
onSymbolClick={this.handleSymbolClick}
openIssuesByLine={this.state.openIssuesByLine}
scroll={this.props.scroll}
selectedIssue={this.state.selectedIssue}
selectedIssueLocation={this.state.selectedIssueLocation}
sources={sources}
symbolsByLine={this.state.symbolsByLine}
/>
@@ -610,10 +559,6 @@ export default class SourceViewerBase extends React.PureComponent {
'source-duplications-expanded': this.state.displayDuplications
});

const selectedIssueObj = this.state.selectedIssue && this.state.issues != null
? this.state.issues.find(issue => issue.key === this.state.selectedIssue)
: null;

return (
<div className={className} ref={node => (this.node = node)}>
<SourceViewerHeader
@@ -626,15 +571,6 @@ export default class SourceViewerBase extends React.PureComponent {
{translate('code_viewer.no_source_code_displayed_due_to_security')}
</div>}
{this.state.sources != null && this.renderCode(this.state.sources)}
{selectedIssueObj != null &&
selectedIssueObj.flows.length > 0 &&
<SourceViewerIssueLocations
height={this.state.locationsPanelHeight}
issue={selectedIssueObj}
onResize={this.handleLocationsPanelResize}
onSelectLocation={this.handleSelectIssueLocation}
selectedLocation={this.state.selectedIssueLocation}
/>}
</div>
);
}

+ 35
- 43
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js View File

@@ -22,14 +22,10 @@ import React from 'react';
import { intersection } from 'lodash';
import Line from './components/Line';
import { translate } from '../../helpers/l10n';
import { getLinearLocations } from './helpers/issueLocations';
import type { Duplication, SourceLine } from './types';
import type { Issue } from '../issue/types';
import type {
LinearIssueLocation,
IndexedIssueLocation,
IndexedIssueLocationsByIssueAndLine,
IndexedIssueLocationMessagesByIssueAndLine
} from './helpers/indexing';
import type { Issue, FlowLocation } from '../issue/types';
import type { LinearIssueLocation } from './helpers/indexing';

const EMPTY_ARRAY = [];

@@ -49,12 +45,12 @@ export default class SourceViewerCode extends React.PureComponent {
hasSourcesAfter: boolean,
hasSourcesBefore: boolean,
highlightedLine: number | null,
highlightedLocations?: Array<FlowLocation>,
highlightedLocationMessage?: { index: number, text: string },
highlightedSymbols: Array<string>,
issues: Array<Issue>,
issuesByLine: { [number]: Array<Issue> },
issueLocationsByLine: { [number]: Array<LinearIssueLocation> },
issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine,
issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine,
loadDuplications: (SourceLine, HTMLElement) => void,
loadSourcesAfter: () => void,
loadSourcesBefore: () => void,
@@ -68,12 +64,12 @@ export default class SourceViewerCode extends React.PureComponent {
onIssuesOpen: SourceLine => void,
onIssuesClose: SourceLine => void,
onLineClick: (SourceLine, HTMLElement) => void,
onLocationSelect?: number => void,
onSCMClick: (SourceLine, HTMLElement) => void,
onLocationSelect: (flowIndex: number, locationIndex: number) => void,
onSymbolClick: Array<string> => void,
openIssuesByLine: { [number]: boolean },
scroll?: HTMLElement => void,
selectedIssue: string | null,
selectedIssueLocation: IndexedIssueLocation | null,
sources: Array<SourceLine>,
symbolsByLine: { [number]: Array<string> }
|};
@@ -90,20 +86,19 @@ export default class SourceViewerCode extends React.PureComponent {
return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY;
}

getSecondaryIssueLocationsForLine(line: SourceLine, issueKey: string) {
const index = this.props.issueSecondaryLocationsByIssueByLine;
if (index[issueKey] == null) {
getSecondaryIssueLocationsForLine(
line: SourceLine
): Array<{ from: number, to: number, line: number, index: number, startLine: number }> {
const { highlightedLocations } = this.props;
if (!highlightedLocations) {
return EMPTY_ARRAY;
}
return index[issueKey][line.line] || EMPTY_ARRAY;
}

getSecondaryIssueLocationMessagesForLine(line: SourceLine, issueKey: string) {
const index = this.props.issueSecondaryLocationMessagesByIssueByLine;
if (index[issueKey] == null) {
return EMPTY_ARRAY;
}
return index[issueKey][line.line] || EMPTY_ARRAY;
return highlightedLocations.reduce((locations, location, index) => {
const linearLocations = getLinearLocations(location.textRange)
.filter(l => l.line === line.line)
.map(l => ({ ...l, startLine: location.textRange.startLine, index }));
return [...locations, ...linearLocations];
}, []);
}

renderLine = (
@@ -114,14 +109,10 @@ export default class SourceViewerCode extends React.PureComponent {
displayFiltered: boolean,
displayIssues: boolean
) => {
const { filterLine, selectedIssue, sources } = this.props;
const { filterLine, highlightedLocationMessage, selectedIssue, sources } = this.props;
const filtered = filterLine ? filterLine(line) : null;
const secondaryIssueLocations = selectedIssue
? this.getSecondaryIssueLocationsForLine(line, selectedIssue)
: EMPTY_ARRAY;
const secondaryIssueLocationMessages = selectedIssue
? this.getSecondaryIssueLocationMessagesForLine(line, selectedIssue)
: EMPTY_ARRAY;

const secondaryIssueLocations = this.getSecondaryIssueLocationsForLine(line);

const duplicationsCount = this.props.duplications ? this.props.duplications.length : 0;

@@ -132,7 +123,7 @@ export default class SourceViewerCode extends React.PureComponent {
const { highlightedSymbols } = this.props;
let optimizedHighlightedSymbols = intersection(symbolsForLine, highlightedSymbols);
if (!optimizedHighlightedSymbols.length) {
optimizedHighlightedSymbols = EMPTY_ARRAY;
optimizedHighlightedSymbols = undefined;
}

const optimizedSelectedIssue = selectedIssue != null &&
@@ -140,15 +131,16 @@ export default class SourceViewerCode extends React.PureComponent {
? selectedIssue
: null;

const { selectedIssueLocation } = this.props;
const optimizedSelectedIssueLocation = selectedIssueLocation != null &&
secondaryIssueLocations.some(
location =>
location.flowIndex === selectedIssueLocation.flowIndex &&
location.locationIndex === selectedIssueLocation.locationIndex
const optimizedSecondaryIssueLocations = secondaryIssueLocations.length > 0
? secondaryIssueLocations
: EMPTY_ARRAY;

const optimizedLocationMessage = highlightedLocationMessage != null &&
optimizedSecondaryIssueLocations.some(
location => location.index === highlightedLocationMessage.index
)
? selectedIssueLocation
: null;
? highlightedLocationMessage
: undefined;

return (
<Line
@@ -161,6 +153,7 @@ export default class SourceViewerCode extends React.PureComponent {
duplicationsCount={duplicationsCount}
filtered={filtered}
highlighted={line.line === this.props.highlightedLine}
highlightedLocationMessage={optimizedLocationMessage}
highlightedSymbols={optimizedHighlightedSymbols}
issueLocations={this.getIssueLocationsForLine(line)}
issues={issuesForLine}
@@ -175,15 +168,14 @@ export default class SourceViewerCode extends React.PureComponent {
onIssueUnselect={this.props.onIssueUnselect}
onIssuesOpen={this.props.onIssuesOpen}
onIssuesClose={this.props.onIssuesClose}
onSCMClick={this.props.onSCMClick}
onLocationSelect={this.props.onLocationSelect}
onSCMClick={this.props.onSCMClick}
onSymbolClick={this.props.onSymbolClick}
openIssues={this.props.openIssuesByLine[line.line] || false}
previousLine={index > 0 ? sources[index - 1] : undefined}
secondaryIssueLocations={secondaryIssueLocations}
secondaryIssueLocationMessages={secondaryIssueLocationMessages}
scroll={this.props.scroll}
secondaryIssueLocations={optimizedSecondaryIssueLocations}
selectedIssue={optimizedSelectedIssue}
selectedIssueLocation={optimizedSelectedIssueLocation}
/>
);
};

+ 0
- 312
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js View File

@@ -1,312 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer';
import { DraggableCore } from 'react-draggable';
import classNames from 'classnames';
import { throttle } from 'lodash';
import { scrollToElement } from '../../helpers/scrolling';
import { translate } from '../../helpers/l10n';
import type { Issue, FlowLocation } from '../issue/types';
import type { IndexedIssueLocation } from './helpers/indexing';

type Props = {
height: number,
issue: Issue,
onResize: (height: number) => void,
onSelectLocation: (flowIndex: number, locationIndex: number) => void,
selectedLocation: IndexedIssueLocation | null
};

type State = {
fixed: boolean,
locationBlink: boolean
};

export default class SourceViewerIssueLocations extends React.PureComponent {
fixedNode: HTMLElement;
locations: { [string]: HTMLElement };
node: HTMLElement;
props: Props;
rootNode: HTMLElement;
state: State;

constructor(props: Props) {
super(props);
this.state = { fixed: true, locationBlink: false };
this.locations = {};
this.handleScroll = throttle(this.handleScroll, 50);
}

componentDidMount() {
this.bindShortcuts();
this.listenScroll();
}

componentWillReceiveProps(nextProps: Props) {
if (nextProps.selectedLocation !== this.props.selectedLocation) {
this.setState({ locationBlink: false });
}
}

componentDidUpdate(prevProps: Props) {
if (
prevProps.selectedLocation !== this.props.selectedLocation &&
this.props.selectedLocation != null
) {
this.scrollToLocation();
}
}

componentWillUnmount() {
this.unbindShortcuts();
this.unlistenScroll();
}

bindShortcuts() {
document.addEventListener('keydown', this.handleKeyPress);
}

unbindShortcuts() {
document.removeEventListener('keydown', this.handleKeyPress);
}

listenScroll() {
window.addEventListener('scroll', this.handleScroll);
}

unlistenScroll() {
window.removeEventListener('scroll', this.handleScroll);
}

blinkLocation = () => {
this.setState({ locationBlink: true });
setTimeout(() => this.setState({ locationBlink: false }), 1000);
};

handleScroll = () => {
const rootNodeTop = this.rootNode.getBoundingClientRect().top;
const fixedNodeRect = this.fixedNode.getBoundingClientRect();
const fixedNodeTop = fixedNodeRect.top;
const fixedNodeBottom = fixedNodeRect.bottom;
this.setState((state: State) => {
if (state.fixed) {
if (rootNodeTop <= fixedNodeTop) {
return { fixed: false };
}
} else if (fixedNodeBottom >= window.innerHeight) {
return { fixed: true };
}
});
};

handleDrag = (e: Event, data: { deltaY: number }) => {
let height = this.props.height - data.deltaY;
if (height < 100) {
height = 100;
}
if (height > window.innerHeight / 2) {
height = window.innerHeight / 2;
}
this.props.onResize(height);
};

scrollToLocation() {
const { selectedLocation } = this.props;
if (selectedLocation != null) {
const key = `${selectedLocation.flowIndex}-${selectedLocation.locationIndex}`;
const locationElement = this.locations[key];
if (locationElement) {
scrollToElement(locationElement, 15, 15, this.node);
}
}
}

handleSelectPrev() {
const { issue, selectedLocation } = this.props;
if (!selectedLocation) {
if (issue.flows.length > 0) {
// move to the first location of the first flow
this.props.onSelectLocation(0, 0);
}
} else {
const currentFlow = issue.flows[selectedLocation.flowIndex];
if (
currentFlow.locations != null &&
currentFlow.locations.length > selectedLocation.locationIndex + 1
) {
// move to the next location for the same flow
this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex + 1);
} else if (selectedLocation.flowIndex > 0) {
// move to the first location of the previous flow
this.props.onSelectLocation(selectedLocation.flowIndex - 1, 0);
} else {
this.blinkLocation();
}
}
}

handleSelectNext() {
const { issue, selectedLocation } = this.props;
if (!selectedLocation) {
if (issue.flows.length > 0) {
// move to the last location of the first flow
const firstFlow = issue.flows[0];
if (firstFlow.locations != null) {
this.props.onSelectLocation(0, firstFlow.locations.length - 1);
}
}
} else if (selectedLocation.locationIndex > 0) {
// move to the previous location for the same flow
this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex - 1);
} else if (issue.flows.length > selectedLocation.flowIndex + 1) {
// move to the last location of the next flow
const nextFlow = issue.flows[selectedLocation.flowIndex + 1];
if (nextFlow.locations) {
this.props.onSelectLocation(selectedLocation.flowIndex + 1, nextFlow.locations.length - 1);
}
} else {
this.blinkLocation();
}
}

handleKeyPress = (e: Object) => {
const tagName = e.target.tagName.toUpperCase();
const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON';

if (shouldHandle) {
const selectNext = e.keyCode === 40 && e.altKey;
const selectPrev = e.keyCode === 38 && e.altKey;

if (selectNext) {
e.preventDefault();
this.handleSelectNext();
}

if (selectPrev) {
e.preventDefault();
this.handleSelectPrev();
}
}
};

reverseLocations(locations: Array<*>) {
return [...locations].reverse();
}

isLocationSelected(flowIndex: number, locationIndex: number) {
const { selectedLocation } = this.props;
if (selectedLocation == null) {
return false;
} else {
return (
selectedLocation.flowIndex === flowIndex && selectedLocation.locationIndex === locationIndex
);
}
}

handleLocationClick(flowIndex: number, locationIndex: number, e: SyntheticInputEvent) {
e.preventDefault();
this.props.onSelectLocation(flowIndex, locationIndex);
}

renderLocation = (
location: FlowLocation,
flowIndex: number,
locationIndex: number,
locations: Array<*>
) => {
const displayIndex = locations.length > 1;
const line = location.textRange ? location.textRange.startLine : null;
const key = `${flowIndex}-${locationIndex}`;
// note that locations order is reversed
const selected = this.isLocationSelected(flowIndex, locations.length - locationIndex - 1);

return (
<li key={key} ref={node => (this.locations[key] = node)} className="spacer-bottom">
{line != null && <code className="source-issue-locations-line">L{line}</code>}
<a
className={classNames('issue-location-message', 'flash', 'flash-heavy', {
selected,
in: selected && this.state.locationBlink
})}
href="#"
onClick={this.handleLocationClick.bind(
this,
flowIndex,
locations.length - locationIndex - 1
)}>
{displayIndex && <strong>{locationIndex + 1}: </strong>}
{location.msg}
</a>
</li>
);
};

render() {
const { flows } = this.props.issue;
const { height } = this.props;

const className = classNames('source-issue-locations-panel', { fixed: this.state.fixed });

return (
<AutoSizer disableHeight={true}>
{({ width }) => (
<div
ref={node => (this.rootNode = node)}
className="source-issue-locations"
style={{ width, height }}>
<div
ref={node => (this.fixedNode = node)}
className={className}
style={{ width, height }}>
<header className="source-issue-locations-header" />
<div className="source-issue-locations-shortcuts">
<span className="shortcut-button">Alt</span>
{' + '}
<span className="shortcut-button">↑</span>
{' '}
<span className="shortcut-button">↓</span>
{' '}
{translate('source_viewer.to_navigate_issue_locations')}
</div>
<ul
ref={node => (this.node = node)}
className="source-issue-locations-list"
style={{ height: height - 15 }}>
{flows.map(
(flow, flowIndex) =>
flow.locations != null &&
this.reverseLocations(flow.locations).map((location, locationIndex) =>
this.renderLocation(location, flowIndex, locationIndex, flow.locations || [])
)
)}
</ul>
<DraggableCore axis="y" onDrag={this.handleDrag} offsetParent={document.body}>
<div className="workspace-viewer-resize" />
</DraggableCore>
</div>
</div>
)}
</AutoSizer>
);
}
}

+ 15
- 14
server/sonar-web/src/main/js/components/SourceViewer/components/Line.js View File

@@ -30,11 +30,7 @@ import LineIssuesIndicator from './LineIssuesIndicator';
import LineCode from './LineCode';
import { TooltipsContainer } from '../../mixins/tooltips-mixin';
import type { SourceLine } from '../types';
import type {
LinearIssueLocation,
IndexedIssueLocation,
IndexedIssueLocationMessage
} from '../helpers/indexing';
import type { LinearIssueLocation } from '../helpers/indexing';
import type { Issue } from '../../issue/types';

type Props = {|
@@ -47,7 +43,8 @@ type Props = {|
duplicationsCount: number,
filtered: boolean | null,
highlighted: boolean,
highlightedSymbols: Array<string>,
highlightedLocationMessage?: { index: number, text: string },
highlightedSymbols?: Array<string>,
issueLocations: Array<LinearIssueLocation>,
issues: Array<Issue>,
line: SourceLine,
@@ -60,16 +57,20 @@ type Props = {|
onIssueUnselect: () => void,
onIssuesOpen: SourceLine => void,
onIssuesClose: SourceLine => void,
onLocationSelect?: number => void,
onSCMClick: (SourceLine, HTMLElement) => void,
onLocationSelect: (flowIndex: number, locationIndex: number) => void,
onSymbolClick: Array<string> => void,
openIssues: boolean,
previousLine?: SourceLine,
selectedIssue: string | null,
secondaryIssueLocations: Array<IndexedIssueLocation>,
// $FlowFixMe
secondaryIssueLocationMessages: Array<IndexedIssueLocationMessage>,
selectedIssueLocation: IndexedIssueLocation | null
scroll?: HTMLElement => void,
secondaryIssueLocations: Array<{
from: number,
to: number,
line: number,
index: number,
startLine: number
}>,
selectedIssue: string | null
|};

export default class Line extends React.PureComponent {
@@ -138,6 +139,7 @@ export default class Line extends React.PureComponent {
</td>}

<LineCode
highlightedLocationMessage={this.props.highlightedLocationMessage}
highlightedSymbols={this.props.highlightedSymbols}
issues={this.props.issues}
issueLocations={this.props.issueLocations}
@@ -146,10 +148,9 @@ export default class Line extends React.PureComponent {
onIssueSelect={this.props.onIssueSelect}
onLocationSelect={this.props.onLocationSelect}
onSymbolClick={this.props.onSymbolClick}
secondaryIssueLocationMessages={this.props.secondaryIssueLocationMessages}
scroll={this.props.scroll}
secondaryIssueLocations={this.props.secondaryIssueLocations}
selectedIssue={this.props.selectedIssue}
selectedIssueLocation={this.props.selectedIssueLocation}
showIssues={this.props.openIssues || this.props.displayAllIssues}
/>
</tr>

+ 63
- 83
server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.js View File

@@ -21,35 +21,33 @@
import React from 'react';
import classNames from 'classnames';
import LineIssuesList from './LineIssuesList';
import {
splitByTokens,
highlightSymbol,
highlightIssueLocations,
generateHTML
} from '../helpers/highlight';
import LocationIndex from '../../common/LocationIndex';
import LocationMessage from '../../common/LocationMessage';
import { splitByTokens, highlightSymbol, highlightIssueLocations } from '../helpers/highlight';
import type { Tokens } from '../helpers/highlight';
import type { SourceLine } from '../types';
import type {
LinearIssueLocation,
IndexedIssueLocation,
IndexedIssueLocationMessage
} from '../helpers/indexing';
import type { LinearIssueLocation } from '../helpers/indexing';
import type { Issue } from '../../issue/types';

type Props = {|
highlightedSymbols: Array<string>,
highlightedLocationMessage?: { index: number, text: string },
highlightedSymbols?: Array<string>,
issues: Array<Issue>,
issueLocations: Array<LinearIssueLocation>,
line: SourceLine,
onIssueChange: Issue => void,
onIssueSelect: (issueKey: string) => void,
onLocationSelect: (flowIndex: number, locationIndex: number) => void,
onLocationSelect?: number => void,
onSymbolClick: Array<string> => void,
// $FlowFixMe
secondaryIssueLocations: Array<IndexedIssueLocation>,
secondaryIssueLocationMessages: Array<IndexedIssueLocationMessage>,
scroll?: HTMLElement => void,
secondaryIssueLocations: Array<{
from: number,
to: number,
line: number,
index: number,
startLine: number
}>,
selectedIssue: string | null,
selectedIssueLocation: IndexedIssueLocation | null,
showIssues: boolean
|};

@@ -58,6 +56,7 @@ type State = {
};

export default class LineCode extends React.PureComponent {
activeMarkerNode: ?HTMLElement;
codeNode: HTMLElement;
props: Props;
state: State;
@@ -72,6 +71,9 @@ export default class LineCode extends React.PureComponent {

componentDidMount() {
this.attachEvents();
if (this.props.highlightedLocationMessage && this.activeMarkerNode && this.props.scroll) {
this.props.scroll(this.activeMarkerNode);
}
}

componentWillReceiveProps(nextProps: Props) {
@@ -86,8 +88,16 @@ export default class LineCode extends React.PureComponent {
this.detachEvents();
}

componentDidUpdate() {
componentDidUpdate(prevProps: Props) {
this.attachEvents();
if (
this.props.highlightedLocationMessage &&
prevProps.highlightedLocationMessage !== this.props.highlightedLocationMessage &&
this.activeMarkerNode &&
this.props.scroll
) {
this.props.scroll(this.activeMarkerNode);
}
}

componentWillUnmount() {
@@ -117,75 +127,38 @@ export default class LineCode extends React.PureComponent {
}
};

handleLocationMessageClick = (
e: SyntheticInputEvent,
flowIndex: number,
locationIndex: number
) => {
e.preventDefault();
this.props.onLocationSelect(flowIndex, locationIndex);
};

isSecondaryIssueLocationSelected(location: IndexedIssueLocation | IndexedIssueLocationMessage) {
const { selectedIssueLocation } = this.props;
if (selectedIssueLocation == null) {
return false;
} else {
return (
selectedIssueLocation.flowIndex === location.flowIndex &&
selectedIssueLocation.locationIndex === location.locationIndex
);
}
}

renderSecondaryIssueLocationMessage = (location: IndexedIssueLocationMessage) => {
const className = classNames('source-viewer-issue-location', 'issue-location-message', {
selected: this.isSecondaryIssueLocationSelected(location)
});

const limitString = (str: string) => (str.length > 30 ? str.substr(0, 30) + '...' : str);

renderMarker(index: number, message: ?string) {
const { onLocationSelect } = this.props;
const onClick = onLocationSelect ? () => onLocationSelect(index) : undefined;
const ref = message != null ? node => (this.activeMarkerNode = node) : undefined;
return (
<a
key={`${location.flowIndex}-${location.locationIndex}`}
href="#"
className={className}
title={location.msg}
onClick={e =>
this.handleLocationMessageClick(e, location.flowIndex, location.locationIndex)}>
{location.index && <strong>{location.index}: </strong>}
{location.msg ? limitString(location.msg) : ''}
</a>
);
};

renderSecondaryIssueLocationMessages(locations: Array<IndexedIssueLocationMessage>) {
return (
<div className="source-line-issue-locations">
{locations.map(this.renderSecondaryIssueLocationMessage)}
</div>
<LocationIndex key={`marker-${index}`} onClick={onClick} selected={message != null}>
<span href="#" ref={ref}>{index + 1}</span>
{message != null && <LocationMessage selected={true}>{message}</LocationMessage>}
</LocationIndex>
);
}

render() {
const {
highlightedLocationMessage,
highlightedSymbols,
issues,
issueLocations,
line,
onIssueSelect,
secondaryIssueLocationMessages,
secondaryIssueLocations,
selectedIssue,
selectedIssueLocation,
showIssues
} = this.props;

let tokens = [...this.state.tokens];

highlightedSymbols.forEach(symbol => {
tokens = highlightSymbol(tokens, symbol);
});
if (highlightedSymbols) {
highlightedSymbols.forEach(symbol => {
tokens = highlightSymbol(tokens, symbol);
});
}

if (issueLocations.length > 0) {
tokens = highlightIssueLocations(tokens, issueLocations);
@@ -193,32 +166,39 @@ export default class LineCode extends React.PureComponent {

if (secondaryIssueLocations) {
tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'issue-location');
if (selectedIssueLocation != null) {
const x = secondaryIssueLocations.find(location =>
this.isSecondaryIssueLocationSelected(location)

if (highlightedLocationMessage) {
const location = secondaryIssueLocations.find(
location => location.index === highlightedLocationMessage.index
);
if (x) {
tokens = highlightIssueLocations(tokens, [x], 'selected');
if (location) {
tokens = highlightIssueLocations(tokens, [location], 'selected');
}
}
}

const finalCode = generateHTML(tokens);

const className = classNames('source-line-code', 'code', {
'has-issues': issues.length > 0
});

const renderedTokens = [];
tokens.forEach((token, index) => {
if (token.markers.length > 0) {
token.markers.forEach(marker => {
const message = highlightedLocationMessage != null &&
highlightedLocationMessage.index === marker
? highlightedLocationMessage.text
: null;
renderedTokens.push(this.renderMarker(marker, message));
});
}
renderedTokens.push(<span className={token.className} key={index}>{token.text}</span>);
});

return (
<td className={className} data-line-number={line.line}>
<div className="source-line-code-inner">
<pre
ref={node => (this.codeNode = node)}
dangerouslySetInnerHTML={{ __html: finalCode }}
/>
{secondaryIssueLocationMessages != null &&
secondaryIssueLocationMessages.length > 0 &&
this.renderSecondaryIssueLocationMessages(secondaryIssueLocationMessages)}
<pre ref={node => (this.codeNode = node)}>{renderedTokens}</pre>
</div>
{showIssues &&
issues.length > 0 &&

+ 0
- 35
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.js View File

@@ -19,7 +19,6 @@
*/
import React from 'react';
import { shallow } from 'enzyme';
// import { click } from '../../../../helpers/testUtils';
import LineCode from '../LineCode';

it('render code', () => {
@@ -28,9 +27,6 @@ it('render code', () => {
code: '<span class="k">class</span> <span class="sym sym-1">Foo</span> {'
};
const issueLocations = [{ from: 0, to: 5, line: 3 }];
const secondaryIssueLocations = [{ from: 6, to: 9, line: 3 }];
const secondaryIssueLocationMessages = [{ msg: 'Fix that', flowIndex: 0, locationIndex: 0 }];
const selectedIssueLocation = { from: 6, to: 9, line: 3, flowIndex: 0, locationIndex: 0 };
const wrapper = shallow(
<LineCode
highlightedSymbols={['sym1']}
@@ -40,40 +36,9 @@ it('render code', () => {
onIssueSelect={jest.fn()}
onSelectLocation={jest.fn()}
onSymbolClick={jest.fn()}
secondaryIssueLocations={secondaryIssueLocations}
secondaryIssueLocationMessages={secondaryIssueLocationMessages}
selectedIssue="issue-1"
selectedIssueLocation={selectedIssueLocation}
showIssues={true}
/>
);
expect(wrapper).toMatchSnapshot();
});

it('should handle empty location message', () => {
const line = {
line: 3,
code: '<span class="k">class</span>'
};
const issueLocations = [{ from: 0, to: 5, line: 3 }];
const secondaryIssueLocations = [{ from: 6, to: 9, line: 3 }];
const secondaryIssueLocationMessages = [{ flowIndex: 0, locationIndex: 0 }];
const selectedIssueLocation = { from: 6, to: 9, line: 3, flowIndex: 0, locationIndex: 0 };
const wrapper = shallow(
<LineCode
highlightedSymbols={['sym1']}
issues={[{ key: 'issue-1' }, { key: 'issue-2' }]}
issueLocations={issueLocations}
line={line}
onIssueSelect={jest.fn()}
onSelectLocation={jest.fn()}
onSymbolClick={jest.fn()}
secondaryIssueLocations={secondaryIssueLocations}
secondaryIssueLocationMessages={secondaryIssueLocationMessages}
selectedIssue="issue-1"
selectedIssueLocation={selectedIssueLocation}
showIssues={true}
/>
);
expect(wrapper.find('.source-line-issue-locations')).toMatchSnapshot();
});

+ 18
- 26
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.js.snap View File

@@ -4,22 +4,24 @@ exports[`test render code 1`] = `
data-line-number={3}>
<div
className="source-line-code-inner">
<pre
dangerouslySetInnerHTML={
Object {
"__html": "<span class=\"k source-line-code-issue\">class</span><span class=\"\"> </span><span class=\"sym sym-1 issue-location\">Foo</span><span class=\"\"> {</span>",
}
} />
<div
className="source-line-issue-locations">
<a
className="source-viewer-issue-location issue-location-message selected"
href="#"
onClick={[Function]}
title="Fix that">
Fix that
</a>
</div>
<pre>
<span
className="k source-line-code-issue">
class
</span>
<span
className="">
</span>
<span
className="sym sym-1">
Foo
</span>
<span
className="">
{
</span>
</pre>
</div>
<LineIssuesList
issues={
@@ -36,13 +38,3 @@ exports[`test render code 1`] = `
selectedIssue="issue-1" />
</td>
`;

exports[`test should handle empty location message 1`] = `
<div
className="source-line-issue-locations">
<a
className="source-viewer-issue-location issue-location-message selected"
href="#"
onClick={[Function]} />
</div>
`;

+ 18
- 18
server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.js View File

@@ -24,33 +24,33 @@ describe('highlightSymbol', () => {
it('should not highlight symbols with similar beginning', () => {
// test all positions of sym-X in the string: beginning, middle and ending
const tokens = [
{ className: 'sym-18 b', text: 'foo' },
{ className: 'a sym-18', text: 'foo' },
{ className: 'a sym-18 b', text: 'foo' },
{ className: 'sym-1 d', text: 'bar' },
{ className: 'c sym-1', text: 'bar' },
{ className: 'c sym-1 d', text: 'bar' }
{ className: 'sym-18 b', markers: [], text: 'foo' },
{ className: 'a sym-18', markers: [], text: 'foo' },
{ className: 'a sym-18 b', markers: [], text: 'foo' },
{ className: 'sym-1 d', markers: [], text: 'bar' },
{ className: 'c sym-1', markers: [], text: 'bar' },
{ className: 'c sym-1 d', markers: [], text: 'bar' }
];
expect(highlightSymbol(tokens, 'sym-1')).toEqual([
{ className: 'sym-18 b', text: 'foo' },
{ className: 'a sym-18', text: 'foo' },
{ className: 'a sym-18 b', text: 'foo' },
{ className: 'sym-1 d highlighted', text: 'bar' },
{ className: 'c sym-1 highlighted', text: 'bar' },
{ className: 'c sym-1 d highlighted', text: 'bar' }
{ className: 'sym-18 b', markers: [], text: 'foo' },
{ className: 'a sym-18', markers: [], text: 'foo' },
{ className: 'a sym-18 b', markers: [], text: 'foo' },
{ className: 'sym-1 d highlighted', markers: [], text: 'bar' },
{ className: 'c sym-1 highlighted', markers: [], text: 'bar' },
{ className: 'c sym-1 d highlighted', markers: [], text: 'bar' }
]);
});

it('should highlight symbols marked twice', () => {
const tokens = [
{ className: 'sym sym-1 sym sym-2', text: 'foo' },
{ className: 'sym sym-1', text: 'bar' },
{ className: 'sym sym-2', text: 'qux' }
{ className: 'sym sym-1 sym sym-2', markers: [], text: 'foo' },
{ className: 'sym sym-1', markers: [], text: 'bar' },
{ className: 'sym sym-2', markers: [], text: 'qux' }
];
expect(highlightSymbol(tokens, 'sym-1')).toEqual([
{ className: 'sym sym-1 sym sym-2 highlighted', text: 'foo' },
{ className: 'sym sym-1 highlighted', text: 'bar' },
{ className: 'sym sym-2', text: 'qux' }
{ className: 'sym sym-1 sym sym-2 highlighted', markers: [], text: 'foo' },
{ className: 'sym sym-1 highlighted', markers: [], text: 'bar' },
{ className: 'sym sym-2', markers: [], text: 'qux' }
]);
});
});

+ 15
- 7
server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js View File

@@ -19,9 +19,9 @@
*/
// @flow
import escapeHtml from 'escape-html';
import type { LinearIssueLocation } from './indexing';
import { uniq } from 'lodash';

export type Token = { className: string, text: string };
export type Token = { className: string, markers: Array<number>, text: string };
export type Tokens = Array<Token>;

const ISSUE_LOCATION_CLASS = 'source-line-code-issue';
@@ -39,7 +39,7 @@ export const splitByTokens = (code: string, rootClassName: string = ''): Tokens
}
if (node.nodeType === 3) {
// TEXT NODE
tokens.push({ className: rootClassName, text: node.nodeValue });
tokens.push({ className: rootClassName, markers: [], text: node.nodeValue });
}
});
return tokens;
@@ -88,28 +88,36 @@ const part = (str: string, from: number, to: number, acc: number): string => {
*/
export const highlightIssueLocations = (
tokens: Tokens,
issueLocations: Array<LinearIssueLocation>,
issueLocations: Array<*>,
rootClassName: string = ISSUE_LOCATION_CLASS
): Tokens => {
issueLocations.forEach(location => {
const nextTokens = [];
let acc = 0;
let markerAdded = location.line !== location.startLine;
tokens.forEach(token => {
const x = intersect(acc, acc + token.text.length, location.from, location.to);
const p1 = part(token.text, acc, x.from, acc);
const p2 = part(token.text, x.from, x.to, acc);
const p3 = part(token.text, x.to, acc + token.text.length, acc);
if (p1.length) {
nextTokens.push({ className: token.className, text: p1 });
nextTokens.push({ ...token, text: p1 });
}
if (p2.length) {
const newClassName = token.className.indexOf(rootClassName) === -1
? `${token.className} ${rootClassName}`
: token.className;
nextTokens.push({ className: newClassName, text: p2 });
nextTokens.push({
className: newClassName,
markers: !markerAdded && location.index != null
? uniq([...token.markers, location.index])
: token.markers,
text: p2
});
markerAdded = true;
}
if (p3.length) {
nextTokens.push({ className: token.className, text: p3 });
nextTokens.push({ ...token, text: p3 });
}
acc += token.text.length;
});

+ 3
- 79
server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js View File

@@ -20,21 +20,20 @@
// @flow
import { flatten } from 'lodash';
import { splitByTokens } from './highlight';
import { getLinearLocations, getIssueLocations } from './issueLocations';
import { getLinearLocations } from './issueLocations';
import type { Issue } from '../../issue/types';
import type { SourceLine } from '../types';

export type LinearIssueLocation = {
from: number,
line: number,
to: number
to: number,
index?: number
};

export type IndexedIssueLocation = {
flowIndex: number,
from: number,
line: number,
locationIndex: number,
to: number
};

@@ -44,19 +43,6 @@ export type IndexedIssueLocationMessage = {
msg?: string
};

export type IndexedIssueLocationsByIssueAndLine = {
[issueKey: string]: {
// $FlowFixMe
[lineNumber: number]: Array<IndexedIssueLocation>
}
};

export type IndexedIssueLocationMessagesByIssueAndLine = {
[issueKey: string]: {
[lineNumber: number]: Array<IndexedIssueLocationMessage>
}
};

export const issuesByLine = (issues: Array<Issue>) => {
const index = {};
issues.forEach(issue => {
@@ -82,47 +68,6 @@ export const locationsByLine = (issues: Array<Issue>): { [number]: Array<LinearI
return index;
};

export const locationsByIssueAndLine = (
issues: Array<Issue>
): IndexedIssueLocationsByIssueAndLine => {
const index = {};
issues.forEach(issue => {
const byLine = {};
getIssueLocations(issue).forEach(location => {
getLinearLocations(location.textRange).forEach(linearLocation => {
if (!(linearLocation.line in byLine)) {
byLine[linearLocation.line] = [];
}
byLine[linearLocation.line].push({
...linearLocation,
flowIndex: location.flowIndex,
locationIndex: location.locationIndex
});
});
});
index[issue.key] = byLine;
});
return index;
};

export const locationMessagesByIssueAndLine = (
issues: Array<Issue>
): IndexedIssueLocationMessagesByIssueAndLine => {
const index = {};
issues.forEach(issue => {
const byLine = {};
getIssueLocations(issue).forEach(location => {
const line = location.textRange ? location.textRange.startLine : 0;
if (!(line in byLine)) {
byLine[line] = [];
}
byLine[line].push(location);
});
index[issue.key] = byLine;
});
return index;
};

export const duplicationsByLine = (duplications: Array<*> | null) => {
if (duplications == null) {
return {};
@@ -160,24 +105,3 @@ export const symbolsByLine = (sources: Array<SourceLine>) => {
});
return index;
};

export const findLocationByIndex = (
locations: IndexedIssueLocationsByIssueAndLine,
flowIndex: number,
locationIndex: number
) => {
const issueKeys = Object.keys(locations);
for (const issueKey of issueKeys) {
const lineNumbers = Object.keys(locations[issueKey]);
for (let lineIndex = 0; lineIndex < lineNumbers.length; lineIndex++) {
for (let i = 0; i < locations[issueKey][lineNumbers[lineIndex]].length; i++) {
const location = locations[issueKey][lineNumbers[lineIndex]][i];
if (location.flowIndex === flowIndex && location.locationIndex === locationIndex) {
return location;
}
}
}
}

return null;
};

+ 1
- 1
server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js View File

@@ -48,7 +48,7 @@ export const getIssueLocations = (
index?: number
}> => {
const allLocations = [];
issue.flows.forEach(({ locations }, flowIndex) => {
issue.flows.forEach((locations, flowIndex) => {
if (locations) {
const locationsCount = locations.length;
locations.forEach((location, index) => {

+ 2
- 75
server/sonar-web/src/main/js/components/SourceViewer/styles.css View File

@@ -1,66 +1,11 @@
.source-issue-locations {
position: relative;
}

.source-issue-locations-panel {
background-color: #fff;
box-shadow: 0 -6px 12px rgba(0, 0, 0, .175);
}

.source-issue-locations-panel.fixed {
position: fixed;
bottom: 0;
margin-left: -1px;
border-left: 1px solid #e6e6e6;
border-right: 1px solid #e6e6e6;
}

.source-issue-locations-header {
height: 15px;
padding: 0 15px;
box-sizing: border-box;
background-color: #404040;
color: #fff;
}

.source-issue-locations-shortcuts {
position: absolute;
top: 18px;
right: 18px;
padding: 6px;
background-color: #fff;
color: #777;
font-size: 11px;
}

.source-issue-locations-list {
height: 185px;
padding: 15px;
box-sizing: border-box;
overflow: auto;
}

.source-issue-locations-line {
display: inline-block;
min-width: 25px;
margin-right: 15px;
color: #777;
font-size: 12px;
text-align: right;
}

.issue-location,
.issue-location-message {
.issue-location {
display: inline-block;
vertical-align: top;
line-height: 18px;
height: 18px;
box-sizing: border-box;
background-color: #ffeaea;
}

.issue-location {
/* nothing so far */
transition: background-color 0.3s ease;
}

.issue-location.highlighted {
@@ -71,22 +16,4 @@
.issue-location.selected {
border-color: #f4b1b0;
background-color: #f4b1b0;
}

.issue-location-message {
padding: 0 10px;
border: 1px solid #ffeaea;
color: #444 !important;
font-size: 12px;
white-space: nowrap;
transition: all 0.3s ease;
}

.issue-location-message:hover {
border-color: #f4b1b0;
background-color: #f4b1b0;
}

.issue-location-message.selected {
border-color: #dd4040;
}

+ 45
- 0
server/sonar-web/src/main/js/components/common/LocationIndex.css View File

@@ -0,0 +1,45 @@
.location-index {
position: relative;
display: inline-block;
vertical-align: top;
line-height: 16px;
padding-left: 6px;
padding-right: 6px;
border-radius: 2px;
background-color: #d18582;
color: #fff;
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 12px;
transition: background-color 0.3s ease;
}

.location-index.selected {
background-color: #bc5e5e;
}

.location-index.muted {
background-color: #ccc;
}

.location-index[tabindex] {
cursor: pointer;
}

.location-index[tabindex]:hover {
background-color: #bc5e5e;
}

.location-index[tabindex]:focus {
outline: none;
}

.source-line-code .location-index {
line-height: 16px;
margin: 1px;
margin-left: 4px;
margin-right: 4px;
}

.source-line-code .location-index + .location-index {
margin-left: 0;
}

+ 51
- 0
server/sonar-web/src/main/js/components/common/LocationIndex.js View File

@@ -0,0 +1,51 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import classNames from 'classnames';
import './LocationIndex.css';

type Props = {
children?: React.Element<*>,
onClick?: () => void,
selected?: boolean
};

export default function LocationIndex(props: Props) {
const clickAttributes = props.onClick
? {
onClick: props.onClick,
role: 'button',
tabIndex: 0
}
: {};

return (
<div
className={classNames('location-index', { selected: props.selected })}
{...clickAttributes}>
{props.children}
</div>
);
}

LocationIndex.defaultProps = {
selected: false
};

+ 43
- 0
server/sonar-web/src/main/js/components/common/LocationMessage.css View File

@@ -0,0 +1,43 @@
.location-message {
display: inline-block;
vertical-align: top;
line-height: 16px;
padding: 0 6px;
border-radius: 2px;
background-color: #9e9e9e;
color: #fff;
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 12px;
transition: background-color 0.3s ease;
}

.location-message.selected {
background-color: #475760;
}

.location-index + .location-message {
margin-left: 4px;
}

.location-index > .location-message {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
}

.location-index > .location-message::after {
position: absolute;
bottom: -5px;
left: 4px;
width: 0;
height: 0;
border-top: 5px solid #475760;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
content: "";
}

.source-line-code .location-message {
padding-top: 2px;
padding-bottom: 2px;
}

+ 36
- 0
server/sonar-web/src/main/js/components/common/LocationMessage.js View File

@@ -0,0 +1,36 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import classNames from 'classnames';
import './LocationMessage.css';

type Props = {
children?: React.Element<*>,
selected: boolean
};

export default function LocationMessage(props: Props) {
return (
<div className={classNames('location-message', { selected: props.selected })}>
{props.children}
</div>
);
}

+ 3
- 4
server/sonar-web/src/main/js/components/issue/types.js View File

@@ -27,7 +27,7 @@ export type TextRange = {

export type FlowLocation = {
msg: string,
textRange?: TextRange
textRange: TextRange
};

export type IssueComment = {
@@ -59,9 +59,7 @@ export type Issue = {
creationDate: string,
effort?: string,
key: string,
flows: Array<{
locations?: Array<FlowLocation>
}>,
flows: Array<Array<FlowLocation>>,
line?: number,
message: string,
organization: string,
@@ -72,6 +70,7 @@ export type Issue = {
resolution?: string,
rule: string,
ruleName: string,
secondaryLocations: Array<FlowLocation>,
severity: string,
status: string,
subProject?: string,

+ 15
- 17
server/sonar-web/src/main/js/helpers/__tests__/issues-test.js View File

@@ -41,21 +41,19 @@ it('should populate comments data', () => {
}
]
};
expect(parseIssueFromResponse(issue, undefined, users, undefined)).toEqual({
comments: [
{
author: 'admin',
authorActive: true,
authorAvatar: 'c1244e6857f7be3dc4549d9e9d51c631',
authorLogin: 'admin',
authorName: 'Admin Admin',
createdAt: '2017-04-11T10:38:09+0200',
htmlText: 'comment!',
key: 'AVtcKbZkQmGLa7yW8J71',
login: undefined,
markdown: 'comment!',
updatable: true
}
]
});
expect(parseIssueFromResponse(issue, undefined, users, undefined).comments).toEqual([
{
author: 'admin',
authorActive: true,
authorAvatar: 'c1244e6857f7be3dc4549d9e9d51c631',
authorLogin: 'admin',
authorName: 'Admin Admin',
createdAt: '2017-04-11T10:38:09+0200',
htmlText: 'comment!',
key: 'AVtcKbZkQmGLa7yW8J71',
login: undefined,
markdown: 'comment!',
updatable: true
}
]);
});

+ 24
- 8
server/sonar-web/src/main/js/helpers/issues.js View File

@@ -18,9 +18,9 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import { sortBy } from 'lodash';
import { flatten, sortBy } from 'lodash';
import { SEVERITIES } from './constants';
import type { Issue } from '../components/issue/types';
import type { Issue, FlowLocation } from '../components/issue/types';

type TextRange = {
startLine: number,
@@ -42,11 +42,8 @@ type RawIssue = {
author: string,
comments?: Array<Comment>,
component: string,
flows: Array<{
locations: Array<{
msg: string,
textRange: TextRange
}>
flows?: Array<{
locations?: Array<{ msg: string, textRange?: TextRange }>
}>,
key: string,
line?: number,
@@ -111,12 +108,29 @@ const ensureTextRange = (issue: RawIssue) => {
: {};
};

const splitFlows = (
issue: RawIssue
// $FlowFixMe textRange is not null
): { secondaryLocations: Array<FlowLocation>, flows: Array<Array<FlowLocation>> } => {
const parsedFlows = (issue.flows || [])
.filter(flow => flow.locations != null)
// $FlowFixMe flow.locations is not null
.map(flow => flow.locations.filter(location => location.textRange != null));

const onlySecondaryLocations = parsedFlows.every(flow => flow.length === 1);

return onlySecondaryLocations
? { secondaryLocations: flatten(parsedFlows), flows: [] }
: { secondaryLocations: [], flows: parsedFlows };
};

export const parseIssueFromResponse = (
issue: Object,
components?: Array<*>,
users?: Array<*>,
rules?: Array<*>
): Issue => {
const { secondaryLocations, flows } = splitFlows(issue);
return {
...issue,
...injectRelational(issue, components, 'component', 'key'),
@@ -126,6 +140,8 @@ export const parseIssueFromResponse = (
...injectRelational(issue, users, 'assignee', 'login'),
...injectCommentsRelational(issue, users),
...prepareClosed(issue),
...ensureTextRange(issue)
...ensureTextRange(issue),
secondaryLocations,
flows
};
};

+ 1
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -694,6 +694,7 @@ issues.by_creation_date=by creation date
issues.issues=issues
issues.to_select_issues=to select issues
issues.to_navigate=to navigate
issues.to_navigate_issue_locations=to navigate issue locations
issues.leak_period=Leak Period
issues.my_issues=My Issues

@@ -2551,7 +2552,6 @@ source_viewer.tooltip.new_code=New {0}.

source_viewer.load_more_code=Load More Code
source_viewer.loading_more_code=Loading More Code...
source_viewer.to_navigate_issue_locations=to quicky navigate issue locations




Loading…
Cancel
Save