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