From: Stas Vilchik Date: Thu, 2 Mar 2017 12:18:27 +0000 (+0100) Subject: refactor source viewer (#1705) X-Git-Tag: 6.4-RC1~837 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=ce9f0892fc3d15638c4eaa4054ed06f3d7e5fc19;p=sonarqube.git refactor source viewer (#1705) --- diff --git a/server/sonar-server/src/main/java/org/sonar/server/component/ws/AppAction.java b/server/sonar-server/src/main/java/org/sonar/server/component/ws/AppAction.java index 73fc5366f6e..fa07c2e8920 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/component/ws/AppAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/component/ws/AppAction.java @@ -44,14 +44,17 @@ import org.sonar.db.metric.MetricDto; import org.sonar.db.property.PropertyDto; import org.sonar.db.property.PropertyQuery; import org.sonar.server.component.ComponentFinder; +import org.sonar.server.component.ComponentFinder.ParamNames; import org.sonar.server.user.UserSession; import static com.google.common.collect.Lists.newArrayList; import static org.sonar.core.util.Uuids.UUID_EXAMPLE_01; +import static org.sonar.server.ws.KeyExamples.KEY_PROJECT_EXAMPLE_001; public class AppAction implements RequestHandler { private static final String PARAM_COMPONENT_ID = "componentId"; + private static final String PARAM_COMPONENT = "component"; private static final String PARAM_PERIOD = "period"; static final List METRIC_KEYS = newArrayList(CoreMetrics.LINES_KEY, CoreMetrics.VIOLATIONS_KEY, CoreMetrics.COVERAGE_KEY, CoreMetrics.DUPLICATED_LINES_DENSITY_KEY, CoreMetrics.TESTS_KEY, @@ -79,31 +82,40 @@ public class AppAction implements RequestHandler { action .createParam(PARAM_COMPONENT_ID) - .setRequired(true) .setDescription("Component ID") + .setDeprecatedSince("6.4") .setDeprecatedKey("uuid", "6.4") .setExampleValue(UUID_EXAMPLE_01); + action.createParam(PARAM_COMPONENT) + .setDescription("Component key") + .setExampleValue(KEY_PROJECT_EXAMPLE_001) + .setSince("6.4"); + action .createParam(PARAM_PERIOD) .setDescription("User leak Period in order to get differential measures") + .setDeprecatedSince("6.4") .setPossibleValues(1); } @Override public void handle(Request request, Response response) { - try (DbSession session = dbClient.openSession(false); - JsonWriter json = response.newJsonWriter()) { - json.beginObject(); - String componentUuid = request.mandatoryParam(PARAM_COMPONENT_ID); - ComponentDto component = componentFinder.getByUuid(session, componentUuid); + try (DbSession session = dbClient.openSession(false)) { + ComponentDto component = componentFinder.getByUuidOrKey(session, + request.param(PARAM_COMPONENT_ID), + request.param(PARAM_COMPONENT), + ParamNames.COMPONENT_ID_AND_COMPONENT); userSession.checkComponentPermission(UserRole.USER, component); + JsonWriter json = response.newJsonWriter(); + json.beginObject(); Map measuresByMetricKey = measuresByMetricKey(component, session); appendComponent(json, component, userSession, session); appendPermissions(json, component, userSession); appendMeasures(json, measuresByMetricKey); json.endObject(); + json.close(); } } @@ -138,7 +150,7 @@ public class AppAction implements RequestHandler { private static void appendPermissions(JsonWriter json, ComponentDto component, UserSession userSession) { boolean hasBrowsePermission = userSession.hasComponentPermission(UserRole.USER, component); - json.prop("canMarkAsFavourite", userSession.isLoggedIn() && hasBrowsePermission); + json.prop("canMarkAsFavorite", userSession.isLoggedIn() && hasBrowsePermission); } private static void appendMeasures(JsonWriter json, Map measuresByMetricKey) { diff --git a/server/sonar-server/src/main/resources/org/sonar/server/component/ws/app-example.json b/server/sonar-server/src/main/resources/org/sonar/server/component/ws/app-example.json index 8863fa6535d..d06748ba5e9 100644 --- a/server/sonar-server/src/main/resources/org/sonar/server/component/ws/app-example.json +++ b/server/sonar-server/src/main/resources/org/sonar/server/component/ws/app-example.json @@ -7,7 +7,7 @@ "project": "com.sonarsource:java-markdown", "projectName": "Java Markdown", "fav": false, - "canMarkAsFavourite": true, + "canMarkAsFavorite": true, "canCreateManualIssue": true, "measures": { "lines": "786", diff --git a/server/sonar-server/src/test/java/org/sonar/server/component/ws/ComponentsWsTest.java b/server/sonar-server/src/test/java/org/sonar/server/component/ws/ComponentsWsTest.java index ce42f92b41e..b1d32d8d8cf 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/component/ws/ComponentsWsTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/component/ws/ComponentsWsTest.java @@ -83,6 +83,6 @@ public class ComponentsWsTest { assertThat(action.isInternal()).isTrue(); assertThat(action.isPost()).isFalse(); assertThat(action.handler()).isNotNull(); - assertThat(action.params()).hasSize(2); + assertThat(action.params()).hasSize(3); } } diff --git a/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app.json b/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app.json index f7f2947c189..e3554e20775 100644 --- a/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app.json +++ b/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app.json @@ -10,6 +10,6 @@ "project": "org.sonarsource.sonarqube:sonarqube", "projectName": "SonarQube", "fav": false, - "canMarkAsFavourite": true, + "canMarkAsFavorite": true, "measures": {} } diff --git a/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_measures.json b/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_measures.json index f50715b4b2c..64548fda18f 100644 --- a/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_measures.json +++ b/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_measures.json @@ -10,7 +10,7 @@ "project": "org.sonarsource.sonarqube:sonarqube", "projectName": "SonarQube", "fav": false, - "canMarkAsFavourite": true, + "canMarkAsFavorite": true, "measures": { "lines": "200.0", "coverage": "95.4", diff --git a/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_ut_measure.json b/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_ut_measure.json index 59b6db6325a..f906f7dccbf 100644 --- a/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_ut_measure.json +++ b/server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_ut_measure.json @@ -10,7 +10,7 @@ "project": "org.sonarsource.sonarqube:sonarqube", "projectName": "SonarQube", "fav": false, - "canMarkAsFavourite": true, + "canMarkAsFavorite": true, "measures": { "coverage": "95.4" } diff --git a/server/sonar-web/.eslintrc b/server/sonar-web/.eslintrc index 6632e18d8e4..be74c6cf9b4 100644 --- a/server/sonar-web/.eslintrc +++ b/server/sonar-web/.eslintrc @@ -13,7 +13,8 @@ "globals": { "key": true, "d3": true, - "baseUrl": true + "baseUrl": true, + "SyntheticInputEvent": true }, "parser": "babel-eslint", 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 => ( 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> => { + 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 => ( 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 && (
- +
)} 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 (
@@ -140,8 +151,8 @@ export default class ListView extends React.Component { {!!selected && (
+ component={selected.key} + filterLine={filterLine}/>
)}
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 (
@@ -133,8 +144,8 @@ export default class TreeView extends React.Component { {!!selected && (
+ component={selected.key} + filterLine={filterLine}/>
)}
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 (
- +
); } diff --git a/server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js b/server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js deleted file mode 100644 index 95e18194208..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js +++ /dev/null @@ -1,35 +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 IssueView from '../workspace-list-item-view'; - -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 - }; - } -}); - 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 '
'; }, 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(( + + + + ), 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 @@ -
- -
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 @@ -
  • - -
  • 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 '
    '; }, 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(( + + + + ), 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 (
    - +
    ); } 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 }, + duplicatedFiles?: Array<{ key: string }>, + hasSourcesAfter: boolean, + highlightedLine: number | null, + highlightedSymbol: string | null, + issues?: Array, + issuesByLine: { [number]: Array }, + 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, + symbolsByLine: { [number]: Array } +}; + +const LINES = 500; + +const loadComponent = (key: string): Promise<*> => { + return getComponentForSourceViewer(key); +}; + +const loadSources = (key: string, from?: number, to?: number): Promise> => { + 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): Array { + 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) { + const hasSourcesBefore = sources.length > 0 && sources[0].line > 1; + return ( + + + + ); + } + + render () { + const { component, loading } = this.state; + + if (loading) { + return null; + } + + if (this.state.notExist) { + return ( +
    {translate('component_viewer.no_component')}
    + ); + } + + if (component == null) { + return null; + } + + const className = classNames('source-viewer', { 'source-duplications-expanded': this.state.displayDuplications }); + + return ( +
    this.node = node}> + + {this.state.notAccessible && ( +
    + {translate('code_viewer.no_source_code_displayed_due_to_security')} +
    + )} + {this.state.sources != null && this.renderCode(this.state.sources)} +
    + ); + } +} 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, + duplicationsByLine: { [number]: Array }, + duplicatedFiles?: Array<{ key: string }>, + filterLine?: (SourceLine) => boolean, + hasSourcesAfter: boolean, + hasSourcesBefore: boolean, + highlightedLine: number | null, + highlightedSymbol: string | null, + issues: Array, + issuesByLine: { [number]: Array }, + 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, + symbolsByLine: { [number]: Array } + }; + + 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 { + 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 ( + 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 ( +
    + {this.props.hasSourcesBefore && ( +
    + {this.props.loadingSourcesBefore ? ( +
    + + {translate('source_viewer.loading_more_code')} +
    + ) : ( + + )} +
    + )} + + + + {hasFileIssues && ( + this.renderLine(ZERO_LINE, -1, hasCoverage, hasDuplications, displayFiltered, hasIssues) + )} + {sources.map((line, index) => ( + this.renderLine(line, index, hasCoverage, hasDuplications, displayFiltered, hasIssues) + ))} + +
    + + {this.props.hasSourcesAfter && ( +
    + {this.props.loadingSourcesAfter ? ( +
    + + {translate('source_viewer.loading_more_code')} +
    + ) : ( + + )} +
    + )} +
    + ); + } +} 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 ( +
    +
    +
    +
    + + {projectName} + +
    + + {subProject != null && ( +
    + + {subProjectName} + +
    + )} + +
    + + {' '} + {collapsedDirFromPath(path)} + {fileFromPath(path)} + + {this.props.component.canMarkAsFavorite && ( + + )} +
    +
    +
    + + + +
    + {isUnitTest && ( +
    + {formatMeasure(measures.tests, 'SHORT_INT')} + {translate('metric.tests.name')} +
    + )} + + {!isUnitTest && ( +
    + {formatMeasure(measures.lines, 'SHORT_INT')} + {translate('metric.lines.name')} +
    + )} + +
    + + + {measures.issues != null ? formatMeasure(measures.issues, 'SHORT_INT') : 0} + {' '} + + + + {translate('metric.violations.name')} +
    + + {measures.coverage != null && ( +
    + {formatMeasure(measures.coverage, 'PERCENT')} + {translate('metric.coverage.name')} +
    + )} + + {measures.duplicationDensity != null && ( +
    + + {formatMeasure(measures.duplicationDensity, 'PERCENT')} + + {translate('duplications')} +
    + )} +
    +
    + ); + } +} 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 ( + + ); + } +} + +const mapStateToProps = (state, ownProps: { issues: Array }) => { + 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, + duplicationsCount: number, + filtered: boolean | null, + highlighted: boolean, + highlightedSymbol: string | null, + issueLocations: Array<{ from: number, to: number }>, + issues: Array, + 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; + + 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 ( + + ); + } + + renderSCM () { + const { line } = this.props; + const clickable = !!line.line; + return ( + + {this.props.displaySCM && ( +
    + )} + + ); + } + + renderCoverage () { + const { line } = this.props; + const className = 'source-meta source-line-coverage' + + (line.coverageStatus != null ? ` source-line-${line.coverageStatus}` : ''); + return ( + +
    + + ); + } + + 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 ( + +
    + + ); + } + + 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 ( + +
    + + ); + }; + + 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 ( + + {hasIssues && ( + + )} + {issues.length > 1 && ( + {issues.length} + )} + + ); + } + + renderSecondaryIssueLocationMessages (locationMessages: Array<{ msg: string, index?: number }>) { + const limitString = (str: string) => ( + str.length > 30 ? str.substr(0, 30) + '...' : str + ); + + return ( +
    + {locationMessages.map((locationMessage, index) => ( +
    + {locationMessage.index && ( + {locationMessage.index}: + )} + {limitString(locationMessage.msg)} +
    + ))} +
    + ); + } + + 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 ( + +
    +
     this.codeNode = node} dangerouslySetInnerHTML={{ __html: finalCode }}/>
    +          {secondaryIssueLocationMessages != null && secondaryIssueLocationMessages.length > 0 && (
    +            this.renderSecondaryIssueLocationMessages(secondaryIssueLocationMessages)
    +          )}
    +        
    + {showIssues && ( +
    + {issues.map(issue => ( + + ))} +
    + )} + + ); + } + + 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 ( + + {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 && ( + +
    + + )} + + {this.renderCode()} + + ); + } +} 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 ( + + ); + } +} 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; + +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 => ( + `${escapeHtml(token.text)}` + )).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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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 => { + 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 => { + 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 => { + 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/components/issue/ConnectedIssue.js b/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js new file mode 100644 index 00000000000..28be4c7ba4b --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js @@ -0,0 +1,29 @@ +/* + * 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 Issue from './Issue'; +import { getIssueByKey } from '../../store/rootReducer'; + +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
    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 @@
  • + + {{#if hasSimilarIssuesFilter}} +
  • + +
  • + {{/if}} @@ -165,3 +174,9 @@ + +{{#if hasCheckbox}} +
    + +
    +{{/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
    ; - } -} 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}}
    - + {{collapsePath file.longName}}
      @@ -24,7 +24,7 @@ + data-key="{{../file.key}}" data-method="{{name}}"> {{name}} 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}}
      - + {{collapsedDirFromPath file.name}}{{fileFromPath file.name}} @@ -31,7 +31,7 @@
      Lines: {{#joinEach blocks ','}} - + {{this.from}} – {{sum from size -1}} {{/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 @@
      {{qualifierIcon q}} {{collapsedDirFromPath path}}{{fileFromPath path}} - {{#if canMarkAsFavourite}} + {{#if canMarkAsFavorite}} 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}}
      - {{#unless isUnitTest}} + {{#eq q 'UTS'}} +
      +
      + {{> 'measures/_source-viewer-measures-tests'}} +
      +
      +
      + {{> 'measures/_source-viewer-measures-test-cases'}} +
      + {{else}}
      @@ -43,16 +52,7 @@ {{> 'measures/_source-viewer-measures-duplications'}}
      - {{else}} -
      -
      - {{> 'measures/_source-viewer-measures-tests'}} -
      -
      -
      - {{> 'measures/_source-viewer-measures-test-cases'}} -
      - {{/unless}} + {{/eq}}
       
      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 @@
      - {{scmAuthor}} + {{line.scmAuthor}}
      - {{dt scmDate}} + {{dt line.scmDate}}
      - {{#if scmRevision}} + {{#if line.scmRevision}}
      - {{scmRevision}} + {{line.scmRevision}}
      {{/if}}
      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(( + + { + this.model.set({ name: component.name, q: component.q }); + if (line) { + this.scrollToLine(line); + } + }}/> + + ), 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, + 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) => { + 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 { - 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, + notFavorites: Array +}; + +type AddFavoriteAction = { + type: 'ADD_FAVORITE', + componentKey: string +}; + +type RemoveFavoriteAction = { + type: 'REMOVE_FAVORITE', + componentKey: string +}; + +type Action = ReceiveFavoritesAction | AddFavoriteAction | RemoveFavoriteAction; + +type State = Array; + 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, + notFavorites: Array = [] +): 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 +}; + +type Action = ReceiveIssuesAction; + +type State = { [key: string]: Issue }; + +export const receiveIssues = (issues: Array): 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) ); diff --git a/server/sonar-web/src/main/less/components/issues.less b/server/sonar-web/src/main/less/components/issues.less index b56858fe641..8202664e4cc 100644 --- a/server/sonar-web/src/main/less/components/issues.less +++ b/server/sonar-web/src/main/less/components/issues.less @@ -50,7 +50,8 @@ border-color: @issueBorderColor !important; } -.issue + .issue { +.issue + .issue, +.issue-container + .issue-container { margin-top: 5px; } diff --git a/server/sonar-web/src/main/less/components/source.less b/server/sonar-web/src/main/less/components/source.less index 18a2cfa5d1c..9a89d87959a 100644 --- a/server/sonar-web/src/main/less/components/source.less +++ b/server/sonar-web/src/main/less/components/source.less @@ -143,6 +143,14 @@ user-select: none; } +.source-meta:focus { + outline: none; +} + +.source-meta[role="button"] { + cursor: pointer; +} + .source-meta + .source-meta { border-left: 1px solid @barBackgroundColor; } @@ -154,10 +162,6 @@ color: @secondFontColor; text-align: right; - &[data-line-number] { - cursor: pointer; - } - &:before { content: attr(data-line-number); } @@ -207,10 +211,6 @@ .source-line-scm { padding: 0 5px; background-color: @barBackgroundColor; - - &[data-line-number] { - cursor: pointer; - } } .source-line-scm-inner { @@ -229,29 +229,21 @@ height: @source-line-height; } -.source-line-with-issues { - cursor: pointer; -} - .source-line-covered { background-color: @green !important; - cursor: pointer; } .source-line-uncovered { background-color: @red !important; - cursor: pointer; } .source-line-partially-covered { background-color: @orange !important; background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, .5) 4px, transparent 4px, transparent 8px, rgba(255, 255, 255, .5) 8px, rgba(255, 255, 255, .5) 12px, transparent 12px, transparent 16px, rgba(255, 255, 255, .5) 16px, rgba(255, 255, 255, .5) 20px) !important; - cursor: pointer; } .source-line-duplicated { background-color: @duplicationColor !important; - cursor: pointer; } diff --git a/server/sonar-web/src/main/less/pages/issues.less b/server/sonar-web/src/main/less/pages/issues.less index fd1ddd7431e..98bf375c2ea 100644 --- a/server/sonar-web/src/main/less/pages/issues.less +++ b/server/sonar-web/src/main/less/pages/issues.less @@ -45,11 +45,15 @@ padding: 0 10px; } -.issues-workspace-list-component + .issue { +.issues-workspace-list-item + .issues-workspace-list-item { + margin-top: 5px; +} + +.issues-workspace-list-component + .issues-workspace-list-item { margin-top: 10px; } -.issue + .issues-workspace-list-component { +.issues-workspace-list-item + .issues-workspace-list-component { margin-top: 25px; } diff --git a/server/sonar-web/src/main/less/sonar-colorizer.less b/server/sonar-web/src/main/less/sonar-colorizer.less index 325bcad8177..760aec28453 100644 --- a/server/sonar-web/src/main/less/sonar-colorizer.less +++ b/server/sonar-web/src/main/less/sonar-colorizer.less @@ -70,5 +70,11 @@ cursor: pointer; } .highlighted { - background-color: #B3D4FF; + background-color: #b3d4ff; + animation: highlightedFadeIn 0.3s forwards; +} + +@keyframes highlightedFadeIn { + from { background-color: transparent; } + to { background-color: #b3d4ff; } }