diff options
author | Stas Vilchik <vilchiks@gmail.com> | 2015-12-14 16:16:44 +0100 |
---|---|---|
committer | Stas Vilchik <vilchiks@gmail.com> | 2015-12-16 11:17:32 +0100 |
commit | 5af187a6a0bda4f14de5d8ba5c6088c3a00d3e4e (patch) | |
tree | 24c4b4272bd73b225d73db23523f3441a486f2bc /server/sonar-web | |
parent | 5995432e51e72623804ffe926316b84d18913657 (diff) | |
download | sonarqube-5af187a6a0bda4f14de5d8ba5c6088c3a00d3e4e.tar.gz sonarqube-5af187a6a0bda4f14de5d8ba5c6088c3a00d3e4e.zip |
SONAR-7143 display list of components on the code page
Diffstat (limited to 'server/sonar-web')
27 files changed, 1067 insertions, 1 deletions
diff --git a/server/sonar-web/.babelrc b/server/sonar-web/.babelrc index dbaabd522d5..8c43a477c18 100644 --- a/server/sonar-web/.babelrc +++ b/server/sonar-web/.babelrc @@ -1,5 +1,5 @@ { - "presets": ["es2015", "react"], + "presets": ["es2015", "stage-0", "react"], "ignore": [ "**/libs/**" ] diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index b1db429ef8f..53e6738360b 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -10,6 +10,7 @@ "babel-polyfill": "^6.3.14", "babel-preset-es2015": "^6.3.13", "babel-preset-react": "^6.3.13", + "babel-preset-stage-0": "^6.3.13", "babel-register": "^6.3.13", "babelify": "7.2.0", "backbone": "1.2.3", @@ -22,6 +23,7 @@ "clipboard": "1.5.5", "d3": "3.5.6", "del": "2.0.2", + "enzyme": "^1.2.0", "eslint": "^1.10.3", "eslint-plugin-import": "^0.11.0", "eslint-plugin-mocha": "^1.1.0", @@ -52,7 +54,11 @@ "react": "0.14.2", "react-addons-test-utils": "0.14.2", "react-dom": "0.14.2", + "react-redux": "^4.0.1", "react-select": "1.0.0-beta6", + "redux": "^3.0.5", + "redux-logger": "^2.2.1", + "redux-thunk": "^1.0.2", "sinon": "1.15.4", "sinon-chai": "2.8.0", "underscore": "1.8.3", 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 %> diff --git a/server/sonar-web/tests/apps/code/components-test.js b/server/sonar-web/tests/apps/code/components-test.js new file mode 100644 index 00000000000..92ed3427f75 --- /dev/null +++ b/server/sonar-web/tests/apps/code/components-test.js @@ -0,0 +1,284 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { shallow } from 'enzyme'; +import React from 'react'; +import TestUtils from 'react-addons-test-utils'; + +import Breadcrumb from '../../../src/main/js/apps/code/components/Breadcrumb'; +import Breadcrumbs from '../../../src/main/js/apps/code/components/Breadcrumbs'; +import Component from '../../../src/main/js/apps/code/components/Component'; +import ComponentDetach from '../../../src/main/js/apps/code/components/ComponentDetach'; +import ComponentMeasure from '../../../src/main/js/apps/code/components/ComponentMeasure'; +import ComponentName from '../../../src/main/js/apps/code/components/ComponentName'; +import Components from '../../../src/main/js/apps/code/components/Components'; +import ComponentsEmpty from '../../../src/main/js/apps/code/components/ComponentsEmpty'; +import Truncated from '../../../src/main/js/apps/code/components/Truncated'; + +import { getComponentUrl } from '../../../src/main/js/helpers/urls'; +import QualifierIcon from '../../../src/main/js/components/shared/qualifier-icon'; + + +chai.use(sinonChai); + + +const measures = [ + { key: 'ncloc', val: 9757 } +]; +const exampleComponent = { + key: 'A', + name: 'AA', + qualifier: 'TRK', + msr: measures +}; +const exampleComponent2 = { key: 'B' }; +const exampleComponent3 = { key: 'C' }; +const exampleOnBrowse = sinon.spy(); + + +describe('Code :: Components', () => { + + describe('<Breadcrumb/>', () => { + it('should render <ComponentName/>', () => { + const output = shallow( + <Breadcrumb + component={exampleComponent} + onBrowse={exampleOnBrowse}/> + ); + + expect(output.type()) + .to.equal(ComponentName); + expect(output.props()) + .to.deep.equal({ component: exampleComponent, onBrowse: exampleOnBrowse }) + }); + }); + + describe('<Breadcrumbs/>', () => { + let output; + let list; + + before(() => { + output = shallow( + <Breadcrumbs + breadcrumbs={[exampleComponent, exampleComponent2, exampleComponent3]} + onBrowse={exampleOnBrowse}/>); + list = output.find(Breadcrumb); + }); + + it('should render list of <Breadcrumb/>s', () => { + expect(list) + .to.have.length(3); + expect(list.at(0).prop('component')) + .to.equal(exampleComponent); + expect(list.at(1).prop('component')) + .to.equal(exampleComponent2); + expect(list.at(2).prop('component')) + .to.equal(exampleComponent3); + }); + + it('should pass onBrowse to all components except the last one', () => { + expect(list.at(0).prop('onBrowse')) + .to.equal(exampleOnBrowse); + expect(list.at(1).prop('onBrowse')) + .to.equal(exampleOnBrowse); + expect(list.at(2).prop('onBrowse')) + .to.equal(null); + }); + }); + + describe('<Component/>', () => { + let output; + + before(() => { + output = shallow( + <Component + component={exampleComponent} + onBrowse={exampleOnBrowse}/>); + }); + + it('should render <ComponentName/>', () => { + const findings = output.find(ComponentName); + expect(findings) + .to.have.length(1); + expect(findings.first().props()) + .to.deep.equal({ component: exampleComponent, onBrowse: exampleOnBrowse }); + }); + + it('should render <ComponentMeasure/>s', () => { + const findings = output.find(ComponentMeasure); + expect(findings) + .to.have.length(5); + expect(findings.at(0).props()) + .to.deep.equal({ component: exampleComponent, metricKey: 'ncloc', metricType: 'SHORT_INT' }); + expect(findings.at(1).props()) + .to.deep.equal({ component: exampleComponent, metricKey: 'sqale_index', metricType: 'SHORT_WORK_DUR' }); + expect(findings.at(2).props()) + .to.deep.equal({ component: exampleComponent, metricKey: 'violations', metricType: 'SHORT_INT' }); + expect(findings.at(3).props()) + .to.deep.equal({ component: exampleComponent, metricKey: 'coverage', metricType: 'PERCENT' }); + expect(findings.at(4).props()) + .to.deep.equal({ component: exampleComponent, metricKey: 'duplicated_lines_density', metricType: 'PERCENT' }); + }); + + it('should render <ComponentDetach/>', () => { + const findings = output.find(ComponentDetach); + expect(findings) + .to.have.length(1); + expect(findings.first().props()) + .to.deep.equal({ component: exampleComponent }); + }); + }); + + describe('<ComponentDetach/>', () => { + it('should render link', () => { + const output = shallow( + <ComponentDetach component={exampleComponent}/>); + const expectedUrl = getComponentUrl(exampleComponent.key); + + expect(output.type()) + .to.equal('a'); + expect(output.prop('target')) + .to.equal('_blank'); + expect(output.prop('href')) + .to.equal(expectedUrl); + }); + }); + + describe('<ComponentMeasure/>', () => { + it('should render formatted measure', () => { + const output = shallow( + <ComponentMeasure + component={exampleComponent} + metricKey="ncloc" + metricType="SHORT_INT"/>); + + expect(output.text()) + .to.equal('9.8k'); + }); + + it('should not render measure', () => { + const output = shallow( + <ComponentMeasure + component={exampleComponent} + metricKey="random" + metricType="SHORT_INT"/>); + + expect(output.text()) + .to.equal(''); + }); + }); + + describe('<ComponentName/>', () => { + it('should render <QualifierIcon/>', () => { + const output = shallow( + <ComponentName + component={exampleComponent} + onBrowse={exampleOnBrowse}/>); + const findings = output.find(QualifierIcon); + + expect(findings) + .to.have.length(1); + expect(findings.first().prop('qualifier')) + .to.equal('TRK'); + }); + + it('should render link to component', () => { + const output = shallow( + <ComponentName + component={exampleComponent} + onBrowse={exampleOnBrowse}/>); + const findings = output.find('a'); + + expect(findings) + .to.have.length(1); + expect(findings.first().text()) + .to.equal('AA'); + }); + + it('should not render link to component', () => { + const output = shallow( + <ComponentName + component={exampleComponent} + onBrowse={null}/>); + const findings = output.find('span'); + + expect(output.find('a')) + .to.have.length(0); + expect(findings) + .to.have.length(1); + expect(findings.first().text()) + .to.equal('AA'); + }); + + it('should browse on click', () => { + const spy = sinon.spy(); + const preventDefaultSpy = sinon.spy(); + const output = shallow( + <ComponentName + component={exampleComponent} + onBrowse={spy}/>); + const findings = output.find('a'); + + findings.first().simulate('click', { preventDefault: preventDefaultSpy }); + + expect(preventDefaultSpy).to.have.been.called; + expect(spy).to.have.been.calledWith(exampleComponent); + }); + }); + + describe('<Components/>', () => { + let output; + + before(() => { + output = shallow( + <Components + baseComponent={exampleComponent} + components={[exampleComponent2, exampleComponent3]} + onBrowse={exampleOnBrowse}/>); + }); + + it('should render base component', () => { + const findings = output.findWhere(node => { + return node.type() === Component && node.prop('component') === exampleComponent; + }); + + expect(findings) + .to.have.length(1); + expect(findings.first().prop('onBrowse')) + .to.not.be.ok; + }); + + it('should render children component', () => { + const findings = output.findWhere(node => { + return node.type() === Component && node.prop('component') !== exampleComponent; + }); + + expect(findings) + .to.have.length(2); + expect(findings.at(0).prop('onBrowse')) + .to.equal(exampleOnBrowse) + }); + }); + + describe('<ComponentsEmpty/>', () => { + it('should render', () => { + const output = shallow(<ComponentsEmpty/>); + + expect(output.text()) + .to.include('no_results'); + }); + }); + + describe('<Truncated/>', () => { + it('should render and set title', () => { + const output = shallow(<Truncated title="ABC">123</Truncated>); + + expect(output.type()) + .to.equal('span'); + expect(output.text()) + .to.equal('123'); + expect(output.prop('title')) + .to.equal('ABC'); + }); + }); +}); diff --git a/server/sonar-web/tests/apps/code/store-test.js b/server/sonar-web/tests/apps/code/store-test.js new file mode 100644 index 00000000000..039e199a19b --- /dev/null +++ b/server/sonar-web/tests/apps/code/store-test.js @@ -0,0 +1,98 @@ +import { expect } from 'chai'; + +import { + fetching, + baseComponent, + components, + breadcrumbs, + sourceViewer +} from '../../../src/main/js/apps/code/reducers'; +import { + requestComponents, + receiveComponents, + showSource +} from '../../../src/main/js/apps/code/actions'; + + +const exampleComponent = { key: 'A' }; + + +describe('Code :: Store', () => { + //describe('action creators'); + + describe('reducers', () => { + describe('fetching', () => { + it('should be initially false', () => { + expect(fetching(undefined, {})) + .to.equal(false); + }); + + it('should be true after requesting components', () => { + expect(fetching(false, requestComponents())) + .to.equal(true); + }); + + it('should be false after receiving components', () => { + expect(fetching(true, receiveComponents({}, []))) + .to.equal(false); + }); + }); + + describe('baseComponent', () => { + it('should not be set after requesting components', () => { + const component = {}; + expect(baseComponent(null, requestComponents(component))) + .to.equal(null); + }); + + it('should be set after receiving components', () => { + const component = {}; + expect(baseComponent(null, receiveComponents(component, []))) + .to.equal(component); + }); + }); + + describe('components', () => { + it('should be set after receiving components', () => { + const list = [exampleComponent]; + expect(components(null, receiveComponents({}, list))) + .to.equal(list); + }); + }); + + describe('breadcrumbs', () => { + it('should push new component on BROWSE', () => { + const stateBefore = []; + const stateAfter = [exampleComponent]; + expect(breadcrumbs(stateBefore, requestComponents(exampleComponent))) + .to.deep.equal(stateAfter); + }); + + it('should push new component on SHOW_SOURCE', () => { + const stateBefore = []; + const stateAfter = [exampleComponent]; + expect(breadcrumbs(stateBefore, showSource(exampleComponent))) + .to.deep.equal(stateAfter); + }); + + it('should cut the tail', () => { + const stateBefore = [{ key: 'B' }, exampleComponent, { key: 'C' }]; + const stateAfter = [{ key: 'B' }, exampleComponent]; + expect(breadcrumbs(stateBefore, requestComponents(exampleComponent))) + .to.deep.equal(stateAfter); + }); + }); + + describe('sourceViewer', () => { + it('should be set on SHOW_SOURCE', () => { + expect(sourceViewer(null, showSource(exampleComponent))) + .to.equal(exampleComponent); + }); + + it('should be unset on BROWSE', () => { + expect(sourceViewer(exampleComponent, requestComponents({}))) + .to.equal(null); + }); + }); + }); +}); |