diff options
Diffstat (limited to 'server/sonar-web/src')
23 files changed, 678 insertions, 0 deletions
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js index 7b319dd0fe7..24f8c446a6f 100644 --- a/server/sonar-web/src/main/js/api/components.js +++ b/server/sonar-web/src/main/js/api/components.js @@ -43,3 +43,9 @@ export function getFiles (componentKey, metrics = []) { return r.filter(component => component.qualifier === 'FIL'); }); } + +export function getComponent (componentKey, metrics = []) { + const url = baseUrl + '/api/resources/index'; + const data = { resource: componentKey, metrics: metrics.join(',') }; + return getJSON(url, data).then(r => r[0]); +} diff --git a/server/sonar-web/src/main/js/apps/code/actions/index.js b/server/sonar-web/src/main/js/apps/code/actions/index.js new file mode 100644 index 00000000000..9e9df67cfea --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/actions/index.js @@ -0,0 +1,70 @@ +import _ from 'underscore'; + +import { getChildren, getComponent } from '../../../api/components'; + + +const METRICS = [ + 'ncloc', + 'sqale_index', + 'violations', + // TODO handle other types of coverage + 'coverage', + 'duplicated_lines_density' +]; + + +export const INIT = 'INIT'; +export const BROWSE = 'BROWSE'; +export const RECEIVE_COMPONENTS = 'RECEIVE_COMPONENTS'; +export const SHOW_SOURCE = 'SHOW_SOURCE'; + + +export function requestComponents (baseComponent) { + return { + type: BROWSE, + baseComponent + }; +} + + +export function receiveComponents (baseComponent, components) { + return { + type: RECEIVE_COMPONENTS, + baseComponent, + components + }; +} + + +export function showSource (component) { + return { + type: SHOW_SOURCE, + component + }; +} + + +function fetchChildren (dispatch, baseComponent) { + dispatch(requestComponents(baseComponent)); + return getChildren(baseComponent.key, METRICS) + .then(components => _.sortBy(components, 'name')) + .then(components => dispatch(receiveComponents(baseComponent, components))); +} + + +export function initComponent (baseComponent) { + return dispatch => { + return getComponent(baseComponent.key, METRICS) + .then(component => fetchChildren(dispatch, component)); + }; +} + + +export function fetchComponents (baseComponent) { + return (dispatch, getState) => { + const { fetching } = getState(); + if (!fetching) { + return fetchChildren(dispatch, baseComponent); + } + }; +} diff --git a/server/sonar-web/src/main/js/apps/code/app.js b/server/sonar-web/src/main/js/apps/code/app.js new file mode 100644 index 00000000000..c16b4aa3057 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/app.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'react-dom'; +import { Provider } from 'react-redux'; + +import Code from './components/Code'; +import configureStore from './store/configureStore'; + + +const store = configureStore(); + + +window.sonarqube.appStarted.then(({ el, ...other }) => { + render( + <Provider store={store}> + <Code {...other}/> + </Provider>, + document.querySelector(el)); +}); diff --git a/server/sonar-web/src/main/js/apps/code/components/Breadcrumb.js b/server/sonar-web/src/main/js/apps/code/components/Breadcrumb.js new file mode 100644 index 00000000000..2e8f4f484f0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/Breadcrumb.js @@ -0,0 +1,13 @@ +import React from 'react'; + +import ComponentName from './ComponentName'; + + +const Breadcrumb = ({ component, onBrowse }) => ( + <ComponentName + component={component} + onBrowse={onBrowse}/> +); + + +export default Breadcrumb; diff --git a/server/sonar-web/src/main/js/apps/code/components/Breadcrumbs.js b/server/sonar-web/src/main/js/apps/code/components/Breadcrumbs.js new file mode 100644 index 00000000000..95a54ffc6e1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/Breadcrumbs.js @@ -0,0 +1,19 @@ +import React from 'react'; + +import Breadcrumb from './Breadcrumb'; + + +const Breadcrumbs = ({ breadcrumbs, onBrowse }) => ( + <ul className="code-breadcrumbs"> + {breadcrumbs.map((component, index) => ( + <li key={component.key}> + <Breadcrumb + component={component} + onBrowse={index + 1 < breadcrumbs.length ? onBrowse : null}/> + </li> + ))} + </ul> +); + + +export default Breadcrumbs; diff --git a/server/sonar-web/src/main/js/apps/code/components/Code.js b/server/sonar-web/src/main/js/apps/code/components/Code.js new file mode 100644 index 00000000000..d894ab8c952 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/Code.js @@ -0,0 +1,84 @@ +import classNames from 'classnames'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import Components from './Components'; +import Breadcrumbs from './Breadcrumbs'; +import SourceViewer from './SourceViewer'; +import { initComponent, fetchComponents, showSource } from '../actions'; +import { TooltipsContainer } from '../../../components/mixins/tooltips-mixin'; + + +class Code extends Component { + componentDidMount () { + const { dispatch, component } = this.props; + dispatch(initComponent(component)); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.component !== this.props.component) { + const { dispatch, component } = this.props; + dispatch(initComponent(component)); + } + } + + hasSourceCode (component) { + return component.qualifier === 'FIL' || component.qualifier === 'UTS'; + } + + handleBrowse (component) { + const { dispatch } = this.props; + if (this.hasSourceCode(component)) { + dispatch(showSource(component)); + } else { + dispatch(fetchComponents(component)); + } + } + + render () { + const { fetching, baseComponent, components, breadcrumbs, sourceViewer } = this.props; + const shouldShowBreadcrumbs = Array.isArray(breadcrumbs) && breadcrumbs.length > 1; + const shouldShowComponents = !sourceViewer && components; + const shouldShowSourceViewer = sourceViewer; + + const componentsClassName = classNames('spacer-top', { 'new-loading': fetching }); + + return ( + <TooltipsContainer options={{ delay: { show: 500, hide: 0 } }}> + <div className="page"> + <header className="page-header"> + <h1 className="page-title">{window.t('code.page')}</h1> + + {fetching && ( + <i className="spinner"/> + )} + + {shouldShowBreadcrumbs && ( + <Breadcrumbs + breadcrumbs={breadcrumbs} + onBrowse={this.handleBrowse.bind(this)}/> + )} + </header> + + {shouldShowComponents && ( + <div className={componentsClassName}> + <Components + baseComponent={baseComponent} + components={components} + onBrowse={this.handleBrowse.bind(this)}/> + </div> + )} + + {shouldShowSourceViewer && ( + <div className="spacer-top"> + <SourceViewer component={sourceViewer}/> + </div> + )} + </div> + </TooltipsContainer> + ); + } +} + + +export default connect(state => state)(Code); diff --git a/server/sonar-web/src/main/js/apps/code/components/Component.js b/server/sonar-web/src/main/js/apps/code/components/Component.js new file mode 100644 index 00000000000..e319d9ef826 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/Component.js @@ -0,0 +1,64 @@ +import React from 'react'; + +import ComponentName from './ComponentName'; +import ComponentMeasure from './ComponentMeasure'; +import ComponentDetach from './ComponentDetach'; + + +const Component = ({ component, onBrowse }) => ( + <tr> + <td className="thin nowrap"> + <span className="spacer-right"> + <ComponentDetach component={component}/> + </span> + </td> + <td> + <ComponentName + component={component} + onBrowse={onBrowse}/> + </td> + <td className="thin nowrap text-right"> + <div className="code-components-cell"> + <ComponentMeasure + component={component} + metricKey="ncloc" + metricType="SHORT_INT"/> + </div> + </td> + <td className="thin nowrap text-right"> + <div className="code-components-cell"> + <ComponentMeasure + component={component} + metricKey="sqale_index" + metricType="SHORT_WORK_DUR"/> + </div> + </td> + <td className="thin nowrap text-right"> + <div className="code-components-cell"> + <ComponentMeasure + component={component} + metricKey="violations" + metricType="SHORT_INT"/> + </div> + </td> + <td className="thin nowrap text-right"> + <div className="code-components-cell"> + <ComponentMeasure + component={component} + metricKey="coverage" + metricType="PERCENT"/> + </div> + </td> + <td className="thin nowrap text-right"> + <div className="code-components-cell"> + <ComponentMeasure + component={component} + metricKey="duplicated_lines_density" + metricType="PERCENT"/> + </div> + </td> + </tr> +); + + +export default Component; diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentDetach.js b/server/sonar-web/src/main/js/apps/code/components/ComponentDetach.js new file mode 100644 index 00000000000..5459c7a5a6b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentDetach.js @@ -0,0 +1,14 @@ +import React from 'react'; + +import { getComponentUrl } from '../../../helpers/urls'; + + +const ComponentDetach = ({ component }) => ( + <a + className="icon-detach" + target="_blank" + href={getComponentUrl(component.key)}/> +); + + +export default ComponentDetach; diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.js b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.js new file mode 100644 index 00000000000..112ee07dc9f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.js @@ -0,0 +1,17 @@ +import _ from 'underscore'; +import React from 'react'; + +import { formatMeasure } from '../../../helpers/measures'; + + +const ComponentMeasure = ({ component, metricKey, metricType }) => { + const measure = _.findWhere(component.msr, { key: metricKey }); + return ( + <span> + {measure ? formatMeasure(measure.val, metricType) : ''} + </span> + ); +}; + + +export default ComponentMeasure; diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentName.js b/server/sonar-web/src/main/js/apps/code/components/ComponentName.js new file mode 100644 index 00000000000..40031d80ec5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentName.js @@ -0,0 +1,35 @@ +import React from 'react'; + +import Truncated from './Truncated'; +import QualifierIcon from '../../../components/shared/qualifier-icon'; + + +// TODO collapse dirs + +const Component = ({ component, onBrowse }) => { + const handleClick = (e) => { + e.preventDefault(); + onBrowse(component); + }; + + return ( + <Truncated title={component.name}> + <QualifierIcon qualifier={component.qualifier}/> + {' '} + {onBrowse ? ( + <a + onClick={handleClick} + href="#"> + {component.name} + </a> + ) : ( + <span> + {component.name} + </span> + )} + </Truncated> + ); +}; + + +export default Component; diff --git a/server/sonar-web/src/main/js/apps/code/components/Components.js b/server/sonar-web/src/main/js/apps/code/components/Components.js new file mode 100644 index 00000000000..1167e319296 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/Components.js @@ -0,0 +1,42 @@ +import React from 'react'; + +import Component from './Component'; +import ComponentsEmpty from './ComponentsEmpty'; + + +const Components = ({ baseComponent, components, onBrowse }) => ( + <table className="data zebra"> + <thead> + <tr> + <th className="thin nowrap"> </th> + <th> </th> + <th className="thin nowrap text-right">{window.t('metric.ncloc.name')}</th> + <th className="thin nowrap text-right">{window.t('metric.sqale_index.short_name')}</th> + <th className="thin nowrap text-right">{window.t('metric.violations.name')}</th> + <th className="thin nowrap text-right">{window.t('metric.coverage.name')}</th> + <th className="thin nowrap text-right">{window.t('metric.duplicated_lines_density.short_name')}</th> + </tr> + </thead> + <tbody> + <Component component={baseComponent}/> + <tr className="blank"> + <td colSpan="7"> </td> + </tr> + </tbody> + <tbody> + {components.length ? ( + components.map(component => ( + <Component + key={component.key} + component={component} + onBrowse={onBrowse}/> + )) + ) : ( + <ComponentsEmpty/> + )} + </tbody> + </table> +); + + +export default Components; diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.js b/server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.js new file mode 100644 index 00000000000..81f702fe578 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.js @@ -0,0 +1,16 @@ +import React from 'react'; + + +const ComponentsEmpty = () => ( + <tr> + <td colSpan="2"> + {window.t('no_results')} + </td> + <td colSpan="5"> + + </td> + </tr> +); + + +export default ComponentsEmpty; diff --git a/server/sonar-web/src/main/js/apps/code/components/SourceViewer.js b/server/sonar-web/src/main/js/apps/code/components/SourceViewer.js new file mode 100644 index 00000000000..26fcc606833 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/SourceViewer.js @@ -0,0 +1,32 @@ +import React, { Component } from 'react'; + +import BaseSourceViewer from '../../../components/source-viewer/main'; + + +export default class SourceViewer extends Component { + componentDidMount () { + this.renderSourceViewer(); + } + + 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.uuid); + } + + destroySourceViewer () { + this.sourceViewer.destroy(); + } + + render () { + return <div ref="container"></div>; + } +} diff --git a/server/sonar-web/src/main/js/apps/code/components/Truncated.js b/server/sonar-web/src/main/js/apps/code/components/Truncated.js new file mode 100644 index 00000000000..cc58a7b79ff --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/Truncated.js @@ -0,0 +1,14 @@ +import React from 'react'; + + +const Truncated = ({ children, title }) => ( + <span + className="code-truncated" + title={title} + data-toggle="tooltip"> + {children} + </span> +); + + +export default Truncated; diff --git a/server/sonar-web/src/main/js/apps/code/reducers/index.js b/server/sonar-web/src/main/js/apps/code/reducers/index.js new file mode 100644 index 00000000000..d75ca950409 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/reducers/index.js @@ -0,0 +1,82 @@ +import { combineReducers } from 'redux'; + +import { BROWSE, RECEIVE_COMPONENTS, SHOW_SOURCE } from '../actions'; + + +export function fetching (state = false, action) { + switch (action.type) { + case BROWSE: + return true; + case RECEIVE_COMPONENTS: + return false; + default: + return state; + } +} + + +export function baseComponent (state = null, action) { + switch (action.type) { + case RECEIVE_COMPONENTS: + return action.baseComponent; + default: + return state; + } +} + + +export function components (state = null, action) { + switch (action.type) { + case RECEIVE_COMPONENTS: + return action.components; + default: + return state; + } +} + + +export function breadcrumbs (state = [], action) { + switch (action.type) { + case BROWSE: + const existedIndex = state.findIndex(b => b.key === action.baseComponent.key); + let nextBreadcrumbs; + + if (existedIndex === -1) { + // browse deeper + nextBreadcrumbs = [...state, action.baseComponent]; + } else { + // use breadcrumbs + nextBreadcrumbs = [...state.slice(0, existedIndex + 1)]; + } + + return nextBreadcrumbs; + case SHOW_SOURCE: + return [...state, action.component]; + default: + return state; + } +} + + +export function sourceViewer (state = null, action) { + switch (action.type) { + case BROWSE: + return null; + case SHOW_SOURCE: + return action.component; + default: + return state; + } +} + + +const rootReducer = combineReducers({ + fetching, + baseComponent, + components, + breadcrumbs, + sourceViewer +}); + + +export default rootReducer; diff --git a/server/sonar-web/src/main/js/apps/code/store/configureStore.js b/server/sonar-web/src/main/js/apps/code/store/configureStore.js new file mode 100644 index 00000000000..20d1587895a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/store/configureStore.js @@ -0,0 +1,16 @@ +import { createStore, applyMiddleware } from 'redux'; +import thunk from 'redux-thunk'; +import createLogger from 'redux-logger'; + +import rootReducer from '../reducers'; + + +const createStoreWithMiddleware = applyMiddleware( + thunk, + createLogger() +)(createStore); + + +export default function configureStore () { + return createStoreWithMiddleware(rootReducer); +} diff --git a/server/sonar-web/src/main/js/components/mixins/tooltips-mixin.js b/server/sonar-web/src/main/js/components/mixins/tooltips-mixin.js index 0b48fb83fcd..c2328d6e7ae 100644 --- a/server/sonar-web/src/main/js/components/mixins/tooltips-mixin.js +++ b/server/sonar-web/src/main/js/components/mixins/tooltips-mixin.js @@ -1,6 +1,8 @@ import $ from 'jquery'; +import React from 'react'; import ReactDOM from 'react-dom'; + export const TooltipsMixin = { componentDidMount () { this.initTooltips(); @@ -39,3 +41,48 @@ export const TooltipsMixin = { } } }; + + +export const TooltipsContainer = React.createClass({ + componentDidMount () { + this.initTooltips(); + }, + + componentWillUpdate() { + this.hideTooltips(); + }, + + componentDidUpdate () { + this.initTooltips(); + }, + + componentWillUnmount() { + this.destroyTooltips(); + }, + + initTooltips () { + if ($.fn && $.fn.tooltip) { + const options = Object.assign({ container: 'body', placement: 'bottom', html: true }, this.props.options); + $('[data-toggle="tooltip"]', ReactDOM.findDOMNode(this)) + .tooltip(options); + } + }, + + hideTooltips () { + if ($.fn && $.fn.tooltip) { + $('[data-toggle="tooltip"]', ReactDOM.findDOMNode(this)) + .tooltip('hide'); + } + }, + + destroyTooltips () { + if ($.fn && $.fn.tooltip) { + $('[data-toggle="tooltip"]', ReactDOM.findDOMNode(this)) + .tooltip('destroy'); + } + }, + + render () { + return this.props.children; + } +}); diff --git a/server/sonar-web/src/main/js/main/nav/component/component-nav-menu.js b/server/sonar-web/src/main/js/main/nav/component/component-nav-menu.js index b55e44cc2b3..1c097cb21e8 100644 --- a/server/sonar-web/src/main/js/main/nav/component/component-nav-menu.js +++ b/server/sonar-web/src/main/js/main/nav/component/component-nav-menu.js @@ -43,6 +43,11 @@ export default React.createClass({ return qualifier === 'DEV'; }, + isView() { + const qualifier = _.last(this.props.component.breadcrumbs).qualifier; + return qualifier === 'VW' || qualifier === 'SVW'; + }, + periodParameter() { let params = qs.parse(window.location.search.substr(1)); return params.period ? `&period=${params.period}` : ''; @@ -134,6 +139,15 @@ export default React.createClass({ </li>; }, + renderCodeLink() { + if (this.isView() || this.isDeveloper()) { + return null; + } + + const url = `/code/index?id=${encodeURIComponent(this.props.component.key)}`; + return this.renderLink(url, window.t('code.page'), '/code'); + }, + renderComponentsLink() { const url = `/components/index?id=${encodeURIComponent(this.props.component.key)}`; return this.renderLink(url, window.t('components.page'), '/components'); @@ -300,6 +314,7 @@ export default React.createClass({ <ul className="nav navbar-nav nav-tabs"> {!this.isDeveloper() && this.renderFixedDashboards()} {this.renderCustomDashboards()} + {this.renderCodeLink()} {this.renderComponentsLink()} {this.renderComponentIssuesLink()} {this.renderTools()} diff --git a/server/sonar-web/src/main/less/pages.less b/server/sonar-web/src/main/less/pages.less index 05892910ed5..2280226ec49 100644 --- a/server/sonar-web/src/main/less/pages.less +++ b/server/sonar-web/src/main/less/pages.less @@ -7,3 +7,4 @@ @import "pages/login"; @import "pages/api-documentation"; @import "pages/overview"; +@import "pages/code"; diff --git a/server/sonar-web/src/main/less/pages/code.less b/server/sonar-web/src/main/less/pages/code.less new file mode 100644 index 00000000000..ec86785ccc0 --- /dev/null +++ b/server/sonar-web/src/main/less/pages/code.less @@ -0,0 +1,40 @@ +@import (reference) "../mixins"; +@import (reference) "../variables"; +@import (reference) "../init/type"; + + +.code-breadcrumbs { + display: flex; + flex-wrap: wrap; + float: left; + margin-left: 10px; + padding-top: 5px; +} + +.code-breadcrumbs > li { + padding: 0 5px; +} + +.code-breadcrumbs > li::after { + position: relative; + top: -1px; + padding-left: 10px; + color: @secondFontColor; + font-size: 11px; + content: ">"; +} + +.code-breadcrumbs > li:last-child::after { + display: none; +} + +.code-components-cell { + width: 100px; +} + +.code-truncated { + display: inline-block; + vertical-align: text-top; + max-width: 300px; + .text-ellipsis; +} diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/api/resources_controller.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/api/resources_controller.rb index 35918a606ab..eab0fdb9e0c 100644 --- a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/api/resources_controller.rb +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/api/resources_controller.rb @@ -366,6 +366,7 @@ class Api::ResourcesController < Api::ApiController json = { 'id' => resource.id, + 'uuid' => resource.uuid, 'key' => resource.key, 'name' => resource.name, 'scope' => resource.scope, diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/code_controller.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/code_controller.rb new file mode 100644 index 00000000000..6eac33c87c5 --- /dev/null +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/code_controller.rb @@ -0,0 +1,29 @@ +# +# SonarQube, open source software quality management tool. +# Copyright (C) 2008-2014 SonarSource +# mailto:contact AT sonarsource DOT com +# +# SonarQube 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. +# +# SonarQube 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. +# +class CodeController < ApplicationController + before_filter :init_resource_for_user_role + + SECTION=Navigation::SECTION_RESOURCE + + def index + + end + +end diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/views/code/index.html.erb b/server/sonar-web/src/main/webapp/WEB-INF/app/views/code/index.html.erb new file mode 100644 index 00000000000..f5600f6ca03 --- /dev/null +++ b/server/sonar-web/src/main/webapp/WEB-INF/app/views/code/index.html.erb @@ -0,0 +1,3 @@ +<% content_for :extra_script do %> + <script src="<%= ApplicationController.root_context -%>/js/bundles/code.js?v=<%= sonar_version -%>"></script> +<% end %> |