summaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorStas Vilchik <vilchiks@gmail.com>2015-12-14 16:16:44 +0100
committerStas Vilchik <vilchiks@gmail.com>2015-12-16 11:17:32 +0100
commit5af187a6a0bda4f14de5d8ba5c6088c3a00d3e4e (patch)
tree24c4b4272bd73b225d73db23523f3441a486f2bc /server/sonar-web
parent5995432e51e72623804ffe926316b84d18913657 (diff)
downloadsonarqube-5af187a6a0bda4f14de5d8ba5c6088c3a00d3e4e.tar.gz
sonarqube-5af187a6a0bda4f14de5d8ba5c6088c3a00d3e4e.zip
SONAR-7143 display list of components on the code page
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/.babelrc2
-rw-r--r--server/sonar-web/package.json6
-rw-r--r--server/sonar-web/src/main/js/api/components.js6
-rw-r--r--server/sonar-web/src/main/js/apps/code/actions/index.js70
-rw-r--r--server/sonar-web/src/main/js/apps/code/app.js18
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Breadcrumb.js13
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Breadcrumbs.js19
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Code.js84
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Component.js64
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/ComponentDetach.js14
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.js17
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/ComponentName.js35
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Components.js42
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.js16
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/SourceViewer.js32
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Truncated.js14
-rw-r--r--server/sonar-web/src/main/js/apps/code/reducers/index.js82
-rw-r--r--server/sonar-web/src/main/js/apps/code/store/configureStore.js16
-rw-r--r--server/sonar-web/src/main/js/components/mixins/tooltips-mixin.js47
-rw-r--r--server/sonar-web/src/main/js/main/nav/component/component-nav-menu.js15
-rw-r--r--server/sonar-web/src/main/less/pages.less1
-rw-r--r--server/sonar-web/src/main/less/pages/code.less40
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/controllers/api/resources_controller.rb1
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/controllers/code_controller.rb29
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/views/code/index.html.erb3
-rw-r--r--server/sonar-web/tests/apps/code/components-test.js284
-rw-r--r--server/sonar-web/tests/apps/code/store-test.js98
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">&nbsp;</th>
+ <th>&nbsp;</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">&nbsp;</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">
+ &nbsp;
+ </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);
+ });
+ });
+ });
+});