aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js
diff options
context:
space:
mode:
authorStas Vilchik <stas-vilchik@users.noreply.github.com>2017-03-02 13:18:27 +0100
committerGitHub <noreply@github.com>2017-03-02 13:18:27 +0100
commitce9f0892fc3d15638c4eaa4054ed06f3d7e5fc19 (patch)
tree0dd0d0633c514ef51071b03516d5eca5b23d64f2 /server/sonar-web/src/main/js
parent0a547ba79c4affb9b6ff0b669ee4a5e87ef16479 (diff)
downloadsonarqube-ce9f0892fc3d15638c4eaa4054ed06f3d7e5fc19.tar.gz
sonarqube-ce9f0892fc3d15638c4eaa4054ed06f3d7e5fc19.zip
refactor source viewer (#1705)
Diffstat (limited to 'server/sonar-web/src/main/js')
-rw-r--r--server/sonar-web/src/main/js/api/components.js23
-rw-r--r--server/sonar-web/src/main/js/api/issues.js22
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/App.js4
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/ComponentPin.js2
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/components/bubbleChart/BubbleChart.js2
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ListView.js17
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/details/drilldown/TreeView.js17
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/details/treemap/MeasureTreemap.js2
-rw-r--r--server/sonar-web/src/main/js/apps/component/components/App.js45
-rw-r--r--server/sonar-web/src/main/js/apps/issues/component-viewer/main.js214
-rw-r--r--server/sonar-web/src/main/js/apps/issues/controller.js9
-rw-r--r--server/sonar-web/src/main/js/apps/issues/templates/issues-issue-checkbox.hbs3
-rw-r--r--server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter.hbs6
-rw-r--r--server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js90
-rw-r--r--server/sonar-web/src/main/js/apps/issues/workspace-list-view.js23
-rw-r--r--server/sonar-web/src/main/js/apps/overview/components/App.js4
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js47
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js499
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js222
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js185
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssuesIndicator.js44
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js377
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewer.js47
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewerBase.js50
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.js37
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js115
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js119
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js59
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js76
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/types.js40
-rw-r--r--server/sonar-web/src/main/js/components/common/popup.js22
-rw-r--r--server/sonar-web/src/main/js/components/issue/ConnectedIssue.js (renamed from server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js)20
-rw-r--r--server/sonar-web/src/main/js/components/issue/Issue.js133
-rw-r--r--server/sonar-web/src/main/js/components/issue/issue-view.js55
-rw-r--r--server/sonar-web/src/main/js/components/issue/templates/issue.hbs15
-rw-r--r--server/sonar-web/src/main/js/components/issue/types.js40
-rw-r--r--server/sonar-web/src/main/js/components/shared/WithStore.js44
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/SourceViewer.js82
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/main.js17
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/measures-overlay.js3
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/more-actions.js5
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/popups/coverage-popup.js17
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/popups/duplication-popup.js20
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/popups/line-actions-popup.js6
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/popups/scm-popup.js8
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/source.js1
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-coverage-popup.hbs4
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-duplication-popup.hbs4
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-header.hbs2
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-measures.hbs22
-rw-r--r--server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-scm-popup.hbs8
-rw-r--r--server/sonar-web/src/main/js/components/workspace/main.js3
-rw-r--r--server/sonar-web/src/main/js/components/workspace/models/item.js4
-rw-r--r--server/sonar-web/src/main/js/components/workspace/models/items.js7
-rw-r--r--server/sonar-web/src/main/js/components/workspace/views/viewer-view.js55
-rw-r--r--server/sonar-web/src/main/js/helpers/issues.js121
-rw-r--r--server/sonar-web/src/main/js/helpers/request.js25
-rw-r--r--server/sonar-web/src/main/js/store/favorites/duck.js37
-rw-r--r--server/sonar-web/src/main/js/store/issues/duck.js52
-rw-r--r--server/sonar-web/src/main/js/store/rootReducer.js6
60 files changed, 2762 insertions, 476 deletions
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js
index 07ca4204d0b..c09192522e3 100644
--- a/server/sonar-web/src/main/js/api/components.js
+++ b/server/sonar-web/src/main/js/api/components.js
@@ -140,3 +140,26 @@ export function bulkChangeKey (project: string, from: string, to: string, dryRun
export const getSuggestions = (query: string): Promise<Object> => (
getJSON('/api/components/suggestions', { s: query })
);
+
+export const getComponentForSourceViewer = (component: string): Promise<*> => (
+ getJSON('/api/components/app', { component })
+);
+
+export const getSources = (component: string, from?: number, to?: number): Promise<Array<*>> => {
+ const data: Object = { key: component };
+ if (from) {
+ Object.assign(data, { from });
+ }
+ if (to) {
+ Object.assign(data, { to });
+ }
+ return getJSON('/api/sources/lines', data).then(r => r.sources);
+};
+
+export const getDuplications = (component: string): Promise<*> => (
+ getJSON('/api/duplications/show', { key: component })
+);
+
+export const getTests = (component: string, line: number | string): Promise<*> => (
+ getJSON('/api/tests/list', { sourceFileKey: component, sourceFileLineNumber: line }).then(r => r.tests)
+);
diff --git a/server/sonar-web/src/main/js/api/issues.js b/server/sonar-web/src/main/js/api/issues.js
index adcb3a1626b..75cbdec6e2d 100644
--- a/server/sonar-web/src/main/js/api/issues.js
+++ b/server/sonar-web/src/main/js/api/issues.js
@@ -20,7 +20,21 @@
// @flow
import { getJSON, post } from '../helpers/request';
-export const searchIssues = (query: {}) => (
+type IssuesResponse = {
+ components?: Array<*>,
+ debtTotal?: number,
+ facets: Array<*>,
+ issues: Array<*>,
+ paging: {
+ pageIndex: number,
+ pageSize: number,
+ total: number
+ },
+ rules?: Array<*>,
+ users?: Array<*>
+};
+
+export const searchIssues = (query: {}): Promise<IssuesResponse> => (
getJSON('/api/issues/search', query)
);
@@ -52,10 +66,10 @@ export function getTags (query: {}): Promise<*> {
export function extractAssignees (
facet: Array<{ val: string }>,
- response: { users: Array<{ login: string }> }
+ response: IssuesResponse
) {
return facet.map(item => {
- const user = response.users.find(user => user.login = item.val);
+ const user = response.users ? response.users.find(user => user.login = item.val) : null;
return { ...item, user };
});
}
@@ -67,7 +81,7 @@ export function getAssignees (query: {}): Promise<*> {
export function getIssuesCount (query: {}): Promise<*> {
const data = { ...query, ps: 1, facetMode: 'effort' };
return searchIssues(data).then(r => {
- return { issues: r.total, debt: r.debtTotal };
+ return { issues: r.paging.total, debt: r.debtTotal };
});
}
diff --git a/server/sonar-web/src/main/js/apps/code/components/App.js b/server/sonar-web/src/main/js/apps/code/components/App.js
index d4a107033d1..4208e8d409b 100644
--- a/server/sonar-web/src/main/js/apps/code/components/App.js
+++ b/server/sonar-web/src/main/js/apps/code/components/App.js
@@ -22,7 +22,7 @@ import React from 'react';
import { connect } from 'react-redux';
import Components from './Components';
import Breadcrumbs from './Breadcrumbs';
-import SourceViewer from './../../../components/source-viewer/SourceViewer';
+import SourceViewer from './../../../components/SourceViewer/StandaloneSourceViewer';
import Search from './Search';
import ListFooter from '../../../components/controls/ListFooter';
import { retrieveComponentChildren, retrieveComponent, loadMoreChildren, parseError } from '../utils';
@@ -203,7 +203,7 @@ class App extends React.Component {
{shouldShowSourceViewer && (
<div className="spacer-top">
- <SourceViewer component={sourceViewer}/>
+ <SourceViewer component={sourceViewer.key}/>
</div>
)}
</div>
diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentPin.js b/server/sonar-web/src/main/js/apps/code/components/ComponentPin.js
index 33adce19b8a..20670ac9bcb 100644
--- a/server/sonar-web/src/main/js/apps/code/components/ComponentPin.js
+++ b/server/sonar-web/src/main/js/apps/code/components/ComponentPin.js
@@ -25,7 +25,7 @@ import { translate } from '../../../helpers/l10n';
const ComponentPin = ({ component }) => {
const handleClick = e => {
e.preventDefault();
- Workspace.openComponent({ uuid: component.id });
+ Workspace.openComponent({ key: component.key });
};
return (
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/bubbleChart/BubbleChart.js b/server/sonar-web/src/main/js/apps/component-measures/components/bubbleChart/BubbleChart.js
index d8ad4c31afc..4cd674b7c1f 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/bubbleChart/BubbleChart.js
+++ b/server/sonar-web/src/main/js/apps/component-measures/components/bubbleChart/BubbleChart.js
@@ -118,7 +118,7 @@ export default class BubbleChart extends React.Component {
handleBubbleClick (component) {
if (['FIL', 'UTS'].includes(component.qualifier)) {
- Workspace.openComponent({ uuid: component.id });
+ Workspace.openComponent({ key: component.key });
} else {
window.location = getComponentUrl(component.refKey || component.key);
}
diff --git a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ListView.js b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ListView.js
index 149582321dc..bbfc0ae32bf 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ListView.js
+++ b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ListView.js
@@ -19,10 +19,11 @@
*/
import React from 'react';
import classNames from 'classnames';
+import moment from 'moment';
import ComponentsList from './ComponentsList';
import ListHeader from './ListHeader';
import Spinner from '../../components/Spinner';
-import SourceViewer from '../../../../components/source-viewer/SourceViewer';
+import SourceViewer from '../../../../components/SourceViewer/StandaloneSourceViewer';
import ListFooter from '../../../../components/controls/ListFooter';
export default class ListView extends React.Component {
@@ -104,6 +105,16 @@ export default class ListView extends React.Component {
}
const selectedIndex = components.indexOf(selected);
const sourceViewerPeriod = metric.key.indexOf('new_') === 0 && !!leakPeriod ? leakPeriod : null;
+ const sourceViewerPeriodDate = sourceViewerPeriod != null ? moment(sourceViewerPeriod.date).toDate() : null;
+
+ const filterLine = sourceViewerPeriodDate != null ? line => {
+ if (line.scmDate) {
+ const scmDate = moment(line.scmDate).toDate();
+ return scmDate >= sourceViewerPeriodDate;
+ } else {
+ return false;
+ }
+ } : undefined;
return (
<div ref="container" className="measure-details-plain-list">
@@ -140,8 +151,8 @@ export default class ListView extends React.Component {
{!!selected && (
<div className="measure-details-viewer">
<SourceViewer
- component={selected}
- period={sourceViewerPeriod}/>
+ component={selected.key}
+ filterLine={filterLine}/>
</div>
)}
</div>
diff --git a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/TreeView.js b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/TreeView.js
index 554a2904227..fb0bb744bd0 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/TreeView.js
+++ b/server/sonar-web/src/main/js/apps/component-measures/details/drilldown/TreeView.js
@@ -18,10 +18,11 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import React from 'react';
+import moment from 'moment';
import ComponentsList from './ComponentsList';
import ListHeader from './ListHeader';
import Spinner from '../../components/Spinner';
-import SourceViewer from '../../../../components/source-viewer/SourceViewer';
+import SourceViewer from '../../../../components/SourceViewer/StandaloneSourceViewer';
import ListFooter from '../../../../components/controls/ListFooter';
export default class TreeView extends React.Component {
@@ -97,6 +98,16 @@ export default class TreeView extends React.Component {
const selectedIndex = components.indexOf(selected);
const sourceViewerPeriod = metric.key.indexOf('new_') === 0 && !!leakPeriod ? leakPeriod : null;
+ const sourceViewerPeriodDate = sourceViewerPeriod != null ? moment(sourceViewerPeriod.date).toDate() : null;
+
+ const filterLine = sourceViewerPeriodDate != null ? line => {
+ if (line.scmDate) {
+ const scmDate = moment(line.scmDate).toDate();
+ return scmDate >= sourceViewerPeriodDate;
+ } else {
+ return false;
+ }
+ } : undefined;
return (
<div ref="container" className="measure-details-plain-list">
@@ -133,8 +144,8 @@ export default class TreeView extends React.Component {
{!!selected && (
<div className="measure-details-viewer">
<SourceViewer
- component={selected}
- period={sourceViewerPeriod}/>
+ component={selected.key}
+ filterLine={filterLine}/>
</div>
)}
</div>
diff --git a/server/sonar-web/src/main/js/apps/component-measures/details/treemap/MeasureTreemap.js b/server/sonar-web/src/main/js/apps/component-measures/details/treemap/MeasureTreemap.js
index 2255ef5c4c8..b599af3cd2b 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/details/treemap/MeasureTreemap.js
+++ b/server/sonar-web/src/main/js/apps/component-measures/details/treemap/MeasureTreemap.js
@@ -134,7 +134,7 @@ export default class MeasureTreemap extends React.Component {
const isFile = node.qualifier === 'FIL' || node.qualifier === 'UTS';
if (isFile) {
- Workspace.openComponent({ uuid: node.id });
+ Workspace.openComponent({ key: node.key });
return;
}
diff --git a/server/sonar-web/src/main/js/apps/component/components/App.js b/server/sonar-web/src/main/js/apps/component/components/App.js
index d889e4df77f..041625d8243 100644
--- a/server/sonar-web/src/main/js/apps/component/components/App.js
+++ b/server/sonar-web/src/main/js/apps/component/components/App.js
@@ -19,32 +19,43 @@
*/
// @flow
import React from 'react';
-import SourceViewer from '../../../components/source-viewer/SourceViewer';
-import { getComponentNavigation } from '../../../api/nav';
+import SourceViewer from '../../../components/SourceViewer/StandaloneSourceViewer';
export default class App extends React.Component {
- static propTypes = {
- location: React.PropTypes.object.isRequired
- };
-
- state = {};
-
- componentDidMount () {
- getComponentNavigation(this.props.location.query.id).then(component => (
- this.setState({ component })
- ));
+ props: {
+ location: {
+ query: {
+ id: string,
+ line?: string
+ }
+ }
}
- render () {
- if (!this.state.component) {
- return null;
+ scrollToLine = () => {
+ const { line } = this.props.location.query;
+ if (line) {
+ const row = document.querySelector(`.source-line[data-line-number="${line}"]`);
+ if (row) {
+ const rect = row.getBoundingClientRect();
+ const topOffset = window.innerHeight / 2 - 60;
+ const goal = rect.top - topOffset;
+ window.scrollTo(0, goal);
+ }
}
+ };
- const { line } = this.props.location.query;
+ render () {
+ const { id, line } = this.props.location.query;
+
+ const finalLine = line != null ? Number(line) : null;
return (
<div className="page">
- <SourceViewer component={{ id: this.state.component.id }} line={line}/>
+ <SourceViewer
+ aroundLine={finalLine}
+ component={id}
+ highlightedLine={finalLine}
+ onLoaded={this.scrollToLine}/>
</div>
);
}
diff --git a/server/sonar-web/src/main/js/apps/issues/component-viewer/main.js b/server/sonar-web/src/main/js/apps/issues/component-viewer/main.js
index 0f08c21112a..49d7152c45f 100644
--- a/server/sonar-web/src/main/js/apps/issues/component-viewer/main.js
+++ b/server/sonar-web/src/main/js/apps/issues/component-viewer/main.js
@@ -18,186 +18,114 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import $ from 'jquery';
-import SourceViewer from '../../../components/source-viewer/main';
-import IssueView from './issue-view';
-
-export default SourceViewer.extend({
- events () {
- return {
- ...SourceViewer.prototype.events.apply(this, arguments),
- 'click .js-close-component-viewer': 'closeComponentViewer',
- 'click .code-issue': 'selectIssue'
- };
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import Marionette from 'backbone.marionette';
+import SourceViewer from '../../../components/SourceViewer/SourceViewer';
+import WithStore from '../../../components/shared/WithStore';
+
+export default Marionette.ItemView.extend({
+ template () {
+ return '<div></div>';
},
initialize (options) {
- SourceViewer.prototype.initialize.apply(this, arguments);
- return this.listenTo(options.app.state, 'change:selectedIndex', this.select);
+ this.handleLoadIssues = this.handleLoadIssues.bind(this);
+ this.scrollToBaseIssue = this.scrollToBaseIssue.bind(this);
+ this.selectIssue = this.selectIssue.bind(this);
+ this.listenTo(options.app.state, 'change:selectedIndex', this.select);
},
- onLoaded () {
- SourceViewer.prototype.onLoaded.apply(this, arguments);
- this.bindShortcuts();
- if (this.baseIssue != null) {
- this.baseIssue.trigger('locations', this.baseIssue);
- this.scrollToLine(this.baseIssue.get('line'));
+ onRender () {
+ this.showViewer();
+ },
+
+ onDestroy () {
+ this.unbindShortcuts();
+ unmountComponentAtNode(this.el);
+ },
+
+ handleLoadIssues (component: string) {
+ // TODO fromLine: number, toLine: number
+ const issues = this.options.app.list.toJSON().filter(issue => issue.componentKey === component);
+ return Promise.resolve(issues);
+ },
+
+ showViewer (onLoaded) {
+ if (!this.baseIssue) {
+ return;
}
+
+ const componentKey = this.baseIssue.get('component');
+
+ render((
+ <WithStore>
+ <SourceViewer
+ aroundLine={this.baseIssue.get('line')}
+ component={componentKey}
+ displayAllIssues={true}
+ loadIssues={this.handleLoadIssues}
+ onLoaded={onLoaded}
+ onIssueSelect={this.selectIssue}
+ selectedIssue={this.baseIssue.get('key')}/>
+ </WithStore>
+ ), this.el);
+ },
+
+ openFileByIssue (issue) {
+ this.baseIssue = issue;
+ this.selectedIssue = issue.get('key');
+ this.showViewer(this.scrollToBaseIssue);
+ this.bindShortcuts();
},
bindShortcuts () {
- const that = this;
- const doAction = function (action) {
- const selectedIssueView = that.getSelectedIssueEl();
- if (!selectedIssueView) {
- return;
- }
- selectedIssueView.find('.js-issue-' + action).click();
- };
key('up', 'componentViewer', () => {
- that.options.app.controller.selectPrev();
+ this.options.app.controller.selectPrev();
return false;
});
key('down', 'componentViewer', () => {
- that.options.app.controller.selectNext();
+ this.options.app.controller.selectNext();
return false;
});
key('left,backspace', 'componentViewer', () => {
- that.options.app.controller.closeComponentViewer();
+ this.options.app.controller.closeComponentViewer();
return false;
});
- key('f', 'componentViewer', () => doAction('transition'));
- key('a', 'componentViewer', () => doAction('assign'));
- key('m', 'componentViewer', () => doAction('assign-to-me'));
- key('p', 'componentViewer', () => doAction('plan'));
- key('i', 'componentViewer', () => doAction('set-severity'));
- key('c', 'componentViewer', () => doAction('comment'));
},
unbindShortcuts () {
- return key.deleteScope('componentViewer');
- },
-
- onDestroy () {
- SourceViewer.prototype.onDestroy.apply(this, arguments);
- this.unbindScrollEvents();
- return this.unbindShortcuts();
+ key.deleteScope('componentViewer');
},
select () {
const selected = this.options.app.state.get('selectedIndex');
const selectedIssue = this.options.app.list.at(selected);
- if (selectedIssue.get('component') === this.model.get('key')) {
- selectedIssue.trigger('locations', selectedIssue);
- return this.scrollToIssue(selectedIssue.get('key'));
- } else {
- this.unbindShortcuts();
- return this.options.app.controller.showComponentViewer(selectedIssue);
- }
- },
-
- getSelectedIssueEl () {
- const selected = this.options.app.state.get('selectedIndex');
- if (selected == null) {
- return null;
- }
- const selectedIssue = this.options.app.list.at(selected);
- if (selectedIssue == null) {
- return null;
- }
- const selectedIssueView = this.$('#issue-' + (selectedIssue.get('key')));
- if (selectedIssueView.length > 0) {
- return selectedIssueView;
- } else {
- return null;
- }
- },
-
- selectIssue (e) {
- const key = $(e.currentTarget).data('issue-key');
- const issue = this.issues.find(model => model.get('key') === key);
- const index = this.options.app.list.indexOf(issue);
- return this.options.app.state.set({ selectedIndex: index });
- },
-
- scrollToIssue (key) {
- const el = this.$('#issue-' + key);
- if (el.length > 0) {
- const line = el.closest('[data-line-number]').data('line-number');
- return this.scrollToLine(line);
- } else {
- this.unbindShortcuts();
- const selected = this.options.app.state.get('selectedIndex');
- const selectedIssue = this.options.app.list.at(selected);
- return this.options.app.controller.showComponentViewer(selectedIssue);
- }
- },
-
- openFileByIssue (issue) {
- this.baseIssue = issue;
- const componentKey = issue.get('component');
- const componentUuid = issue.get('componentUuid');
- return this.open(componentUuid, componentKey);
- },
-
- linesLimit () {
- let line = this.LINES_LIMIT / 2;
- if ((this.baseIssue != null) && this.baseIssue.has('line')) {
- line = Math.max(line, this.baseIssue.get('line'));
- }
- return {
- from: line - this.LINES_LIMIT / 2 + 1,
- to: line + this.LINES_LIMIT / 2
- };
- },
- limitIssues (issues) {
- const that = this;
- let index = this.ISSUES_LIMIT / 2;
- if ((this.baseIssue != null) && this.baseIssue.has('index')) {
- index = Math.max(index, this.baseIssue.get('index'));
- }
- return issues.filter(issue => Math.abs(issue.get('index') - index) <= that.ISSUES_LIMIT / 2);
- },
-
- requestIssues () {
- const that = this;
- let r;
- if (this.options.app.list.last().get('component') === this.model.get('key')) {
- r = this.options.app.controller.fetchNextPage();
+ if (selectedIssue.get('component') === this.baseIssue.get('component')) {
+ this.baseIssue = selectedIssue;
+ this.showViewer(this.scrollToBaseIssue);
+ this.scrollToBaseIssue();
} else {
- r = $.Deferred().resolve().promise();
+ this.options.app.controller.showComponentViewer(selectedIssue);
}
- return r.done(() => {
- that.issues.reset(that.options.app.list.filter(issue => issue.get('component') === that.model.key()));
- that.issues.reset(that.limitIssues(that.issues));
- return that.addIssuesPerLineMeta(that.issues);
- });
- },
-
- renderIssues () {
- this.issues.forEach(this.renderIssue, this);
- return this.$('.source-line-issues').addClass('hidden');
- },
-
- renderIssue (issue) {
- const issueView = new IssueView({
- el: '#issue-' + issue.get('key'),
- model: issue,
- app: this.options.app
- });
- this.issueViews.push(issueView);
- return issueView.render();
},
scrollToLine (line) {
const row = this.$(`[data-line-number=${line}]`);
const topOffset = $(window).height() / 2 - 60;
const goal = row.length > 0 ? row.offset().top - topOffset : 0;
- return $(window).scrollTop(goal);
+ $(window).scrollTop(goal);
+ },
+
+ selectIssue (issueKey) {
+ const issue = this.options.app.list.find(model => model.get('key') === issueKey);
+ const index = this.options.app.list.indexOf(issue);
+ this.options.app.state.set({ selectedIndex: index });
},
- closeComponentViewer () {
- return this.options.app.controller.closeComponentViewer();
+ scrollToBaseIssue () {
+ this.scrollToLine(this.baseIssue.get('line'));
}
});
diff --git a/server/sonar-web/src/main/js/apps/issues/controller.js b/server/sonar-web/src/main/js/apps/issues/controller.js
index edc86827050..71df7acdb7c 100644
--- a/server/sonar-web/src/main/js/apps/issues/controller.js
+++ b/server/sonar-web/src/main/js/apps/issues/controller.js
@@ -44,14 +44,7 @@ export default Controller.extend({
this.options.app.state.set({ selectedIndex: 0, page: 1 }, { silent: true });
this.closeComponentViewer();
}
- const data = this._issuesParameters();
- Object.assign(data, this.options.app.state.get('query'));
- if (this.options.app.state.get('query').assigned_to_me) {
- Object.assign(data, { assignees: '__me__' });
- }
- if (this.options.app.state.get('isContext')) {
- Object.assign(data, this.options.app.state.get('contextQuery'));
- }
+ const data = this.getQueryAsObject();
return $.get(window.baseUrl + '/api/issues/search', data).done(r => {
const issues = that.options.app.list.parseIssues(r);
if (firstPage) {
diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-checkbox.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-checkbox.hbs
deleted file mode 100644
index dbb50e24779..00000000000
--- a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-checkbox.hbs
+++ /dev/null
@@ -1,3 +0,0 @@
-<div class="js-toggle issue-checkbox-container">
- <i class="issue-checkbox icon-checkbox {{#if selected}}icon-checkbox-checked{{/if}}"></i>
-</div>
diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter.hbs
deleted file mode 100644
index 16a212ddd60..00000000000
--- a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-<li class="issue-meta">
- <button class="button-link issue-action issue-action-with-options js-issue-filter"
- aria-label="{{t "issue.filter_similar_issues"}}">
- <i class="icon-filter icon-half-transparent"></i>&nbsp;<i class="icon-dropdown"></i>
- </button>
-</li>
diff --git a/server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js b/server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js
index 9c57d181aa9..43817e46586 100644
--- a/server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js
+++ b/server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js
@@ -18,10 +18,12 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import $ from 'jquery';
-import IssueView from '../../components/issue/issue-view';
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import Marionette from 'backbone.marionette';
+import Issue from '../../components/issue/Issue';
import IssueFilterView from './issue-filter-view';
-import CheckboxTemplate from './templates/issues-issue-checkbox.hbs';
-import FilterTemplate from './templates/issues-issue-filter.hbs';
+import WithStore from '../../components/shared/WithStore';
const SHOULD_NULL = {
any: ['issues'],
@@ -31,35 +33,43 @@ const SHOULD_NULL = {
assigned: ['assignees']
};
-export default IssueView.extend({
- checkboxTemplate: CheckboxTemplate,
- filterTemplate: FilterTemplate,
+export default Marionette.ItemView.extend({
+ className: 'issues-workspace-list-item',
- events () {
- return {
- ...IssueView.prototype.events.apply(this, arguments),
- 'click': 'selectCurrent',
- 'dblclick': 'openComponentViewer',
- 'click .js-issue-navigate': 'openComponentViewer',
- 'click .js-issue-filter': 'onIssueFilterClick',
- 'click .js-toggle': 'onIssueToggle'
- };
+ initialize (options) {
+ this.openComponentViewer = this.openComponentViewer.bind(this);
+ this.onIssueFilterClick = this.onIssueFilterClick.bind(this);
+ this.onIssueCheck = this.onIssueCheck.bind(this);
+ this.listenTo(options.app.state, 'change:selectedIndex', this.showIssue);
+ this.listenTo(this.model, 'change:selected', this.showIssue);
},
- initialize (options) {
- IssueView.prototype.initialize.apply(this, arguments);
- this.listenTo(options.app.state, 'change:selectedIndex', this.select);
+ template () {
+ return '<div></div>';
},
onRender () {
- IssueView.prototype.onRender.apply(this, arguments);
- this.select();
- this.addFilterSelect();
- this.addCheckbox();
- this.$el.addClass('issue-navigate-right');
- if (this.options.app.state.get('canBulkChange')) {
- this.$el.addClass('issue-with-checkbox');
- }
+ this.showIssue();
+ },
+
+ onDestroy () {
+ unmountComponentAtNode(this.el);
+ },
+
+ showIssue () {
+ const selected = this.model.get('index') === this.options.app.state.get('selectedIndex');
+
+ render((
+ <WithStore>
+ <Issue
+ issue={this.model}
+ checked={this.model.get('selected')}
+ onCheck={this.onIssueCheck}
+ onClick={this.openComponentViewer}
+ onFilterClick={this.onIssueFilterClick}
+ selected={selected}/>
+ </WithStore>
+ ), this.el);
},
onIssueFilterClick (e) {
@@ -89,26 +99,21 @@ export default IssueView.extend({
this.popup.render();
},
- onIssueToggle (e) {
+ onIssueCheck (e) {
e.preventDefault();
+ e.stopPropagation();
this.model.set({ selected: !this.model.get('selected') });
const selected = this.model.collection.where({ selected: true }).length;
this.options.app.state.set({ selected });
},
- addFilterSelect () {
- this.$('.issue-table-meta-cell-first')
- .find('.issue-meta-list')
- .append(this.filterTemplate(this.model.toJSON()));
- },
-
- addCheckbox () {
- this.$el.append(this.checkboxTemplate(this.model.toJSON()));
- },
-
- select () {
+ changeSelection () {
const selected = this.model.get('index') === this.options.app.state.get('selectedIndex');
- this.$el.toggleClass('selected', selected);
+ if (selected) {
+ this.select();
+ } else {
+ this.unselect();
+ }
},
selectCurrent () {
@@ -137,12 +142,5 @@ export default IssueView.extend({
} else {
return this.options.app.controller.showComponentViewer(this.model);
}
- },
-
- serializeData () {
- return {
- ...IssueView.prototype.serializeData.apply(this, arguments),
- showComponent: true
- };
}
});
diff --git a/server/sonar-web/src/main/js/apps/issues/workspace-list-view.js b/server/sonar-web/src/main/js/apps/issues/workspace-list-view.js
index 669f4c139e6..383d3145f24 100644
--- a/server/sonar-web/src/main/js/apps/issues/workspace-list-view.js
+++ b/server/sonar-web/src/main/js/apps/issues/workspace-list-view.js
@@ -37,14 +37,6 @@ export default WorkspaceListView.extend({
bindShortcuts () {
const that = this;
- const doAction = function (action) {
- const selectedIssue = that.collection.at(that.options.app.state.get('selectedIndex'));
- if (selectedIssue == null) {
- return;
- }
- const selectedIssueView = that.children.findByModel(selectedIssue);
- selectedIssueView.$('.js-issue-' + action).click();
- };
WorkspaceListView.prototype.bindShortcuts.apply(this, arguments);
key('right', 'list', () => {
const selectedIssue = that.collection.at(that.options.app.state.get('selectedIndex'));
@@ -56,26 +48,12 @@ export default WorkspaceListView.extend({
selectedIssue.set({ selected: !selectedIssue.get('selected') });
return false;
});
- key('f', 'list', () => doAction('transition'));
- key('a', 'list', () => doAction('assign'));
- key('m', 'list', () => doAction('assign-to-me'));
- key('p', 'list', () => doAction('plan'));
- key('i', 'list', () => doAction('set-severity'));
- key('c', 'list', () => doAction('comment'));
- key('t', 'list', () => doAction('edit-tags'));
},
unbindShortcuts () {
WorkspaceListView.prototype.unbindShortcuts.apply(this, arguments);
key.unbind('right', 'list');
key.unbind('space', 'list');
- key.unbind('f', 'list');
- key.unbind('a', 'list');
- key.unbind('m', 'list');
- key.unbind('p', 'list');
- key.unbind('i', 'list');
- key.unbind('c', 'list');
- key.unbind('t', 'list');
},
scrollTo () {
@@ -122,7 +100,6 @@ export default WorkspaceListView.extend({
displayComponent (container, model) {
const data = { ...model.toJSON() };
- /* eslint-disable no-console */
const qualifier = this.options.app.state.get('contextComponentQualifier');
if (qualifier === 'VW' || qualifier === 'SVW') {
Object.assign(data, { organization: undefined });
diff --git a/server/sonar-web/src/main/js/apps/overview/components/App.js b/server/sonar-web/src/main/js/apps/overview/components/App.js
index 84147530647..91e636eb52a 100644
--- a/server/sonar-web/src/main/js/apps/overview/components/App.js
+++ b/server/sonar-web/src/main/js/apps/overview/components/App.js
@@ -54,10 +54,10 @@ class App extends React.Component {
const { component } = this.props;
if (['FIL', 'UTS'].includes(component.qualifier)) {
- const SourceViewer = require('../../../components/source-viewer/SourceViewer').default;
+ const SourceViewer = require('../../../components/SourceViewer/StandaloneSourceViewer').default;
return (
<div className="page">
- <SourceViewer component={component}/>
+ <SourceViewer component={component.key}/>
</div>
);
}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js
new file mode 100644
index 00000000000..2c6e5b594a6
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js
@@ -0,0 +1,47 @@
+/*
+ * 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 { connect } from 'react-redux';
+import SourceViewerBase from './SourceViewerBase';
+import { receiveFavorites } from '../../store/favorites/duck';
+import { receiveIssues } from '../../store/issues/duck';
+
+const mapStateToProps = null;
+
+const onReceiveComponent = (component: { key: string, canMarkAsFavorite: boolean, fav: boolean }) => dispatch => {
+ if (component.canMarkAsFavorite) {
+ const favorites = [];
+ const notFavorites = [];
+ if (component.fav) {
+ favorites.push({ key: component.key });
+ } else {
+ notFavorites.push({ key: component.key });
+ }
+ dispatch(receiveFavorites(favorites, notFavorites));
+ }
+};
+
+const onReceiveIssues = (issues: Array<*>) => dispatch => {
+ dispatch(receiveIssues(issues));
+};
+
+const mapDispatchToProps = { onReceiveComponent, onReceiveIssues };
+
+export default connect(mapStateToProps, mapDispatchToProps)(SourceViewerBase);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
new file mode 100644
index 00000000000..2ad750e7120
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
@@ -0,0 +1,499 @@
+/*
+ * 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 uniqBy from 'lodash/uniqBy';
+import SourceViewerHeader from './SourceViewerHeader';
+import SourceViewerCode from './SourceViewerCode';
+import CoveragePopupView from '../source-viewer/popups/coverage-popup';
+import DuplicationPopupView from '../source-viewer/popups/duplication-popup';
+import LineActionsPopupView from '../source-viewer/popups/line-actions-popup';
+import SCMPopupView from '../source-viewer/popups/scm-popup';
+import MeasuresOverlay from '../source-viewer/measures-overlay';
+import { TooltipsContainer } from '../mixins/tooltips-mixin';
+import Source from '../source-viewer/source';
+import loadIssues from './helpers/loadIssues';
+import getCoverageStatus from './helpers/getCoverageStatus';
+import {
+ issuesByLine,
+ locationsByLine,
+ locationsByIssueAndLine,
+ locationMessagesByIssueAndLine,
+ duplicationsByLine,
+ symbolsByLine
+} from './helpers/indexing';
+import { getComponentForSourceViewer, getSources, getDuplications, getTests } from '../../api/components';
+import { translate } from '../../helpers/l10n';
+import type { SourceLine } from './types';
+import type { Issue } from '../issue/types';
+
+// TODO react-virtualized
+
+type Props = {
+ aroundLine?: number,
+ component: string,
+ displayAllIssues: boolean,
+ filterLine?: (line: SourceLine) => boolean,
+ highlightedLine?: number,
+ loadComponent: (string) => Promise<*>,
+ loadIssues: (string, number, number) => Promise<*>,
+ loadSources: (string, number, number) => Promise<*>,
+ onLoaded?: (component: Object, sources: Array<*>, issues: Array<*>) => void,
+ onIssueSelect: (string) => void,
+ onIssueUnselect: () => void,
+ onReceiveComponent: ({ canMarkAsFavorite: boolean, fav: boolean, key: string }) => void,
+ onReceiveIssues: (issues: Array<*>) => void,
+ selectedIssue: string | null,
+};
+
+type State = {
+ component?: Object,
+ displayDuplications: boolean,
+ duplications?: Array<{
+ blocks: Array<{
+ _ref: string,
+ from: number,
+ size: number
+ }>
+ }>,
+ duplicationsByLine: { [number]: Array<number> },
+ duplicatedFiles?: Array<{ key: string }>,
+ hasSourcesAfter: boolean,
+ highlightedLine: number | null,
+ highlightedSymbol: string | null,
+ issues?: Array<Issue>,
+ issuesByLine: { [number]: Array<string> },
+ issueLocationsByLine: { [number]: Array<{ from: number, to: number }> },
+ issueSecondaryLocationsByIssueByLine: {
+ [string]: {
+ [number]: Array<{ from: number, to: number }>
+ }
+ },
+ issueSecondaryLocationMessagesByIssueByLine: {
+ [issueKey: string]: {
+ [line: number]: Array<{ msg: string, index?: number }>
+ }
+ },
+ loading: boolean,
+ loadingSourcesAfter: boolean,
+ loadingSourcesBefore: boolean,
+ notAccessible: boolean,
+ notExist: boolean,
+ sources?: Array<SourceLine>,
+ symbolsByLine: { [number]: Array<string> }
+};
+
+const LINES = 500;
+
+const loadComponent = (key: string): Promise<*> => {
+ return getComponentForSourceViewer(key);
+};
+
+const loadSources = (key: string, from?: number, to?: number): Promise<Array<*>> => {
+ return getSources(key, from, to);
+};
+
+export default class SourceViewerBase extends React.Component {
+ mounted: boolean;
+ node: HTMLElement;
+ props: Props;
+ state: State;
+
+ static defaultProps = {
+ displayAllIssues: false,
+ onIssueSelect: () => { },
+ onIssueUnselect: () => { },
+ loadComponent,
+ loadIssues,
+ loadSources
+ };
+
+ constructor (props: Props) {
+ super(props);
+ this.state = {
+ displayDuplications: false,
+ duplicationsByLine: {},
+ hasSourcesAfter: false,
+ highlightedLine: props.highlightedLine || null,
+ highlightedSymbol: null,
+ issuesByLine: {},
+ issueLocationsByLine: {},
+ issueSecondaryLocationsByIssueByLine: {},
+ issueSecondaryLocationMessagesByIssueByLine: {},
+ loading: true,
+ loadingSourcesAfter: false,
+ loadingSourcesBefore: false,
+ notAccessible: false,
+ notExist: false,
+ selectedIssue: props.defaultSelectedIssue || null,
+ symbolsByLine: {}
+ };
+ }
+
+ componentDidMount () {
+ this.mounted = true;
+ this.fetchComponent();
+ }
+
+ componentDidUpdate (prevProps: Props) {
+ if (prevProps.component !== this.props.component) {
+ this.fetchComponent();
+ } else if (this.props.aroundLine != null && prevProps.aroundLine !== this.props.aroundLine &&
+ this.isLineOutsideOfRange(this.props.aroundLine)) {
+ this.fetchSources();
+ }
+ }
+
+ componentWillUnmount () {
+ this.mounted = false;
+ }
+
+ computeCoverageStatus (lines: Array<SourceLine>): Array<SourceLine> {
+ return lines.map(line => ({ ...line, coverageStatus: getCoverageStatus(line) }));
+ }
+
+ isLineOutsideOfRange (lineNumber: number) {
+ const { sources } = this.state;
+ if (sources != null && sources.length > 0) {
+ const firstLine = sources[0];
+ const lastList = sources[sources.length - 1];
+ return lineNumber < firstLine.line || lineNumber > lastList.line;
+ } else {
+ return true;
+ }
+ }
+
+ fetchComponent () {
+ this.setState({ loading: true });
+
+ const loadIssues = (component, sources) => {
+ this.props.loadIssues(this.props.component, 1, LINES).then(issues => {
+ this.props.onReceiveIssues(issues);
+ if (this.mounted) {
+ const finalSources = sources.slice(0, LINES);
+ this.setState({
+ component,
+ issues,
+ issuesByLine: issuesByLine(issues),
+ issueLocationsByLine: locationsByLine(issues),
+ issueSecondaryLocationsByIssueByLine: locationsByIssueAndLine(issues),
+ issueSecondaryLocationMessagesByIssueByLine: locationMessagesByIssueAndLine(issues),
+ loading: false,
+ hasSourcesAfter: sources.length > LINES,
+ sources: this.computeCoverageStatus(finalSources),
+ symbolsByLine: symbolsByLine(sources.slice(0, LINES))
+ }, () => {
+ if (this.props.onLoaded) {
+ this.props.onLoaded(component, finalSources, issues);
+ }
+ });
+ }
+ });
+ };
+
+ const onFailLoadComponent = ({ response }) => {
+ // TODO handle other statuses
+ if (this.mounted && response.status === 404) {
+ this.setState({ loading: false, notExist: true });
+ }
+ };
+
+ const onFailLoadSources = (response, component) => {
+ // TODO handle other statuses
+ if (this.mounted) {
+ if (response.status === 403) {
+ this.setState({ component, loading: false, notAccessible: true });
+ }
+ }
+ };
+
+ const onResolve = component => {
+ this.props.onReceiveComponent(component);
+ this.loadSources().then(
+ sources => loadIssues(component, sources),
+ response => onFailLoadSources(response, component)
+ );
+ };
+
+ this.props.loadComponent(this.props.component).then(onResolve, onFailLoadComponent);
+ }
+
+ fetchSources () {
+ this.loadSources().then(sources => {
+ if (this.mounted) {
+ const finalSources = sources.slice(0, LINES);
+ this.setState({
+ sources: sources.slice(0, LINES),
+ hasSourcesAfter: sources.length > LINES
+ }, () => {
+ if (this.props.onLoaded) {
+ // $FlowFixMe
+ this.props.onLoaded(this.state.component, finalSources, this.state.issues);
+ }
+ });
+ }
+ });
+ }
+
+ loadSources () {
+ return new Promise((resolve, reject) => {
+ const onFailLoadSources = ({ response }) => {
+ // TODO handle other statuses
+ if (this.mounted) {
+ if (response.status === 403) {
+ reject(response);
+ } else if (response.status === 404) {
+ resolve([]);
+ }
+ }
+ };
+
+ const from = this.props.aroundLine ? Math.max(1, this.props.aroundLine - LINES / 2 + 1) : 1;
+ // request one additional line to define `hasSourcesAfter`
+ const to = this.props.aroundLine ? this.props.aroundLine + LINES / 2 + 1 : LINES + 1;
+
+ return this.props.loadSources(this.props.component, from, to).then(
+ sources => resolve(sources),
+ onFailLoadSources
+ );
+ });
+ }
+
+ loadSourcesBefore = () => {
+ if (!this.state.sources) {
+ return;
+ }
+ const firstSourceLine = this.state.sources[0];
+ this.setState({ loadingSourcesBefore: true });
+ const from = Math.max(1, firstSourceLine.line - LINES);
+ this.props.loadSources(this.props.component, from, firstSourceLine.line - 1).then(sources => {
+ this.props.loadIssues(this.props.component, from, firstSourceLine.line - 1).then(issues => {
+ this.props.onReceiveIssues(issues);
+ if (this.mounted) {
+ this.setState(prevState => ({
+ issues: uniqBy([...issues, ...prevState.issues], issue => issue.key),
+ loadingSourcesBefore: false,
+ sources: [...this.computeCoverageStatus(sources), ...prevState.sources],
+ symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources) }
+ }));
+ }
+ });
+ });
+ };
+
+ loadSourcesAfter = () => {
+ if (!this.state.sources) {
+ return;
+ }
+ const lastSourceLine = this.state.sources[this.state.sources.length - 1];
+ this.setState({ loadingSourcesAfter: true });
+ const fromLine = lastSourceLine.line + 1;
+ // request one additional line to define `hasSourcesAfter`
+ const toLine = lastSourceLine.line + LINES + 1;
+ this.props.loadSources(this.props.component, fromLine, toLine).then(sources => {
+ this.props.loadIssues(this.props.component, fromLine, toLine).then(issues => {
+ this.props.onReceiveIssues(issues);
+ if (this.mounted) {
+ this.setState(prevState => ({
+ issues: uniqBy([...prevState.issues, ...issues], issue => issue.key),
+ hasSourcesAfter: sources.length > LINES,
+ loadingSourcesAfter: false,
+ sources: [...prevState.sources, ...this.computeCoverageStatus(sources.slice(0, LINES))],
+ symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources.slice(0, LINES)) }
+ }));
+ }
+ });
+ });
+ };
+
+ loadDuplications = (line: SourceLine, element: HTMLElement) => {
+ getDuplications(this.props.component).then(r => {
+ if (this.mounted) {
+ this.setState({
+ displayDuplications: true,
+ duplications: r.duplications,
+ duplicationsByLine: duplicationsByLine(r.duplications),
+ duplicatedFiles: r.files
+ }, () => {
+ // immediately show dropdown popup if there is only one duplicated block
+ if (r.duplications.length === 1) {
+ this.handleDuplicationClick(0, line.line, element);
+ }
+ });
+ }
+ });
+ };
+
+ openNewWindow = () => {
+ const { component } = this.state;
+ if (component != null) {
+ let query = 'id=' + encodeURIComponent(component.key);
+ const windowParams = 'resizable=1,scrollbars=1,status=1';
+ if (this.state.highlightedLine) {
+ query = query + '&line=' + this.state.highlightedLine;
+ }
+ window.open(window.baseUrl + '/component/index?' + query, component.name, windowParams);
+ }
+ };
+
+ showMeasures = () => {
+ const model = new Source(this.state.component);
+ const measuresOvervlay = new MeasuresOverlay({ model, large: true });
+ measuresOvervlay.render();
+ };
+
+ handleCoverageClick = (line: SourceLine, element: HTMLElement) => {
+ getTests(this.props.component, line.line).then(tests => {
+ const popup = new CoveragePopupView({ line, tests, triggerEl: element });
+ popup.render();
+ });
+ };
+
+ handleDuplicationClick = (index: number, line: number) => {
+ const duplication = this.state.duplications && this.state.duplications[index];
+ let blocks = (duplication && duplication.blocks) || [];
+ const inRemovedComponent = blocks.some(b => b._ref == null);
+ let foundOne = false;
+ blocks = blocks.filter(b => {
+ const outOfBounds = b.from > line || b.from + b.size < line;
+ const currentFile = b._ref === '1';
+ const shouldDisplayForCurrentFile = outOfBounds || foundOne;
+ const shouldDisplay = !currentFile || shouldDisplayForCurrentFile;
+ const isOk = (b._ref != null) && shouldDisplay;
+ if (b._ref === '1' && !outOfBounds) {
+ foundOne = true;
+ }
+ return isOk;
+ });
+
+ const element = this.node.querySelector(`.source-line-duplications-extra[data-line-number="${line}"]`);
+ if (element) {
+ const popup = new DuplicationPopupView({
+ blocks,
+ inRemovedComponent,
+ component: this.state.component,
+ files: this.state.duplicatedFiles,
+ triggerEl: element
+ });
+ popup.render();
+ }
+ };
+
+ displayLinePopup (line: number, element: HTMLElement) {
+ const popup = new LineActionsPopupView({
+ line,
+ triggerEl: element,
+ component: this.state.component
+ });
+ popup.render();
+ }
+
+ handleLineClick = (line: number, element: HTMLElement) => {
+ this.setState(prevState => ({
+ highlightedLine: prevState.highlightedLine === line ? null : line
+ }));
+ this.displayLinePopup(line, element);
+ };
+
+ handleSymbolClick = (symbol: string) => {
+ this.setState(prevState => ({
+ highlightedSymbol: prevState.highlightedSymbol === symbol ? null : symbol
+ }));
+ };
+
+ handleSCMClick = (line: SourceLine, element: HTMLElement) => {
+ const popup = new SCMPopupView({ triggerEl: element, line });
+ popup.render();
+ };
+
+ renderCode (sources: Array<SourceLine>) {
+ const hasSourcesBefore = sources.length > 0 && sources[0].line > 1;
+ return (
+ <TooltipsContainer>
+ <SourceViewerCode
+ displayAllIssues={this.props.displayAllIssues}
+ duplications={this.state.duplications}
+ duplicationsByLine={this.state.duplicationsByLine}
+ duplicatedFiles={this.state.duplicatedFiles}
+ hasSourcesBefore={hasSourcesBefore}
+ hasSourcesAfter={this.state.hasSourcesAfter}
+ filterLine={this.props.filterLine}
+ highlightedLine={this.state.highlightedLine}
+ highlightedSymbol={this.state.highlightedSymbol}
+ 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}
+ loadingSourcesAfter={this.state.loadingSourcesAfter}
+ loadingSourcesBefore={this.state.loadingSourcesBefore}
+ onCoverageClick={this.handleCoverageClick}
+ onDuplicationClick={this.handleDuplicationClick}
+ onIssueSelect={this.props.onIssueSelect}
+ onIssueUnselect={this.props.onIssueUnselect}
+ onLineClick={this.handleLineClick}
+ onSCMClick={this.handleSCMClick}
+ onSymbolClick={this.handleSymbolClick}
+ selectedIssue={this.props.selectedIssue}
+ sources={sources}
+ symbolsByLine={this.state.symbolsByLine}/>
+ </TooltipsContainer>
+ );
+ }
+
+ render () {
+ const { component, loading } = this.state;
+
+ if (loading) {
+ return null;
+ }
+
+ if (this.state.notExist) {
+ return (
+ <div className="alert alert-warning spacer-top">{translate('component_viewer.no_component')}</div>
+ );
+ }
+
+ if (component == null) {
+ return null;
+ }
+
+ const className = classNames('source-viewer', { 'source-duplications-expanded': this.state.displayDuplications });
+
+ return (
+ <div className={className} ref={node => this.node = node}>
+ <SourceViewerHeader
+ component={this.state.component}
+ openNewWindow={this.openNewWindow}
+ showMeasures={this.showMeasures}/>
+ {this.state.notAccessible && (
+ <div className="alert alert-warning spacer-top">
+ {translate('code_viewer.no_source_code_displayed_due_to_security')}
+ </div>
+ )}
+ {this.state.sources != null && this.renderCode(this.state.sources)}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js
new file mode 100644
index 00000000000..32092dd47c5
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js
@@ -0,0 +1,222 @@
+/*
+ * 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 SourceViewerLine from './SourceViewerLine';
+import { translate } from '../../helpers/l10n';
+import type { Duplication, SourceLine } from './types';
+import type { Issue } from '../issue/types';
+
+const EMPTY_ARRAY = [];
+
+const ZERO_LINE = {
+ code: '',
+ duplicated: false,
+ line: 0
+};
+
+export default class SourceViewerCode extends React.Component {
+ props: {
+ displayAllIssues: boolean,
+ duplications?: Array<Duplication>,
+ duplicationsByLine: { [number]: Array<number> },
+ duplicatedFiles?: Array<{ key: string }>,
+ filterLine?: (SourceLine) => boolean,
+ hasSourcesAfter: boolean,
+ hasSourcesBefore: boolean,
+ highlightedLine: number | null,
+ highlightedSymbol: string | null,
+ issues: Array<Issue>,
+ issuesByLine: { [number]: Array<string> },
+ issueLocationsByLine: { [number]: Array<{ from: number, to: number }> },
+ issueSecondaryLocationsByIssueByLine: {
+ [string]: {
+ [number]: Array<{ from: number, to: number }>
+ }
+ },
+ issueSecondaryLocationMessagesByIssueByLine: {
+ [issueKey: string]: {
+ [line: number]: Array<{ msg: string, index?: number }>
+ }
+ },
+ loadDuplications: (SourceLine, HTMLElement) => void,
+ loadSourcesAfter: () => void,
+ loadSourcesBefore: () => void,
+ loadingSourcesAfter: boolean,
+ loadingSourcesBefore: boolean,
+ onCoverageClick: (SourceLine, HTMLElement) => void,
+ onDuplicationClick: (number, number) => void,
+ onIssueSelect: (string) => void,
+ onIssueUnselect: () => void,
+ onLineClick: (number, HTMLElement) => void,
+ onSCMClick: (SourceLine, HTMLElement) => void,
+ onSymbolClick: (string) => void,
+ selectedIssue: string | null,
+ sources: Array<SourceLine>,
+ symbolsByLine: { [number]: Array<string> }
+ };
+
+ isSCMChanged (s: SourceLine, p: null | SourceLine) {
+ let changed = true;
+ if (p != null && s.scmAuthor != null && p.scmAuthor != null) {
+ changed = (s.scmAuthor !== p.scmAuthor) || (s.scmDate !== p.scmDate);
+ }
+ return changed;
+ }
+
+ getDuplicationsForLine (line: SourceLine) {
+ return this.props.duplicationsByLine[line.line] || EMPTY_ARRAY;
+ }
+
+ getIssuesForLine (line: SourceLine): Array<string> {
+ return this.props.issuesByLine[line.line] || EMPTY_ARRAY;
+ }
+
+ getIssueLocationsForLine (line: SourceLine) {
+ return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY;
+ }
+
+ getSecondaryIssueLocationsForLine (line: SourceLine, issueKey: string) {
+ const index = this.props.issueSecondaryLocationsByIssueByLine;
+ if (index[issueKey] == null) {
+ return EMPTY_ARRAY;
+ }
+ return index[issueKey][line.line] || EMPTY_ARRAY;
+ }
+
+ getSecondaryIssueLocationMessagesForLine (line: SourceLine, issueKey: string) {
+ return this.props.issueSecondaryLocationMessagesByIssueByLine[issueKey][line.line] || EMPTY_ARRAY;
+ }
+
+ renderLine = (
+ line: SourceLine,
+ index: number,
+ displayCoverage: boolean,
+ displayDuplications: boolean,
+ displayFiltered: boolean,
+ displayIssues: boolean
+ ) => {
+ const { filterLine, 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 duplicationsCount = this.props.duplications ? this.props.duplications.length : 0;
+
+ const issuesForLine = this.getIssuesForLine(line);
+
+ // for the following properties pass null if the line for sure is not impacted
+ const symbolsForLine = this.props.symbolsByLine[line.line] || [];
+ const { highlightedSymbol } = this.props;
+ const optimizedHighlightedSymbol = highlightedSymbol != null && symbolsForLine.includes(highlightedSymbol) ?
+ highlightedSymbol : null;
+
+ const optimizedSelectedIssue = selectedIssue != null && issuesForLine.includes(selectedIssue) ?
+ selectedIssue : null;
+
+ return (
+ <SourceViewerLine
+ displayAllIssues={this.props.displayAllIssues}
+ displayCoverage={displayCoverage}
+ displayDuplications={displayDuplications}
+ displayFiltered={displayFiltered}
+ displayIssues={displayIssues}
+ displaySCM={this.isSCMChanged(line, index > 0 ? sources[index - 1] : null)}
+ duplications={this.getDuplicationsForLine(line)}
+ duplicationsCount={duplicationsCount}
+ filtered={filtered}
+ highlighted={line.line === this.props.highlightedLine}
+ highlightedSymbol={optimizedHighlightedSymbol}
+ issueLocations={this.getIssueLocationsForLine(line)}
+ issues={issuesForLine}
+ key={line.line}
+ line={line}
+ loadDuplications={this.props.loadDuplications}
+ onClick={this.props.onLineClick}
+ onCoverageClick={this.props.onCoverageClick}
+ onDuplicationClick={this.props.onDuplicationClick}
+ onIssueSelect={this.props.onIssueSelect}
+ onIssueUnselect={this.props.onIssueUnselect}
+ onSCMClick={this.props.onSCMClick}
+ onSymbolClick={this.props.onSymbolClick}
+ secondaryIssueLocations={secondaryIssueLocations}
+ secondaryIssueLocationMessages={secondaryIssueLocationMessages}
+ selectedIssue={optimizedSelectedIssue}/>
+ );
+ };
+
+ render () {
+ const { sources } = this.props;
+
+ const hasCoverage = sources.some(s => s.coverageStatus != null);
+ const hasDuplications = sources.some(s => s.duplicated);
+ const displayFiltered = this.props.filterLine != null;
+ const hasIssues = this.props.issues.length > 0;
+
+ const hasFileIssues = hasIssues && this.props.issues.some(issue => !issue.line);
+
+ return (
+ <div>
+ {this.props.hasSourcesBefore && (
+ <div className="source-viewer-more-code">
+ {this.props.loadingSourcesBefore ? (
+ <div className="js-component-viewer-loading-before">
+ <i className="spinner"/>
+ <span className="note spacer-left">{translate('source_viewer.loading_more_code')}</span>
+ </div>
+ ) : (
+ <button className="js-component-viewer-source-before" onClick={this.props.loadSourcesBefore}>
+ {translate('source_viewer.load_more_code')}
+ </button>
+ )}
+ </div>
+ )}
+
+ <table className="source-table">
+ <tbody>
+ {hasFileIssues && (
+ this.renderLine(ZERO_LINE, -1, hasCoverage, hasDuplications, displayFiltered, hasIssues)
+ )}
+ {sources.map((line, index) => (
+ this.renderLine(line, index, hasCoverage, hasDuplications, displayFiltered, hasIssues)
+ ))}
+ </tbody>
+ </table>
+
+ {this.props.hasSourcesAfter && (
+ <div className="source-viewer-more-code">
+ {this.props.loadingSourcesAfter ? (
+ <div className="js-component-viewer-loading-after">
+ <i className="spinner"/>
+ <span className="note spacer-left">{translate('source_viewer.loading_more_code')}</span>
+ </div>
+ ) : (
+ <button className="js-component-viewer-source-after" onClick={this.props.loadSourcesAfter}>
+ {translate('source_viewer.load_more_code')}
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js
new file mode 100644
index 00000000000..14dedd85572
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js
@@ -0,0 +1,185 @@
+/*
+ * 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 { Link } from 'react-router';
+import QualifierIcon from '../shared/qualifier-icon';
+import FavoriteContainer from '../controls/FavoriteContainer';
+import Workspace from '../workspace/main';
+import { getProjectUrl, getIssuesUrl } from '../../helpers/urls';
+import { collapsedDirFromPath, fileFromPath } from '../../helpers/path';
+import { translate } from '../../helpers/l10n';
+import { formatMeasure } from '../../helpers/measures';
+
+export default class SourceViewerHeader extends React.Component {
+ props: {
+ component: {
+ canMarkAsFavorite: boolean,
+ key: string,
+ measures: {
+ coverage?: string,
+ duplicationDensity?: string,
+ issues?: string,
+ lines?: string,
+ tests?: string
+ },
+ path: string,
+ project: string,
+ projectName: string,
+ q: string,
+ subProject?: string,
+ subProjectName?: string
+ },
+ openNewWindow: () => void,
+ showMeasures: () => void
+ };
+
+ showMeasures = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.props.showMeasures();
+ };
+
+ openNewWindow = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.props.openNewWindow();
+ };
+
+ openInWorkspace = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ const { key } = this.props.component;
+ Workspace.openComponent({ key });
+ };
+
+ render () {
+ const { key, measures, path, project, projectName, q, subProject, subProjectName } = this.props.component;
+ const isUnitTest = q === 'UTS';
+ // TODO check if source viewer is displayed inside workspace
+ const workspace = false;
+ const rawSourcesLink = `${window.baseUrl}/api/sources/raw?key=${encodeURIComponent(this.props.component.key)}`;
+
+ // TODO favorite
+ return (
+ <div className="source-viewer-header">
+ <div className="source-viewer-header-component">
+ <div className="component-name">
+ <div className="component-name-parent">
+ <Link to={getProjectUrl(project)} className="link-with-icon">
+ <QualifierIcon qualifier="TRK"/> <span>{projectName}</span>
+ </Link>
+ </div>
+
+ {subProject != null && (
+ <div className="component-name-parent">
+ <Link to={getProjectUrl(subProject)} className="link-with-icon">
+ <QualifierIcon qualifier="BRC"/> <span>{subProjectName}</span>
+ </Link>
+ </div>
+ )}
+
+ <div className="component-name-path">
+ <QualifierIcon qualifier={q}/>
+ {' '}
+ <span>{collapsedDirFromPath(path)}</span>
+ <span className="component-name-file">{fileFromPath(path)}</span>
+
+ {this.props.component.canMarkAsFavorite && (
+ <FavoriteContainer className="component-name-favorite" componentKey={key}/>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="dropdown source-viewer-header-actions">
+ <a className="js-actions icon-list dropdown-toggle"
+ data-toggle="dropdown"
+ title={translate('component_viewer.more_actions')}/>
+ <ul className="dropdown-menu dropdown-menu-right">
+ <li>
+ <a className="js-measures" href="#" onClick={this.showMeasures}>
+ {translate('component_viewer.show_details')}
+ </a>
+ </li>
+ <li>
+ <a className="js-new-window" href="#" onClick={this.openNewWindow}>
+ {translate('component_viewer.new_window')}
+ </a>
+ </li>
+ {!workspace && (
+ <li>
+ <a className="js-workspace" href="#" onClick={this.openInWorkspace}>
+ {translate('component_viewer.open_in_workspace')}
+ </a>
+ </li>
+ )}
+ <li>
+ <a className="js-raw-source" href={rawSourcesLink} target="_blank">
+ {translate('component_viewer.show_raw_source')}
+ </a>
+ </li>
+ </ul>
+ </div>
+
+ <div className="source-viewer-header-measures">
+ {isUnitTest && (
+ <div className="source-viewer-header-measure">
+ <span className="source-viewer-header-measure-value">{formatMeasure(measures.tests, 'SHORT_INT')}</span>
+ <span className="source-viewer-header-measure-label">{translate('metric.tests.name')}</span>
+ </div>
+ )}
+
+ {!isUnitTest && (
+ <div className="source-viewer-header-measure">
+ <span className="source-viewer-header-measure-value">{formatMeasure(measures.lines, 'SHORT_INT')}</span>
+ <span className="source-viewer-header-measure-label">{translate('metric.lines.name')}</span>
+ </div>
+ )}
+
+ <div className="source-viewer-header-measure">
+ <span className="source-viewer-header-measure-value">
+ <Link to={getIssuesUrl({ resolved: 'false', componentKeys: key })}
+ className="source-viewer-header-external-link" target="_blank">
+ {measures.issues != null ? formatMeasure(measures.issues, 'SHORT_INT') : 0}
+ {' '}
+ <i className="icon-detach"/>
+ </Link>
+ </span>
+ <span className="source-viewer-header-measure-label">{translate('metric.violations.name')}</span>
+ </div>
+
+ {measures.coverage != null && (
+ <div className="source-viewer-header-measure">
+ <span className="source-viewer-header-measure-value">{formatMeasure(measures.coverage, 'PERCENT')}</span>
+ <span className="source-viewer-header-measure-label">{translate('metric.coverage.name')}</span>
+ </div>
+ )}
+
+ {measures.duplicationDensity != null && (
+ <div className="source-viewer-header-measure">
+ <span className="source-viewer-header-measure-value">
+ {formatMeasure(measures.duplicationDensity, 'PERCENT')}
+ </span>
+ <span className="source-viewer-header-measure-label">{translate('duplications')}</span>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssuesIndicator.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssuesIndicator.js
new file mode 100644
index 00000000000..f6993949244
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssuesIndicator.js
@@ -0,0 +1,44 @@
+/*
+ * 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 { connect } from 'react-redux';
+import SeverityIcon from '../shared/severity-icon';
+import { getIssueByKey } from '../../store/rootReducer';
+import { sortBySeverity } from '../../helpers/issues';
+
+class SourceViewerIssuesIndicator extends React.Component {
+ props: {
+ issue: { severity: string }
+ };
+
+ render () {
+ return (
+ <SeverityIcon severity={this.props.issue.severity}/>
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps: { issues: Array<string> }) => {
+ const issues = ownProps.issues.map(issueKey => getIssueByKey(state, issueKey));
+ return { issue: sortBySeverity(issues)[0] };
+};
+
+export default connect(mapStateToProps)(SourceViewerIssuesIndicator);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js
new file mode 100644
index 00000000000..72cb0d5c053
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js
@@ -0,0 +1,377 @@
+/*
+ * 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 times from 'lodash/times';
+import ConnectedIssue from '../issue/ConnectedIssue';
+import SourceViewerIssuesIndicator from './SourceViewerIssuesIndicator';
+import { translate } from '../../helpers/l10n';
+import { splitByTokens, highlightSymbol, highlightIssueLocations, generateHTML } from './helpers/highlight';
+import type { SourceLine } from './types';
+
+type Props = {
+ displayAllIssues: boolean,
+ displayCoverage: boolean,
+ displayDuplications: boolean,
+ displayFiltered: boolean,
+ displayIssues: boolean,
+ displaySCM: boolean,
+ duplications: Array<number>,
+ duplicationsCount: number,
+ filtered: boolean | null,
+ highlighted: boolean,
+ highlightedSymbol: string | null,
+ issueLocations: Array<{ from: number, to: number }>,
+ issues: Array<string>,
+ line: SourceLine,
+ loadDuplications: (SourceLine, HTMLElement) => void,
+ onClick: (number, HTMLElement) => void,
+ onCoverageClick: (SourceLine, HTMLElement) => void,
+ onDuplicationClick: (number, number) => void,
+ onIssueSelect: (string) => void,
+ onIssueUnselect: () => void,
+ onSCMClick: (SourceLine, HTMLElement) => void,
+ onSymbolClick: (string) => void,
+ selectedIssue: string | null,
+ // $FlowFixMe
+ secondaryIssueLocations: Array<{ from: number, to: number }>,
+ // $FlowFixMe
+ secondaryIssueLocationMessages: Array<{ msg: string, index?: number }>
+};
+
+type State = {
+ issuesOpen: boolean
+};
+
+export default class SourceViewerLine extends React.PureComponent {
+ codeNode: HTMLElement;
+ props: Props;
+ issueElements: { [string]: HTMLElement } = {};
+ issueViews: { [string]: { destroy: () => void } } = {};
+ state: State = { issuesOpen: false };
+ symbols: NodeList<HTMLElement>;
+
+ componentDidMount () {
+ this.attachEvents();
+ }
+
+ componentWillUpdate () {
+ this.detachEvents();
+ }
+
+ componentDidUpdate (prevProps: Props) {
+ /* eslint-disable no-console */
+ console.log('re-render line', this.props.line.line, 'because they are not equal:');
+ Object.keys(this.props).forEach(prop => {
+ if (this.props[prop] !== prevProps[prop]) {
+ console.log(prop);
+ }
+ });
+ console.log('');
+
+ this.attachEvents();
+ }
+
+ componentWillUnmount () {
+ this.detachEvents();
+ }
+
+ attachEvents () {
+ this.symbols = this.codeNode.querySelectorAll('.sym');
+ for (const symbol of this.symbols) {
+ symbol.addEventListener('click', this.handleSymbolClick);
+ }
+ }
+
+ detachEvents () {
+ if (this.symbols) {
+ for (const symbol of this.symbols) {
+ symbol.removeEventListener('click', this.handleSymbolClick);
+ }
+ }
+ }
+
+ handleClick = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.props.onClick(this.props.line.line, e.target);
+ };
+
+ handleCoverageClick = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.props.onCoverageClick(this.props.line, e.target);
+ };
+
+ handleIssuesIndicatorClick = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.setState(prevState => {
+ // TODO not sure if side effects allowed here
+ if (!prevState.issuesOpen) {
+ const { issues } = this.props;
+ if (issues.length > 0) {
+ this.props.onIssueSelect(issues[0]);
+ }
+ } else {
+ this.props.onIssueUnselect();
+ }
+
+ return { issuesOpen: !prevState.issuesOpen };
+ });
+ }
+
+ handleSCMClick = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.props.onSCMClick(this.props.line, e.target);
+ }
+
+ handleSymbolClick = (e: Object) => {
+ e.preventDefault();
+ const key = e.currentTarget.className.match(/sym-\d+/);
+ if (key && key[0]) {
+ this.props.onSymbolClick(key[0]);
+ }
+ };
+
+ handleIssueSelect = (issueKey: string) => {
+ this.props.onIssueSelect(issueKey);
+ };
+
+ renderLineNumber () {
+ const { line } = this.props;
+ return (
+ <td className="source-meta source-line-number"
+ // don't display 0
+ data-line-number={line.line ? line.line : undefined}
+ role={line.line ? 'button' : undefined}
+ tabIndex={line.line ? 0 : undefined}
+ onClick={line.line ? this.handleClick : undefined}/>
+ );
+ }
+
+ renderSCM () {
+ const { line } = this.props;
+ const clickable = !!line.line;
+ return (
+ <td className="source-meta source-line-scm"
+ data-line-number={line.line}
+ role={clickable ? 'button' : undefined}
+ tabIndex={clickable ? 0 : undefined}
+ onClick={clickable ? this.handleSCMClick : undefined}>
+ {this.props.displaySCM && (
+ <div className="source-line-scm-inner" data-author={line.scmAuthor}/>
+ )}
+ </td>
+ );
+ }
+
+ renderCoverage () {
+ const { line } = this.props;
+ const className = 'source-meta source-line-coverage' +
+ (line.coverageStatus != null ? ` source-line-${line.coverageStatus}` : '');
+ return (
+ <td className={className}
+ data-line-number={line.line}
+ title={line.coverageStatus != null && translate('source_viewer.tooltip', line.coverageStatus)}
+ data-placement={line.coverageStatus != null && 'right'}
+ data-toggle={line.coverageStatus != null && 'tooltip'}
+ role={line.coverageStatus != null ? 'button' : undefined}
+ tabIndex={line.coverageStatus != null ? 0 : undefined}
+ onClick={line.coverageStatus != null && this.handleCoverageClick}>
+ <div className="source-line-bar"/>
+ </td>
+ );
+ }
+
+ renderDuplications () {
+ const { line } = this.props;
+ const className = classNames('source-meta', 'source-line-duplications', {
+ 'source-line-duplicated': line.duplicated
+ });
+
+ const handleDuplicationClick = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.props.loadDuplications(this.props.line, e.target);
+ };
+
+ return (
+ <td className={className}
+ title={line.duplicated && translate('source_viewer.tooltip.duplicated_line')}
+ data-placement={line.duplicated && 'right'}
+ data-toggle={line.duplicated && 'tooltip'}
+ role="button"
+ tabIndex="0"
+ onClick={handleDuplicationClick}>
+ <div className="source-line-bar"/>
+ </td>
+ );
+ }
+
+ renderDuplicationsExtra () {
+ const { duplications, duplicationsCount } = this.props;
+ return times(duplicationsCount).map(index => this.renderDuplication(index, duplications.includes(index)));
+ }
+
+ renderDuplication = (index: number, duplicated: boolean) => {
+ const className = classNames('source-meta', 'source-line-duplications-extra', {
+ 'source-line-duplicated': duplicated
+ });
+
+ const handleDuplicationClick = (e: SyntheticInputEvent) => {
+ e.preventDefault();
+ this.props.onDuplicationClick(index, this.props.line.line);
+ };
+
+ return (
+ <td key={index}
+ className={className}
+ data-line-number={this.props.line.line}
+ data-index={index}
+ title={duplicated ? translate('source_viewer.tooltip.duplicated_block') : undefined}
+ data-placement={duplicated ? 'right' : undefined}
+ data-toggle={duplicated ? 'tooltip' : undefined}
+ role={duplicated ? 'button' : undefined}
+ tabIndex={duplicated ? '0' : undefined}
+ onClick={duplicated ? handleDuplicationClick : undefined}>
+ <div className="source-line-bar"/>
+ </td>
+ );
+ };
+
+ renderIssuesIndicator () {
+ const { issues } = this.props;
+ const hasIssues = issues.length > 0;
+ const className = classNames('source-meta', 'source-line-issues', { 'source-line-with-issues': hasIssues });
+ const onClick = hasIssues ? this.handleIssuesIndicatorClick : undefined;
+
+ return (
+ <td className={className}
+ data-line-number={this.props.line.line}
+ role="button"
+ tabIndex="0"
+ onClick={onClick}>
+ {hasIssues && (
+ <SourceViewerIssuesIndicator issues={issues}/>
+ )}
+ {issues.length > 1 && (
+ <span className="source-line-issues-counter">{issues.length}</span>
+ )}
+ </td>
+ );
+ }
+
+ renderSecondaryIssueLocationMessages (locationMessages: Array<{ msg: string, index?: number }>) {
+ const limitString = (str: string) => (
+ str.length > 30 ? str.substr(0, 30) + '...' : str
+ );
+
+ return (
+ <div className="source-line-issue-locations">
+ {locationMessages.map((locationMessage, index) => (
+ <div key={index} className="source-viewer-issue-location" title={locationMessage.msg}>
+ {locationMessage.index && (
+ <strong>{locationMessage.index}: </strong>
+ )}
+ {limitString(locationMessage.msg)}
+ </div>
+ ))}
+ </div>
+ );
+ }
+
+ renderCode () {
+ const { line, highlightedSymbol, issueLocations, issues, secondaryIssueLocations } = this.props;
+ const { secondaryIssueLocationMessages } = this.props;
+ const className = classNames('source-line-code', 'code', { 'has-issues': issues.length > 0 });
+
+ const code = line.code || '';
+ let tokens = splitByTokens(code);
+
+ if (highlightedSymbol) {
+ tokens = highlightSymbol(tokens, highlightedSymbol);
+ }
+
+ if (issueLocations.length > 0) {
+ tokens = highlightIssueLocations(tokens, issueLocations);
+ }
+
+ if (secondaryIssueLocations) {
+ tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'source-line-code-secondary-issue');
+ }
+
+ const finalCode = generateHTML(tokens);
+
+ const showIssues = (this.state.issuesOpen || this.props.displayAllIssues) && issues.length > 0;
+
+ 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)
+ )}
+ </div>
+ {showIssues && (
+ <div className="issue-list">
+ {issues.map(issue => (
+ <ConnectedIssue
+ key={issue}
+ issueKey={issue}
+ onClick={this.handleIssueSelect}
+ selected={this.props.selectedIssue === issue}/>
+ ))}
+ </div>
+ )}
+ </td>
+ );
+ }
+
+ render () {
+ const { line, duplicationsCount, filtered } = this.props;
+ const className = classNames('source-line', {
+ 'source-line-highlighted': this.props.highlighted,
+ 'source-line-shadowed': filtered === false,
+ 'source-line-filtered': filtered === true
+ });
+
+ return (
+ <tr className={className} data-line-number={line.line}>
+ {this.renderLineNumber()}
+
+ {this.renderSCM()}
+
+ {this.props.displayCoverage && this.renderCoverage()}
+
+ {this.props.displayDuplications && this.renderDuplications()}
+
+ {duplicationsCount > 0 && this.renderDuplicationsExtra()}
+
+ {this.props.displayIssues && !this.props.displayAllIssues && this.renderIssuesIndicator()}
+
+ {this.props.displayFiltered && (
+ <td className="source-meta source-line-filtered-container" data-line-number={line.line}>
+ <div className="source-line-bar"/>
+ </td>
+ )}
+
+ {this.renderCode()}
+ </tr>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewer.js b/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewer.js
new file mode 100644
index 00000000000..d673bd44dd0
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewer.js
@@ -0,0 +1,47 @@
+/*
+ * 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 { connect } from 'react-redux';
+import StandaloneSourceViewerBase from './StandaloneSourceViewerBase';
+import { receiveFavorites } from '../../store/favorites/duck';
+import { receiveIssues } from '../../store/issues/duck';
+
+const mapStateToProps = null;
+
+const onReceiveComponent = (component: { key: string, canMarkAsFavorite: boolean, fav: boolean }) => dispatch => {
+ if (component.canMarkAsFavorite) {
+ const favorites = [];
+ const notFavorites = [];
+ if (component.fav) {
+ favorites.push({ key: component.key });
+ } else {
+ notFavorites.push({ key: component.key });
+ }
+ dispatch(receiveFavorites(favorites, notFavorites));
+ }
+};
+
+const onReceiveIssues = (issues: Array<*>) => dispatch => {
+ dispatch(receiveIssues(issues));
+};
+
+const mapDispatchToProps = { onReceiveComponent, onReceiveIssues };
+
+export default connect(mapStateToProps, mapDispatchToProps)(StandaloneSourceViewerBase);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewerBase.js b/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewerBase.js
new file mode 100644
index 00000000000..ea28e00b36f
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewerBase.js
@@ -0,0 +1,50 @@
+/*
+ * 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 SourceViewerBase from './SourceViewerBase';
+
+type State = {
+ selectedIssue: string | null
+};
+
+export default class StandaloneSourceViewerBase extends React.Component {
+ state: State = {
+ selectedIssue: null
+ };
+
+ handleIssueSelect = (issue: string) => {
+ this.setState({ selectedIssue: issue });
+ };
+
+ handleIssueUnselect = () => {
+ this.setState({ selectedIssue: null });
+ };
+
+ render () {
+ return (
+ <SourceViewerBase
+ {...this.props}
+ onIssueSelect={this.handleIssueSelect}
+ onIssueUnselect={this.handleIssueUnselect}
+ selectedIssue={this.state.selectedIssue}/>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.js
new file mode 100644
index 00000000000..2f99ed81675
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.js
@@ -0,0 +1,37 @@
+/*
+ * 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 { SourceLine } from '../types';
+
+const getCoverageStatus = (s: SourceLine): string | null => {
+ let status = null;
+ if (s.lineHits != null && s.lineHits > 0) {
+ status = 'partially-covered';
+ }
+ if (s.lineHits != null && s.lineHits > 0 && s.conditions === s.coveredConditions) {
+ status = 'covered';
+ }
+ if (s.lineHits === 0 || s.coveredConditions === 0) {
+ status = 'uncovered';
+ }
+ return status;
+};
+
+export default getCoverageStatus;
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js
new file mode 100644
index 00000000000..0adc3f0d31f
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js
@@ -0,0 +1,115 @@
+/*
+ * 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 escapeHtml from 'escape-html';
+
+type Token = { className: string, text: string };
+type Tokens = Array<Token>;
+
+const ISSUE_LOCATION_CLASS = 'source-line-code-issue';
+
+export const splitByTokens = (code: string, rootClassName: string = ''): Tokens => {
+ const container = document.createElement('div');
+ let tokens = [];
+ container.innerHTML = code;
+ [].forEach.call(container.childNodes, node => {
+ if (node.nodeType === 1) {
+ // ELEMENT NODE
+ const fullClassName = rootClassName ? (rootClassName + ' ' + node.className) : node.className;
+ const innerTokens = splitByTokens(node.innerHTML, fullClassName);
+ tokens = tokens.concat(innerTokens);
+ }
+ if (node.nodeType === 3) {
+ // TEXT NODE
+ tokens.push({ className: rootClassName, text: node.nodeValue });
+ }
+ });
+ return tokens;
+};
+
+export const highlightSymbol = (tokens: Tokens, symbol: string): Tokens => (
+ tokens.map(token => token.className.includes(symbol) ?
+ { ...token, className: `${token.className} highlighted` } :
+ token
+));
+
+/**
+ * Intersect two ranges
+ * @param s1 Start position of the first range
+ * @param e1 End position of the first range
+ * @param s2 Start position of the second range
+ * @param e2 End position of the second range
+ */
+const intersect = (s1: number, e1: number, s2: number, e2: number): { from: number, to: number } => {
+ return { from: Math.max(s1, s2), to: Math.min(e1, e2) };
+};
+
+/**
+ * Get the substring of a string
+ * @param str A string
+ * @param from "From" offset
+ * @param to "To" offset
+ * @param acc Global offset to eliminate
+ */
+const part = (str: string, from: number, to: number, acc: number): string => {
+ // we do not want negative number as the first argument of `substr`
+ return from >= acc ? str.substr(from - acc, to - from) : str.substr(0, to - from);
+};
+
+/**
+ * Highlight issue locations in the list of tokens
+ */
+export const highlightIssueLocations = (
+ tokens: Tokens,
+ issueLocations: Array<{ from: number, to: number }>,
+ rootClassName: string = ISSUE_LOCATION_CLASS
+): Tokens => {
+ issueLocations.forEach(location => {
+ const nextTokens = [];
+ let acc = 0;
+ 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 });
+ }
+ if (p2.length) {
+ const newClassName = token.className.indexOf(rootClassName) === -1 ?
+ `${token.className} ${rootClassName}` :
+ token.className;
+ nextTokens.push({ className: newClassName, text: p2 });
+ }
+ if (p3.length) {
+ nextTokens.push({ className: token.className, text: p3 });
+ }
+ acc += token.text.length;
+ });
+ tokens = nextTokens.slice();
+ });
+ return tokens;
+};
+
+export const generateHTML = (tokens: Tokens): string => {
+ return tokens.map(token => (
+ `<span class="${token.className}">${escapeHtml(token.text)}</span>`
+ )).join('');
+};
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js
new file mode 100644
index 00000000000..a9016ef0c7c
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js
@@ -0,0 +1,119 @@
+/*
+ * 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 { splitByTokens } from './highlight';
+import { getLinearLocations, getIssueLocations } from './issueLocations';
+import type { Issue } from '../../issue/types';
+import type { SourceLine } from '../types';
+
+export const issuesByLine = (issues: Array<Issue>) => {
+ const index = {};
+ issues.forEach(issue => {
+ const line = issue.line || 0;
+ if (!(line in index)) {
+ index[line] = [];
+ }
+ index[line].push(issue.key);
+ });
+ return index;
+};
+
+export const locationsByLine = (issues: Array<Issue>) => {
+ const index = {};
+ issues.forEach(issue => {
+ getLinearLocations(issue.textRange).forEach(location => {
+ if (!(location.line in index)) {
+ index[location.line] = [];
+ }
+ index[location.line].push(location);
+ });
+ });
+ return index;
+};
+
+export const locationsByIssueAndLine = (issues: Array<Issue>) => {
+ 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({ from: linearLocation.from, to: linearLocation.to });
+ });
+ });
+ index[issue.key] = byLine;
+ });
+ return index;
+};
+
+export const locationMessagesByIssueAndLine = (issues: Array<Issue>) => {
+ 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({ msg: location.msg, index: location.index });
+ });
+ index[issue.key] = byLine;
+ });
+ return index;
+};
+
+export const duplicationsByLine = (duplications: Array<*> | null) => {
+ if (duplications == null) {
+ return {};
+ }
+
+ const duplicationsByLine = {};
+
+ duplications.forEach(({ blocks }, duplicationIndex) => {
+ blocks.forEach(block => {
+ if (block._ref === '1') {
+ for (let line = block.from; line < block.from + block.size; line++) {
+ if (!(line in duplicationsByLine)) {
+ duplicationsByLine[line] = [];
+ }
+ duplicationsByLine[line].push(duplicationIndex);
+ }
+ }
+ });
+ });
+
+ return duplicationsByLine;
+};
+
+export const symbolsByLine = (sources: Array<SourceLine>) => {
+ const index = {};
+ sources.forEach(line => {
+ const tokens = splitByTokens(line.code);
+ index[line.line] = tokens
+ .map(token => {
+ const key = token.className.match(/sym-\d+/);
+ return key && key[0];
+ })
+ .filter(key => key);
+ });
+ return index;
+};
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js
new file mode 100644
index 00000000000..d2c8991fc3c
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js
@@ -0,0 +1,59 @@
+/*
+ * 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 { TextRange, Issue } from '../../issue/types';
+
+export const getLinearLocations = (textRange?: TextRange): Array<{ line: number, from: number, to: number }> => {
+ if (!textRange) {
+ return [];
+ }
+ const locations = [];
+
+ // go through all lines of the `textRange`
+ for (let line = textRange.startLine; line <= textRange.endLine; line++) {
+ // TODO fix 999999
+ const from = line === textRange.startLine ? textRange.startOffset : 0;
+ const to = line === textRange.endLine ? textRange.endOffset : 999999;
+ locations.push({ line, from, to });
+ }
+ return locations;
+};
+
+export const getIssueLocations = (issue: Issue): Array<{ msg: string, textRange: TextRange, index?: number }> => {
+ const primaryLocation = {
+ msg: issue.message,
+ textRange: issue.textRange
+ };
+ const allLocations = [primaryLocation];
+ issue.flows.forEach(({ locations }) => {
+ if (locations) {
+ const locationsCount = locations.length;
+ locations.forEach((location, index) => {
+ const flowLocation = {
+ ...location,
+ // set index only for real flows, do not set for just secondary locations
+ index: locationsCount > 1 ? locationsCount - index : undefined
+ };
+ allLocations.push(flowLocation);
+ });
+ }
+ });
+ return allLocations;
+};
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js
new file mode 100644
index 00000000000..ddc2963c0e7
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js
@@ -0,0 +1,76 @@
+/*
+ * 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 { searchIssues } from '../../../api/issues';
+import { parseIssueFromResponse } from '../../../helpers/issues';
+
+export type Query = { [string]: string };
+
+export type Issues = Array<*>;
+
+// maximum possible value
+const PAGE_SIZE = 500;
+
+const buildQuery = (component: string): Query => ({
+ additionalFields: '_all',
+ resolved: 'false',
+ componentKeys: component,
+ s: 'FILE_LINE'
+});
+
+export const loadPage = (query: Query, page: number, pageSize: number = PAGE_SIZE): Promise<Issues> => {
+ return searchIssues({ ...query, p: page, ps: pageSize }).then(r => (
+ r.issues.map(issue => parseIssueFromResponse(issue, r.components, r.users, r.rules))
+ ));
+};
+
+export const loadPageAndNext = (
+ query: Query,
+ toLine: number,
+ page: number,
+ pageSize: number = PAGE_SIZE
+): Promise<Issues> => {
+ return loadPage(query, page).then(issues => {
+ if (issues.length === 0) {
+ return [];
+ }
+
+ const lastIssue = issues[issues.length - 1];
+
+ if ((lastIssue.line != null && lastIssue.line > toLine) || issues.length < pageSize) {
+ return issues;
+ }
+
+ return loadPageAndNext(query, toLine, page + 1, pageSize).then(nextIssues => {
+ return [...issues, ...nextIssues];
+ });
+ });
+};
+
+const loadIssues = (component: string, fromLine: number, toLine: number): Promise<Issues> => {
+ const query = buildQuery(component);
+ return new Promise(resolve => {
+ loadPageAndNext(query, toLine, 1).then(issues => {
+ resolve(issues);
+ });
+ });
+};
+
+export default loadIssues;
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/types.js b/server/sonar-web/src/main/js/components/SourceViewer/types.js
new file mode 100644
index 00000000000..3dd00eeb553
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/types.js
@@ -0,0 +1,40 @@
+/*
+ * 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
+export type SourceLine = {
+ code: string,
+ conditions?: number,
+ coverageStatus?: string | null,
+ coveredConditions?: number,
+ duplicated: boolean,
+ line: number,
+ lineHits?: number,
+ scmAuthor?: string,
+ scmDate?: string,
+ scmRevision?: string
+};
+
+export type Duplication = {
+ blocks: Array<{
+ _ref: string,
+ from: number,
+ size: number
+ }>
+};
diff --git a/server/sonar-web/src/main/js/components/common/popup.js b/server/sonar-web/src/main/js/components/common/popup.js
index 363f0bdf72b..af7d22f632c 100644
--- a/server/sonar-web/src/main/js/components/common/popup.js
+++ b/server/sonar-web/src/main/js/components/common/popup.js
@@ -25,22 +25,23 @@ export default Marionette.ItemView.extend({
onRender () {
this.$el.detach().appendTo($('body'));
+ const triggerEl = $(this.options.triggerEl);
if (this.options.bottom) {
this.$el.addClass('bubble-popup-bottom');
this.$el.css({
- top: this.options.triggerEl.offset().top + this.options.triggerEl.outerHeight(),
- left: this.options.triggerEl.offset().left
+ top: triggerEl.offset().top + triggerEl.outerHeight(),
+ left: triggerEl.offset().left
});
} else if (this.options.bottomRight) {
this.$el.addClass('bubble-popup-bottom-right');
this.$el.css({
- top: this.options.triggerEl.offset().top + this.options.triggerEl.outerHeight(),
- right: $(window).width() - this.options.triggerEl.offset().left - this.options.triggerEl.outerWidth()
+ top: triggerEl.offset().top + triggerEl.outerHeight(),
+ right: $(window).width() - triggerEl.offset().left - triggerEl.outerWidth()
});
} else {
this.$el.css({
- top: this.options.triggerEl.offset().top,
- left: this.options.triggerEl.offset().left + this.options.triggerEl.outerWidth()
+ top: triggerEl.offset().top,
+ left: triggerEl.offset().left + triggerEl.outerWidth()
});
}
this.attachCloseEvents();
@@ -48,6 +49,7 @@ export default Marionette.ItemView.extend({
attachCloseEvents () {
const that = this;
+ const triggerEl = $(this.options.triggerEl);
key('escape', () => {
that.destroy();
});
@@ -55,8 +57,8 @@ export default Marionette.ItemView.extend({
$('body').off('click.bubble-popup');
that.destroy();
});
- this.options.triggerEl.on('click.bubble-popup', e => {
- that.options.triggerEl.off('click.bubble-popup');
+ triggerEl.on('click.bubble-popup', e => {
+ triggerEl.off('click.bubble-popup');
e.stopPropagation();
that.destroy();
});
@@ -64,7 +66,7 @@ export default Marionette.ItemView.extend({
onDestroy () {
$('body').off('click.bubble-popup');
- this.options.triggerEl.off('click.bubble-popup');
+ const triggerEl = $(this.options.triggerEl);
+ triggerEl.off('click.bubble-popup');
}
});
-
diff --git a/server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js b/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js
index 95e18194208..28be4c7ba4b 100644
--- a/server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js
+++ b/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js
@@ -17,19 +17,13 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import IssueView from '../workspace-list-item-view';
+// @flow
+import { connect } from 'react-redux';
+import Issue from './Issue';
+import { getIssueByKey } from '../../store/rootReducer';
-export default IssueView.extend({
- onRender () {
- IssueView.prototype.onRender.apply(this, arguments);
- this.$el.removeClass('issue-navigate-right issue-with-checkbox');
- },
-
- serializeData () {
- return {
- ...IssueView.prototype.serializeData.apply(this, arguments),
- showComponent: false
- };
- }
+const mapStateToProps = (state, ownProps) => ({
+ issue: getIssueByKey(state, ownProps.issueKey)
});
+export default connect(mapStateToProps)(Issue);
diff --git a/server/sonar-web/src/main/js/components/issue/Issue.js b/server/sonar-web/src/main/js/components/issue/Issue.js
new file mode 100644
index 00000000000..c437b8f41af
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/issue/Issue.js
@@ -0,0 +1,133 @@
+/*
+ * 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 { connect } from 'react-redux';
+import IssueView from './issue-view';
+import IssueModel from './models/issue';
+import { receiveIssues } from '../../store/issues/duck';
+import type { Issue as IssueType } from './types';
+
+type Model = { toJSON: () => {} };
+
+type Props = {
+ checked?: boolean,
+ issue: IssueType | Model,
+ onCheck?: () => void,
+ onClick: () => void,
+ onFilterClick?: () => void,
+ onIssueChange: ({}) => void,
+ selected: boolean
+};
+
+class Issue extends React.PureComponent {
+ issueView: Object;
+ node: HTMLElement;
+ props: Props;
+
+ componentDidMount () {
+ this.renderIssueView();
+ if (this.props.selected) {
+ this.bindShortcuts();
+ }
+ }
+
+ componentWillUpdate (nextProps: Props) {
+ if (!nextProps.selected && this.props.selected) {
+ this.unbindShortcuts();
+ }
+ this.destroyIssueView();
+ }
+
+ componentDidUpdate (prevProps: Props) {
+ this.renderIssueView();
+ if (!prevProps.selected && this.props.selected) {
+ this.bindShortcuts();
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.props.selected) {
+ this.unbindShortcuts();
+ }
+ this.destroyIssueView();
+ }
+
+ bindShortcuts () {
+ document.addEventListener('keypress', this.handleKeyPress);
+ }
+
+ unbindShortcuts () {
+ document.removeEventListener('keypress', this.handleKeyPress);
+ }
+
+ doIssueAction (action: string) {
+ this.issueView.$('.js-issue-' + action).click();
+ }
+
+ handleKeyPress = (e: Object) => {
+ const tagName = e.target.tagName.toUpperCase();
+ const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON';
+
+ if (shouldHandle) {
+ switch (e.key) {
+ case 'f': return this.doIssueAction('transition');
+ case 'a': return this.doIssueAction('assign');
+ case 'm': return this.doIssueAction('assign-to-me');
+ case 'p': return this.doIssueAction('plan');
+ case 'i': return this.doIssueAction('set-severity');
+ case 'c': return this.doIssueAction('comment');
+ case 't': return this.doIssueAction('edit-tags');
+ }
+ }
+ };
+
+ renderIssueView () {
+ const model = this.props.issue.toJSON ? this.props.issue : new IssueModel(this.props.issue);
+ this.issueView = new IssueView({
+ model,
+ checked: this.props.checked,
+ onCheck: this.props.onCheck,
+ onClick: this.props.onClick,
+ onFilterClick: this.props.onFilterClick,
+ onIssueChange: this.props.onIssueChange
+ });
+ this.issueView.render().$el.appendTo(this.node);
+ if (this.props.selected) {
+ this.issueView.select();
+ }
+ }
+
+ destroyIssueView () {
+ this.issueView.destroy();
+ }
+
+ render () {
+ return <div className="issue-container" ref={node => this.node = node}/>;
+ }
+}
+
+const onIssueChange = issue => dispatch => {
+ dispatch(receiveIssues([issue]));
+};
+
+const mapDispatchToProps = { onIssueChange };
+
+export default connect(null, mapDispatchToProps)(Issue);
diff --git a/server/sonar-web/src/main/js/components/issue/issue-view.js b/server/sonar-web/src/main/js/components/issue/issue-view.js
index 71ced0ff47a..a4a691fa955 100644
--- a/server/sonar-web/src/main/js/components/issue/issue-view.js
+++ b/server/sonar-web/src/main/js/components/issue/issue-view.js
@@ -34,16 +34,21 @@ import Template from './templates/issue.hbs';
import getCurrentUserFromStore from '../../app/utils/getCurrentUserFromStore';
export default Marionette.ItemView.extend({
- className: 'issue',
template: Template,
modelEvents: {
- 'change': 'render',
+ 'change': 'notifyAndRender',
'transition': 'onTransition'
},
+ className () {
+ const hasCheckbox = this.options.onCheck != null;
+ return hasCheckbox ? 'issue issue-with-checkbox' : 'issue';
+ },
+
events () {
return {
+ 'click': 'handleClick',
'click .js-issue-comment': 'onComment',
'click .js-issue-comment-edit': 'editComment',
'click .js-issue-comment-delete': 'deleteComment',
@@ -56,10 +61,24 @@ export default Marionette.ItemView.extend({
'click .js-issue-show-changelog': 'showChangeLog',
'click .js-issue-rule': 'showRule',
'click .js-issue-edit-tags': 'editTags',
- 'click .js-issue-locations': 'showLocations'
+ 'click .js-issue-locations': 'showLocations',
+ 'click .js-issue-filter': 'filterSimilarIssues',
+ 'click .js-toggle': 'onIssueCheck'
};
},
+ notifyAndRender () {
+ const { onIssueChange } = this.options;
+ if (onIssueChange) {
+ onIssueChange(this.model.toJSON());
+ }
+
+ // if ConnectedIssue is used, this view can be destroyed just after onIssueChange()
+ if (!this.isDestroyed) {
+ this.render();
+ }
+ },
+
onRender () {
this.$el.attr('data-key', this.model.get('key'));
},
@@ -243,19 +262,45 @@ export default Marionette.ItemView.extend({
this.model.trigger('locations', this.model);
},
+ select () {
+ this.$el.addClass('selected');
+ },
+
+ unselect () {
+ this.$el.removeClass('selected');
+ },
+
onTransition (transition) {
if (transition === 'falsepositive' || transition === 'wontfix') {
this.comment({ fromTransition: true });
}
},
+ handleClick (e) {
+ e.preventDefault();
+ const { onClick } = this.options;
+ if (onClick) {
+ onClick(this.model.get('key'));
+ }
+ },
+
+ filterSimilarIssues (e) {
+ this.options.onFilterClick(e);
+ },
+
+ onIssueCheck (e) {
+ this.options.onCheck(e);
+ },
+
serializeData () {
const issueKey = encodeURIComponent(this.model.get('key'));
return {
...Marionette.ItemView.prototype.serializeData.apply(this, arguments),
permalink: window.baseUrl + '/issues/search#issues=' + issueKey,
- hasSecondaryLocations: this.model.get('flows').length
+ hasSecondaryLocations: this.model.get('flows').length,
+ hasSimilarIssuesFilter: this.options.onFilterClick != null,
+ hasCheckbox: this.options.onCheck != null,
+ checked: this.options.checked
};
}
});
-
diff --git a/server/sonar-web/src/main/js/components/issue/templates/issue.hbs b/server/sonar-web/src/main/js/components/issue/templates/issue.hbs
index a828ecf5e3e..f951a40c0c4 100644
--- a/server/sonar-web/src/main/js/components/issue/templates/issue.hbs
+++ b/server/sonar-web/src/main/js/components/issue/templates/issue.hbs
@@ -35,6 +35,15 @@
<li class="issue-meta">
<a class="js-issue-permalink icon-link" href="{{permalink}}" target="_blank"></a>
</li>
+
+ {{#if hasSimilarIssuesFilter}}
+ <li class="issue-meta">
+ <button class="button-link issue-action issue-action-with-options js-issue-filter"
+ aria-label="{{t "issue.filter_similar_issues"}}">
+ <i class="icon-filter icon-half-transparent"></i>&nbsp;<i class="icon-dropdown"></i>
+ </button>
+ </li>
+ {{/if}}
</ul>
</td>
</tr>
@@ -165,3 +174,9 @@
<i class="issue-navigate-to-left icon-chevron-left"></i>
<i class="issue-navigate-to-right icon-chevron-right"></i>
</a>
+
+{{#if hasCheckbox}}
+ <div class="js-toggle issue-checkbox-container">
+ <i class="issue-checkbox icon-checkbox {{#if checked}}icon-checkbox-checked{{/if}}"></i>
+ </div>
+{{/if}}
diff --git a/server/sonar-web/src/main/js/components/issue/types.js b/server/sonar-web/src/main/js/components/issue/types.js
new file mode 100644
index 00000000000..dd0bbc1d2e6
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/issue/types.js
@@ -0,0 +1,40 @@
+/*
+ * 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
+export type TextRange = {
+ startLine: number,
+ startOffset: number,
+ endLine: number,
+ endOffset: number
+};
+
+export type Issue = {
+ key: string,
+ flows: Array<{
+ locations?: Array<{
+ msg: string,
+ textRange?: TextRange
+ }>
+ }>,
+ line?: number,
+ message: string,
+ severity: string,
+ textRange: TextRange
+};
diff --git a/server/sonar-web/src/main/js/components/shared/WithStore.js b/server/sonar-web/src/main/js/components/shared/WithStore.js
new file mode 100644
index 00000000000..f3cb83233e2
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/shared/WithStore.js
@@ -0,0 +1,44 @@
+/*
+ * 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 getStore from '../../app/utils/getStore';
+
+export default class WithStore extends React.Component {
+ store: {};
+ props: { children: Object };
+
+ static childContextTypes = {
+ store: React.PropTypes.object
+ };
+
+ constructor (props: { children: Object }) {
+ super(props);
+ this.store = getStore();
+ }
+
+ getChildContext () {
+ return { store: this.store };
+ }
+
+ render () {
+ return this.props.children;
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/source-viewer/SourceViewer.js b/server/sonar-web/src/main/js/components/source-viewer/SourceViewer.js
deleted file mode 100644
index 0589bcd0643..00000000000
--- a/server/sonar-web/src/main/js/components/source-viewer/SourceViewer.js
+++ /dev/null
@@ -1,82 +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.
- */
-import React from 'react';
-import BaseSourceViewer from './main';
-import { getPeriodDate, getPeriodLabel } from '../../helpers/periods';
-
-export default class SourceViewer extends React.Component {
- static propTypes = {
- component: React.PropTypes.shape({
- id: React.PropTypes.string.isRequired
- }).isRequired,
- period: React.PropTypes.object,
- line: React.PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.string])
- };
-
- componentDidMount () {
- this.renderSourceViewer();
- }
-
- shouldComponentUpdate (nextProps) {
- return nextProps.component.id !== this.props.component.id;
- }
-
- componentWillUpdate () {
- this.destroySourceViewer();
- }
-
- componentDidUpdate () {
- this.renderSourceViewer();
- }
-
- componentWillUnmount () {
- this.destroySourceViewer();
- }
-
- renderSourceViewer () {
- this.sourceViewer = new BaseSourceViewer();
- this.sourceViewer.render().$el.appendTo(this.refs.container);
- this.sourceViewer.open(this.props.component.id);
- this.sourceViewer.on('loaded', this.handleLoad.bind(this));
- }
-
- destroySourceViewer () {
- this.sourceViewer.destroy();
- }
-
- handleLoad () {
- const { period, line } = this.props;
-
- if (period) {
- const periodDate = getPeriodDate(period);
- const periodLabel = getPeriodLabel(period);
- this.sourceViewer.filterLinesByDate(periodDate, periodLabel);
- }
-
- if (line) {
- this.sourceViewer.highlightLine(line);
- this.sourceViewer.scrollToLine(line);
- }
- }
-
- render () {
- return <div ref="container"/>;
- }
-}
diff --git a/server/sonar-web/src/main/js/components/source-viewer/main.js b/server/sonar-web/src/main/js/components/source-viewer/main.js
index 58bfb9ce4f8..8b1725d09f3 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/main.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/main.js
@@ -21,7 +21,6 @@ import $ from 'jquery';
import moment from 'moment';
import sortBy from 'lodash/sortBy';
import toPairs from 'lodash/toPairs';
-import Backbone from 'backbone';
import Marionette from 'backbone.marionette';
import Source from './source';
import Issues from '../issue/collections/issues';
@@ -403,7 +402,7 @@ export default Marionette.LayoutView.extend({
const row = this.model.get('source').find(row => row.line === line);
const popup = new SCMPopupView({
triggerEl: $(e.currentTarget),
- model: new Backbone.Model(row)
+ line: row
});
popup.render();
},
@@ -422,8 +421,8 @@ export default Marionette.LayoutView.extend({
};
return $.get(url, options).done(data => {
const popup = new CoveragePopupView({
- row,
- collection: new Backbone.Collection(data.tests),
+ line: row,
+ tests: data.tests,
triggerEl: $(e.currentTarget)
});
popup.render();
@@ -468,10 +467,11 @@ export default Marionette.LayoutView.extend({
return isOk;
});
const popup = new DuplicationPopupView({
+ blocks,
inRemovedComponent,
- triggerEl: $(e.currentTarget),
- model: this.model,
- collection: new Backbone.Collection(blocks)
+ component: this.model.toJSON(),
+ files: this.model.get('duplicationFiles'),
+ triggerEl: $(e.currentTarget)
});
popup.render();
},
@@ -498,8 +498,7 @@ export default Marionette.LayoutView.extend({
const popup = new LineActionsPopupView({
line,
triggerEl: $(e.currentTarget),
- model: this.model,
- row: $(e.currentTarget).closest('.source-line')
+ component: this.model.toJSON()
});
popup.render();
},
diff --git a/server/sonar-web/src/main/js/components/source-viewer/measures-overlay.js b/server/sonar-web/src/main/js/components/source-viewer/measures-overlay.js
index a01d69b0f85..4baf170a2e8 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/measures-overlay.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/measures-overlay.js
@@ -34,7 +34,7 @@ export default ModalView.extend({
initialize () {
this.testsScroll = 0;
const requests = [this.requestMeasures(), this.requestIssues()];
- if (this.model.get('isUnitTest')) {
+ if (this.model.get('q') === 'UTS') {
requests.push(this.requestTests());
}
Promise.all(requests).then(() => this.render());
@@ -282,4 +282,3 @@ export default ModalView.extend({
};
}
});
-
diff --git a/server/sonar-web/src/main/js/components/source-viewer/more-actions.js b/server/sonar-web/src/main/js/components/source-viewer/more-actions.js
index 9b7181a6463..aba02a8e1de 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/more-actions.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/more-actions.js
@@ -50,8 +50,8 @@ export default Marionette.ItemView.extend({
},
openInWorkspace () {
- const uuid = this.options.parent.model.id;
- Workspace.openComponent({ uuid });
+ const key = this.options.parent.model.get('key');
+ Workspace.openComponent({ key });
},
showRawSource () {
@@ -66,4 +66,3 @@ export default Marionette.ItemView.extend({
};
}
});
-
diff --git a/server/sonar-web/src/main/js/components/source-viewer/popups/coverage-popup.js b/server/sonar-web/src/main/js/components/source-viewer/popups/coverage-popup.js
index 1440241e42a..68fd0ccc388 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/popups/coverage-popup.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/popups/coverage-popup.js
@@ -27,7 +27,7 @@ export default Popup.extend({
template: Template,
events: {
- 'click a[data-id]': 'goToFile'
+ 'click a[data-key]': 'goToFile'
},
onRender () {
@@ -37,19 +37,19 @@ export default Popup.extend({
goToFile (e) {
e.stopPropagation();
- const id = $(e.currentTarget).data('id');
- Workspace.openComponent({ uuid: id });
+ const key = $(e.currentTarget).data('key');
+ Workspace.openComponent({ key });
},
serializeData () {
- const row = this.options.row || {};
- const tests = groupBy(this.collection.toJSON(), 'fileId');
- const testFiles = Object.keys(tests).map(fileId => {
- const testSet = tests[fileId];
+ const row = this.options.line || {};
+ const tests = groupBy(this.options.tests, 'fileKey');
+ const testFiles = Object.keys(tests).map(fileKey => {
+ const testSet = tests[fileKey];
const test = testSet[0];
return {
file: {
- id: test.fileId,
+ key: test.fileKey,
longName: test.fileName
},
tests: testSet
@@ -58,4 +58,3 @@ export default Popup.extend({
return { testFiles, row };
}
});
-
diff --git a/server/sonar-web/src/main/js/components/source-viewer/popups/duplication-popup.js b/server/sonar-web/src/main/js/components/source-viewer/popups/duplication-popup.js
index 24ad94fe254..da542333a30 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/popups/duplication-popup.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/popups/duplication-popup.js
@@ -28,37 +28,35 @@ export default Popup.extend({
template: Template,
events: {
- 'click a[data-uuid]': 'goToFile'
+ 'click a[data-key]': 'goToFile'
},
goToFile (e) {
e.stopPropagation();
- const uuid = $(e.currentTarget).data('uuid');
+ const key = $(e.currentTarget).data('key');
const line = $(e.currentTarget).data('line');
- Workspace.openComponent({ uuid, line });
+ Workspace.openComponent({ key, line });
},
serializeData () {
const that = this;
- const files = this.model.get('duplicationFiles');
- const groupedBlocks = groupBy(this.collection.toJSON(), '_ref');
+ const groupedBlocks = groupBy(this.options.blocks, '_ref');
let duplications = Object.keys(groupedBlocks).map(fileRef => {
return {
blocks: groupedBlocks[fileRef],
- file: files[fileRef]
+ file: this.options.files[fileRef]
};
});
duplications = sortBy(duplications, d => {
- const a = d.file.projectName !== that.model.get('projectName');
- const b = d.file.subProjectName !== that.model.get('subProjectName');
- const c = d.file.key !== that.model.get('key');
+ const a = d.file.projectName !== that.options.component.projectName;
+ const b = d.file.subProjectName !== that.options.component.subProjectName;
+ const c = d.file.key !== that.options.component.key;
return '' + a + b + c;
});
return {
duplications,
- component: this.model.toJSON(),
+ component: this.options.component,
inRemovedComponent: this.options.inRemovedComponent
};
}
});
-
diff --git a/server/sonar-web/src/main/js/components/source-viewer/popups/line-actions-popup.js b/server/sonar-web/src/main/js/components/source-viewer/popups/line-actions-popup.js
index aa89585bc44..a2d94f568b8 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/popups/line-actions-popup.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/popups/line-actions-popup.js
@@ -29,9 +29,9 @@ export default Popup.extend({
getPermalink (e) {
e.preventDefault();
- const url =
- `${window.baseUrl}/component/index?id=${encodeURIComponent(this.model.key())}&line=${this.options.line}`;
+ const { component, line } = this.options;
+ const url = `${window.baseUrl}/component/index?id=${encodeURIComponent(component.key)}&line=${line}`;
const windowParams = 'resizable=1,scrollbars=1,status=1';
- window.open(url, this.model.get('name'), windowParams);
+ window.open(url, component.name, windowParams);
}
});
diff --git a/server/sonar-web/src/main/js/components/source-viewer/popups/scm-popup.js b/server/sonar-web/src/main/js/components/source-viewer/popups/scm-popup.js
index 755a866baec..f140e37c56b 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/popups/scm-popup.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/popups/scm-popup.js
@@ -34,6 +34,12 @@ export default Popup.extend({
onClick (e) {
e.stopPropagation();
+ },
+
+ serializeData () {
+ return {
+ ...Popup.prototype.serializeData.apply(this, arguments),
+ line: this.options.line
+ };
}
});
-
diff --git a/server/sonar-web/src/main/js/components/source-viewer/source.js b/server/sonar-web/src/main/js/components/source-viewer/source.js
index 43009061a22..3cb1198e328 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/source.js
+++ b/server/sonar-web/src/main/js/components/source-viewer/source.js
@@ -96,4 +96,3 @@ export default Backbone.Model.extend({
return source.some(line => line.coverageStatus != null);
}
});
-
diff --git a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-coverage-popup.hbs b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-coverage-popup.hbs
index a0e7b62896e..57c6301119e 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-coverage-popup.hbs
+++ b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-coverage-popup.hbs
@@ -15,7 +15,7 @@
{{#each testFiles}}
<div class="bubble-popup-section">
- <a class="component-viewer-popup-test-file link-action" data-id="{{file.id}}" title="{{file.longName}}">
+ <a class="component-viewer-popup-test-file link-action" data-key="{{file.key}}" title="{{file.longName}}">
<span>{{collapsePath file.longName}}</span>
</a>
<ul class="bubble-popup-list">
@@ -24,7 +24,7 @@
<i class="component-viewer-popup-test-status {{testStatusIconClass status}}"></i>
<span class="component-viewer-popup-test-name">
<a class="component-viewer-popup-test-file link-action" title="{{name}}"
- data-id="{{../file.id}}" data-method="{{name}}">
+ data-key="{{../file.key}}" data-method="{{name}}">
{{name}}
</a>
</span>
diff --git a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-duplication-popup.hbs b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-duplication-popup.hbs
index 9b0783c6655..ea8fc2b2349 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-duplication-popup.hbs
+++ b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-duplication-popup.hbs
@@ -21,7 +21,7 @@
{{#notEq file.key ../component.key}}
<div class="component-name-path">
- <a class="link-action" data-uuid="{{file.uuid}}" title="{{file.name}}">
+ <a class="link-action" data-key="{{file.key}}" title="{{file.name}}">
<span>{{collapsedDirFromPath file.name}}</span><span
class="component-name-file">{{fileFromPath file.name}}</span>
</a>
@@ -31,7 +31,7 @@
<div class="component-name-path">
Lines:
{{#joinEach blocks ','}}
- <a class="link-action" data-uuid="{{../file.uuid}}" data-line="{{this.from}}">
+ <a class="link-action" data-key="{{../file.key}}" data-line="{{this.from}}">
{{this.from}} – {{sum from size -1}}
</a>
{{/joinEach}}
diff --git a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-header.hbs b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-header.hbs
index e276c7e938b..a4532354481 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-header.hbs
+++ b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-header.hbs
@@ -16,7 +16,7 @@
<div class="component-name-path">
{{qualifierIcon q}}&nbsp;<span>{{collapsedDirFromPath path}}</span><span class="component-name-file">{{fileFromPath path}}</span>
- {{#if canMarkAsFavourite}}
+ {{#if canMarkAsFavorite}}
<a class="js-favorite component-name-favorite {{#if fav}}icon-favorite{{else}}icon-not-favorite{{/if}}"
title="{{#if fav}}{{t 'click_to_remove_from_favorites'}}{{else}}{{t 'click_to_add_to_favorites'}}{{/if}}">
</a>
diff --git a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-measures.hbs b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-measures.hbs
index a3f4df55605..0df076390c9 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-measures.hbs
+++ b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-measures.hbs
@@ -19,7 +19,16 @@
{{/unless}}
</div>
- {{#unless isUnitTest}}
+ {{#eq q 'UTS'}}
+ <div class="source-viewer-measures">
+ <div class="source-viewer-measures-section">
+ {{> 'measures/_source-viewer-measures-tests'}}
+ </div>
+ </div>
+ <div class="source-viewer-measures">
+ {{> 'measures/_source-viewer-measures-test-cases'}}
+ </div>
+ {{else}}
<div class="source-viewer-measures">
<div class="source-viewer-measures-section">
<div class="source-viewer-measures-card">
@@ -43,16 +52,7 @@
{{> 'measures/_source-viewer-measures-duplications'}}
</div>
</div>
- {{else}}
- <div class="source-viewer-measures">
- <div class="source-viewer-measures-section">
- {{> 'measures/_source-viewer-measures-tests'}}
- </div>
- </div>
- <div class="source-viewer-measures">
- {{> 'measures/_source-viewer-measures-test-cases'}}
- </div>
- {{/unless}}
+ {{/eq}}
<div class="spacer-bottom">&nbsp;</div>
diff --git a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-scm-popup.hbs b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-scm-popup.hbs
index 768ea72341d..dd82aca528c 100644
--- a/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-scm-popup.hbs
+++ b/server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-scm-popup.hbs
@@ -1,13 +1,13 @@
<div class="bubble-popup-container">
<div class="bubble-popup-section">
- {{scmAuthor}}
+ {{line.scmAuthor}}
</div>
<div class="bubble-popup-section">
- {{dt scmDate}}
+ {{dt line.scmDate}}
</div>
- {{#if scmRevision}}
+ {{#if line.scmRevision}}
<div class="bubble-popup-section">
- {{scmRevision}}
+ {{line.scmRevision}}
</div>
{{/if}}
</div>
diff --git a/server/sonar-web/src/main/js/components/workspace/main.js b/server/sonar-web/src/main/js/components/workspace/main.js
index 30082332e4b..4e1170bac38 100644
--- a/server/sonar-web/src/main/js/components/workspace/main.js
+++ b/server/sonar-web/src/main/js/components/workspace/main.js
@@ -99,7 +99,8 @@ Workspace.prototype = {
that.closeComponentViewer();
m.destroy();
});
- this.viewerView.render().$el.appendTo(document.body);
+ this.viewerView.$el.appendTo(document.body);
+ this.viewerView.render();
},
showComponentViewer (model) {
diff --git a/server/sonar-web/src/main/js/components/workspace/models/item.js b/server/sonar-web/src/main/js/components/workspace/models/item.js
index 0ecbef4ac33..1dd6daf7fc5 100644
--- a/server/sonar-web/src/main/js/components/workspace/models/item.js
+++ b/server/sonar-web/src/main/js/components/workspace/models/item.js
@@ -25,8 +25,8 @@ export default Backbone.Model.extend({
if (!this.has('__type__')) {
return 'type is missing';
}
- if (this.get('__type__') === 'component' && !this.has('uuid')) {
- return 'uuid is missing';
+ if (this.get('__type__') === 'component' && !this.has('key')) {
+ return 'key is missing';
}
if (this.get('__type__') === 'rule' && !this.has('key')) {
return 'key is missing';
diff --git a/server/sonar-web/src/main/js/components/workspace/models/items.js b/server/sonar-web/src/main/js/components/workspace/models/items.js
index 5d015e037ea..97ff41e2267 100644
--- a/server/sonar-web/src/main/js/components/workspace/models/items.js
+++ b/server/sonar-web/src/main/js/components/workspace/models/items.js
@@ -47,16 +47,13 @@ export default Backbone.Collection.extend({
},
has (model) {
- const forComponent = model.isComponent() && this.findWhere({ uuid: model.get('uuid') }) != null;
+ const forComponent = model.isComponent() && this.findWhere({ key: model.get('key') }) != null;
const forRule = model.isRule() && this.findWhere({ key: model.get('key') }) != null;
return forComponent || forRule;
},
add2 (model) {
- const tryModel = model.isComponent() ?
- this.findWhere({ uuid: model.get('uuid') }) :
- this.findWhere({ key: model.get('key') });
+ const tryModel = this.findWhere({ key: model.get('key') });
return tryModel != null ? tryModel : this.add(model);
}
});
-
diff --git a/server/sonar-web/src/main/js/components/workspace/views/viewer-view.js b/server/sonar-web/src/main/js/components/workspace/views/viewer-view.js
index 924ea80ad7f..7ab96e7c683 100644
--- a/server/sonar-web/src/main/js/components/workspace/views/viewer-view.js
+++ b/server/sonar-web/src/main/js/components/workspace/views/viewer-view.js
@@ -17,9 +17,13 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import $ from 'jquery';
+import React from 'react';
+import { render } from 'react-dom';
import BaseView from './base-viewer-view';
-import SourceViewer from '../../source-viewer/main';
+import SourceViewer from '../../SourceViewer/StandaloneSourceViewer';
import Template from '../templates/workspace-viewer.hbs';
+import WithStore from '../../shared/WithStore';
export default BaseView.extend({
template: Template,
@@ -29,22 +33,39 @@ export default BaseView.extend({
this.showViewer();
},
- showViewer () {
- const that = this;
- const viewer = new SourceViewer();
- const options = this.model.toJSON();
- viewer.open(this.model.get('uuid'), { workspace: true });
- viewer.on('loaded', () => {
- that.model.set({
- name: viewer.model.get('name'),
- q: viewer.model.get('q')
- });
- if (options.line != null) {
- viewer.highlightLine(options.line);
- viewer.scrollToLine(options.line);
+ scrollToLine (line) {
+ const row = this.$el.find(`.source-line[data-line-number="${line}"]`);
+ if (row.length > 0) {
+ const sourceViewer = this.$el.find('.source-viewer');
+ let p = sourceViewer.scrollParent();
+ if (p.is(document) || p.is('body')) {
+ p = $(window);
}
- });
- this.viewerRegion.show(viewer);
+ const pTopOffset = p.offset() != null ? p.offset().top : 0;
+ const pHeight = p.height();
+ const goal = row.offset().top - pHeight / 3 - pTopOffset;
+ p.scrollTop(goal);
+ }
+ },
+
+ showViewer () {
+ const { key, line } = this.model.toJSON();
+
+ const el = document.querySelector(this.viewerRegion.el);
+
+ render((
+ <WithStore>
+ <SourceViewer
+ component={key}
+ fromWorkspace={true}
+ highlightedLine={line}
+ onLoaded={component => {
+ this.model.set({ name: component.name, q: component.q });
+ if (line) {
+ this.scrollToLine(line);
+ }
+ }}/>
+ </WithStore>
+ ), el);
}
});
-
diff --git a/server/sonar-web/src/main/js/helpers/issues.js b/server/sonar-web/src/main/js/helpers/issues.js
new file mode 100644
index 00000000000..3a1e509f790
--- /dev/null
+++ b/server/sonar-web/src/main/js/helpers/issues.js
@@ -0,0 +1,121 @@
+/*
+ * 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 sortBy from 'lodash/sortBy';
+import { SEVERITIES } from './constants';
+
+type TextRange = {
+ startLine: number,
+ endLine: number,
+ startOffset: number,
+ endOffset: number
+};
+
+type Comment = {
+ login: string
+};
+
+type User = {
+ login: string
+};
+
+type RawIssue = {
+ assignee?: string,
+ author: string,
+ comments?: Array<Comment>,
+ component: string,
+ line?: number,
+ project: string,
+ rule: string,
+ status: string,
+ subProject?: string,
+ textRange?: TextRange
+};
+
+export const sortBySeverity = (issues: Array<*>) => (
+ sortBy(issues, issue => SEVERITIES.indexOf(issue.severity))
+);
+
+const injectRelational = (
+ issue: RawIssue | Comment,
+ source?: Array<*>,
+ baseField: string,
+ lookupField: string
+) => {
+ const newFields = {};
+ const baseValue = issue[baseField];
+ if (baseValue != null && source != null) {
+ const lookupValue = source.find(candidate => candidate[lookupField] === baseValue);
+ if (lookupValue != null) {
+ Object.keys(lookupValue).forEach(key => {
+ const newKey = baseField + key.charAt(0).toUpperCase() + key.slice(1);
+ newFields[newKey] = lookupValue[key];
+ });
+ }
+ }
+ return newFields;
+};
+
+const injectCommentsRelational = (issue: RawIssue, users?: Array<User>) => {
+ if (!issue.comments) {
+ return {};
+ }
+ const comments = issue.comments.map(comment => ({
+ ...comment,
+ author: comment.login,
+ login: undefined,
+ ...injectRelational(comment, users, 'author', 'login')
+ }));
+ return { comments };
+};
+
+const prepareClosed = (issue: RawIssue) => {
+ return issue.status === 'CLOSED' ? { flows: undefined } : {};
+};
+
+const ensureTextRange = (issue: RawIssue) => {
+ return issue.line && !issue.textRange ? {
+ textRange: {
+ startLine: issue.line,
+ endLine: issue.line,
+ startOffset: 0,
+ endOffset: 999999
+ }
+ } : {};
+};
+
+export const parseIssueFromResponse = (
+ issue: RawIssue,
+ components?: Array<*>,
+ users?: Array<*>,
+ rules?: Array<*>
+) => {
+ return {
+ ...issue,
+ ...injectRelational(issue, components, 'component', 'key'),
+ ...injectRelational(issue, components, 'project', 'key'),
+ ...injectRelational(issue, components, 'subProject', 'key'),
+ ...injectRelational(issue, rules, 'rule', 'key'),
+ ...injectRelational(issue, users, 'assignee', 'login'),
+ ...injectCommentsRelational(issue, users),
+ ...prepareClosed(issue),
+ ...ensureTextRange(issue)
+ };
+};
diff --git a/server/sonar-web/src/main/js/helpers/request.js b/server/sonar-web/src/main/js/helpers/request.js
index 80bb9e787cc..cbd5a4e7c01 100644
--- a/server/sonar-web/src/main/js/helpers/request.js
+++ b/server/sonar-web/src/main/js/helpers/request.js
@@ -146,19 +146,18 @@ export function request (url: string): Request {
* @returns {*}
*/
export function checkStatus (response: Response): Promise<Object> {
- if (response.status === 401) {
- // workaround cyclic dependencies
- const handleRequiredAuthentication = require('../app/utils/handleRequiredAuthentication').default;
- handleRequiredAuthentication();
- return Promise.reject();
- } else if (response.status >= 200 && response.status < 300) {
- return Promise.resolve(response);
- } else {
- const error = new Error(response.status);
- // $FlowFixMe complains that `response` is not found
- error.response = response;
- throw error;
- }
+ return new Promise((resolve, reject) => {
+ if (response.status === 401) {
+ // workaround cyclic dependencies
+ const handleRequiredAuthentication = require('../app/utils/handleRequiredAuthentication').default;
+ handleRequiredAuthentication();
+ reject();
+ } else if (response.status >= 200 && response.status < 300) {
+ resolve(response);
+ } else {
+ reject({ response });
+ }
+ });
}
/**
diff --git a/server/sonar-web/src/main/js/store/favorites/duck.js b/server/sonar-web/src/main/js/store/favorites/duck.js
index c97f715edbd..ceeb119abfa 100644
--- a/server/sonar-web/src/main/js/store/favorites/duck.js
+++ b/server/sonar-web/src/main/js/store/favorites/duck.js
@@ -17,32 +17,58 @@
* 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 uniq from 'lodash/uniq';
import without from 'lodash/without';
+type Favorite = { key: string };
+
+type ReceiveFavoritesAction = {
+ type: 'RECEIVE_FAVORITES',
+ favorites: Array<Favorite>,
+ notFavorites: Array<Favorite>
+};
+
+type AddFavoriteAction = {
+ type: 'ADD_FAVORITE',
+ componentKey: string
+};
+
+type RemoveFavoriteAction = {
+ type: 'REMOVE_FAVORITE',
+ componentKey: string
+};
+
+type Action = ReceiveFavoritesAction | AddFavoriteAction | RemoveFavoriteAction;
+
+type State = Array<string>;
+
export const actions = {
RECEIVE_FAVORITES: 'RECEIVE_FAVORITES',
ADD_FAVORITE: 'ADD_FAVORITE',
REMOVE_FAVORITE: 'REMOVE_FAVORITE'
};
-export const receiveFavorites = (favorites, notFavorites = []) => ({
+export const receiveFavorites = (
+ favorites: Array<Favorite>,
+ notFavorites: Array<Favorite> = []
+): ReceiveFavoritesAction => ({
type: actions.RECEIVE_FAVORITES,
favorites,
notFavorites
});
-export const addFavorite = componentKey => ({
+export const addFavorite = (componentKey: string): AddFavoriteAction => ({
type: actions.ADD_FAVORITE,
componentKey
});
-export const removeFavorite = componentKey => ({
+export const removeFavorite = (componentKey: string): RemoveFavoriteAction => ({
type: actions.REMOVE_FAVORITE,
componentKey
});
-export default (state = [], action = {}) => {
+export default (state: State = [], action: Action): State => {
if (action.type === actions.RECEIVE_FAVORITES) {
const toAdd = action.favorites.map(f => f.key);
const toRemove = action.notFavorites.map(f => f.key);
@@ -60,7 +86,6 @@ export default (state = [], action = {}) => {
return state;
};
-export const isFavorite = (state, componentKey) => (
+export const isFavorite = (state: State, componentKey: string) => (
state.includes(componentKey)
);
-
diff --git a/server/sonar-web/src/main/js/store/issues/duck.js b/server/sonar-web/src/main/js/store/issues/duck.js
new file mode 100644
index 00000000000..1126bcfd57f
--- /dev/null
+++ b/server/sonar-web/src/main/js/store/issues/duck.js
@@ -0,0 +1,52 @@
+/*
+ * 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 keyBy from 'lodash/keyBy';
+
+type Issue = { key: string };
+
+type ReceiveIssuesAction = {
+ type: 'RECEIVE_ISSUES',
+ issues: Array<Issue>
+};
+
+type Action = ReceiveIssuesAction;
+
+type State = { [key: string]: Issue };
+
+export const receiveIssues = (issues: Array<Issue>): ReceiveIssuesAction => ({
+ type: 'RECEIVE_ISSUES',
+ issues
+});
+
+const reducer = (state: State = {}, action: Action) => {
+ switch (action.type) {
+ case 'RECEIVE_ISSUES':
+ return { ...state, ...keyBy(action.issues, 'key') };
+ default:
+ return state;
+ }
+};
+
+export default reducer;
+
+export const getIssueByKey = (state: State, key: string): ?Issue => (
+ state[key]
+);
diff --git a/server/sonar-web/src/main/js/store/rootReducer.js b/server/sonar-web/src/main/js/store/rootReducer.js
index aee309845c2..1b8539f84eb 100644
--- a/server/sonar-web/src/main/js/store/rootReducer.js
+++ b/server/sonar-web/src/main/js/store/rootReducer.js
@@ -22,6 +22,7 @@ import appState from './appState/duck';
import components, * as fromComponents from './components/reducer';
import users, * as fromUsers from './users/reducer';
import favorites, * as fromFavorites from './favorites/duck';
+import issues, * as fromIssues from './issues/duck';
import languages, * as fromLanguages from './languages/reducer';
import measures, * as fromMeasures from './measures/reducer';
import notifications, * as fromNotifications from './notifications/duck';
@@ -40,6 +41,7 @@ export default combineReducers({
components,
globalMessages,
favorites,
+ issues,
languages,
measures,
notifications,
@@ -80,6 +82,10 @@ export const isFavorite = (state, componentKey) => (
fromFavorites.isFavorite(state.favorites, componentKey)
);
+export const getIssueByKey = (state, key) => (
+ fromIssues.getIssueByKey(state.issues, key)
+);
+
export const getComponentMeasure = (state, componentKey, metricKey) => (
fromMeasures.getComponentMeasure(state.measures, componentKey, metricKey)
);