]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7143 display list of components on the code page
authorStas Vilchik <vilchiks@gmail.com>
Mon, 14 Dec 2015 15:16:44 +0000 (16:16 +0100)
committerStas Vilchik <vilchiks@gmail.com>
Wed, 16 Dec 2015 10:17:32 +0000 (11:17 +0100)
28 files changed:
server/sonar-web/.babelrc
server/sonar-web/package.json
server/sonar-web/src/main/js/api/components.js
server/sonar-web/src/main/js/apps/code/actions/index.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/app.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/Breadcrumb.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/Breadcrumbs.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/Code.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/Component.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/ComponentDetach.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/ComponentName.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/Components.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/SourceViewer.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/components/Truncated.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/reducers/index.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/code/store/configureStore.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/mixins/tooltips-mixin.js
server/sonar-web/src/main/js/main/nav/component/component-nav-menu.js
server/sonar-web/src/main/less/pages.less
server/sonar-web/src/main/less/pages/code.less [new file with mode: 0644]
server/sonar-web/src/main/webapp/WEB-INF/app/controllers/api/resources_controller.rb
server/sonar-web/src/main/webapp/WEB-INF/app/controllers/code_controller.rb [new file with mode: 0644]
server/sonar-web/src/main/webapp/WEB-INF/app/views/code/index.html.erb [new file with mode: 0644]
server/sonar-web/tests/apps/code/components-test.js [new file with mode: 0644]
server/sonar-web/tests/apps/code/store-test.js [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index dbaabd522d523ce2b7e5affe87c1e1cd5d6fed17..8c43a477c182e72caad20a46da48fef84c517f7a 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "presets": ["es2015", "react"],
+  "presets": ["es2015", "stage-0", "react"],
    "ignore": [
       "**/libs/**"
     ]
index b1db429ef8fdb92b8f2805b1ad40260faed13009..53e6738360ba2214354a28578e0e7d412e5ad321 100644 (file)
@@ -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",
     "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",
index 7b319dd0fe7838bf28ab676e2242c3cadb143dd4..24f8c446a6f8d43f54d3ac106da05da782ce1169 100644 (file)
@@ -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 (file)
index 0000000..9e9df67
--- /dev/null
@@ -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 (file)
index 0000000..c16b4aa
--- /dev/null
@@ -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 (file)
index 0000000..2e8f4f4
--- /dev/null
@@ -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 (file)
index 0000000..95a54ff
--- /dev/null
@@ -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 (file)
index 0000000..d894ab8
--- /dev/null
@@ -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 (file)
index 0000000..e319d9e
--- /dev/null
@@ -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 (file)
index 0000000..5459c7a
--- /dev/null
@@ -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 (file)
index 0000000..112ee07
--- /dev/null
@@ -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 (file)
index 0000000..40031d8
--- /dev/null
@@ -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 (file)
index 0000000..1167e31
--- /dev/null
@@ -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 (file)
index 0000000..81f702f
--- /dev/null
@@ -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 (file)
index 0000000..26fcc60
--- /dev/null
@@ -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 (file)
index 0000000..cc58a7b
--- /dev/null
@@ -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 (file)
index 0000000..d75ca95
--- /dev/null
@@ -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 (file)
index 0000000..20d1587
--- /dev/null
@@ -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);
+}
index 0b48fb83fcd6f85d2b19ff8d7ad55bc60305e941..c2328d6e7ae1251adbe9e5554b4ef3f359fc490b 100644 (file)
@@ -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;
+  }
+});
index b55e44cc2b398cd1edd733ae98ace6714d8ed9c1..1c097cb21e8b364d4ba0697540bcbd3131d8330f 100644 (file)
@@ -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()}
index 05892910ed5543dc33f2345f0d0f937b3015870f..2280226ec49d50b05695b7e23ca8d061097fa22d 100644 (file)
@@ -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 (file)
index 0000000..ec86785
--- /dev/null
@@ -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;
+}
index 35918a606abc5360d3f80fdb3f24e9086484cf46..eab0fdb9e0c5b9fc92f9389e35dc6b75b6c27b79 100644 (file)
@@ -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 (file)
index 0000000..6eac33c
--- /dev/null
@@ -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 (file)
index 0000000..f5600f6
--- /dev/null
@@ -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 (file)
index 0000000..92ed342
--- /dev/null
@@ -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 (file)
index 0000000..039e199
--- /dev/null
@@ -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);
+      });
+    });
+  });
+});
index e249705826b9070394df765eb472c452cef387c3..c907c83357415eac061ba46254117d4081db5548 100644 (file)
@@ -521,6 +521,7 @@ provisioning.page.description=Use this page to initialize projects if you would
 
 clouds.page=Clouds
 overview.page=Overview
+code.page=Code
 components.page=Components
 coverage.page=Coverage
 default_dashboards.page=Default Dashboards