]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-6848 Merge the "Bulk Deletion" and "Provisioning" pages
authorStas Vilchik <vilchiks@gmail.com>
Thu, 1 Oct 2015 10:06:21 +0000 (12:06 +0200)
committerStas Vilchik <vilchiks@gmail.com>
Fri, 2 Oct 2015 08:06:00 +0000 (10:06 +0200)
23 files changed:
server/sonar-web/Gruntfile.coffee
server/sonar-web/src/main/js/api/components.js [new file with mode: 0644]
server/sonar-web/src/main/js/api/nav.js [new file with mode: 0644]
server/sonar-web/src/main/js/api/users.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/nav/settings/settings-nav.jsx
server/sonar-web/src/main/js/apps/projects/app.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/constants.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/create-view.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/delete-view.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/form-view.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/header.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/main.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/projects.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/search.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/templates/projects-create-form.hbs [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/templates/projects-delete.hbs [new file with mode: 0644]
server/sonar-web/src/main/js/components/shared/checkbox.jsx
server/sonar-web/src/main/less/components/page.less
server/sonar-web/src/main/less/init/tables.less
server/sonar-web/src/main/webapp/WEB-INF/app/controllers/projects_controller.rb [new file with mode: 0644]
server/sonar-web/src/main/webapp/WEB-INF/app/views/projects/index.html.erb [new file with mode: 0644]
server/sonar-web/tests/apps/projects-test.js [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index e11220c026cb762a98a9bf098931828c3cef643c..e87032768afa82bcfb63c21dc9261f5f7d505df3 100644 (file)
@@ -156,6 +156,7 @@ module.exports = (grunt) ->
           'build-app:metrics'
           'build-app:nav'
           'build-app:permission-templates'
+          'build-app:projects'
           'build-app:project-permissions'
           'build-app:provisioning'
           'build-app:quality-gates'
@@ -258,6 +259,9 @@ module.exports = (grunt) ->
           '<%= BUILD_PATH %>/js/apps/permission-templates/templates.js': [
             '<%= SOURCE_PATH %>/js/apps/permission-templates/templates/**/*.hbs'
           ]
+          '<%= BUILD_PATH %>/js/apps/projects/templates.js': [
+            '<%= SOURCE_PATH %>/js/apps/projects/templates/**/*.hbs'
+          ]
 
 
     clean:
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js
new file mode 100644 (file)
index 0000000..8fe69f9
--- /dev/null
@@ -0,0 +1,27 @@
+import $ from 'jquery';
+
+export function getComponents (data) {
+  let url = baseUrl + '/api/components/search';
+  return $.get(url, data);
+}
+
+export function getProvisioned (data) {
+  let url = baseUrl + '/api/projects/provisioned';
+  return $.get(url, data);
+}
+
+export function getGhosts (data) {
+  let url = baseUrl + '/api/projects/ghosts';
+  return $.get(url, data);
+}
+
+export function deleteComponents (data) {
+  let url = baseUrl + '/api/projects/bulk_delete';
+  return $.post(url, data);
+}
+
+export function createProject (options) {
+  options.url = baseUrl + '/api/projects/create';
+  options.type = 'POST';
+  return $.ajax(options);
+}
diff --git a/server/sonar-web/src/main/js/api/nav.js b/server/sonar-web/src/main/js/api/nav.js
new file mode 100644 (file)
index 0000000..86cc425
--- /dev/null
@@ -0,0 +1,6 @@
+import $ from 'jquery';
+
+export function getGlobalNavigation () {
+  let url = baseUrl + '/api/navigation/global';
+  return $.get(url);
+}
diff --git a/server/sonar-web/src/main/js/api/users.js b/server/sonar-web/src/main/js/api/users.js
new file mode 100644 (file)
index 0000000..90910dd
--- /dev/null
@@ -0,0 +1,6 @@
+import $ from 'jquery';
+
+export function getCurrentUser () {
+  let url = baseUrl + '/api/users/current';
+  return $.get(url);
+}
index 8d483131f989e0c7bb86d1dd16f8a2fd8999e3e3..66923b96cf785ce425c82648e194e94781c63311 100644 (file)
@@ -55,10 +55,13 @@ export default React.createClass({
                 {window.t('sidebar.projects')}&nbsp;<i className="icon-dropdown"></i>
               </a>
               <ul className="dropdown-menu">
+                {this.renderNewLink('/projects', 'Management')}
+                {this.renderNewLink('/background_tasks', 'Background Tasks')}
+                <li className="divider"/>
                 {this.state.showProvisioning ? this.renderLink('/provisioning', window.t('provisioning.page')) : null}
                 {this.renderLink('/bulk_deletion', window.t('bulk_deletion.page'))}
                 {this.renderLink('/computation', window.t('analysis_reports.page'))}
-                {this.renderNewLink('/background_tasks', 'Background Tasks')}
+
               </ul>
             </li>
 
diff --git a/server/sonar-web/src/main/js/apps/projects/app.js b/server/sonar-web/src/main/js/apps/projects/app.js
new file mode 100644 (file)
index 0000000..8e98bb1
--- /dev/null
@@ -0,0 +1,21 @@
+import $ from 'jquery';
+import React from 'react';
+import Main from './main';
+import {getCurrentUser} from '../../api/users';
+import {getGlobalNavigation} from '../../api/nav';
+
+export default {
+  start(options) {
+    $.when(
+        getCurrentUser(),
+        getGlobalNavigation(),
+        window.requestMessages()
+    ).then((user, nav) => {
+          let el = document.querySelector(options.el),
+              hasProvisionPermission = user[0].permissions.global.indexOf('provisioning') !== -1,
+              topLevelQualifiers = nav[0].qualifiers;
+          React.render(<Main hasProvisionPermission={hasProvisionPermission}
+                             topLevelQualifiers={topLevelQualifiers}/>, el);
+        });
+  }
+};
diff --git a/server/sonar-web/src/main/js/apps/projects/constants.js b/server/sonar-web/src/main/js/apps/projects/constants.js
new file mode 100644 (file)
index 0000000..70cbf8f
--- /dev/null
@@ -0,0 +1,9 @@
+export const PAGE_SIZE = 30;
+
+export const QUALIFIERS_ORDER = ['TRK', 'VW', 'DEV'];
+
+export const TYPE = {
+  ALL: 'ALL',
+  PROVISIONED: 'PROVISIONED',
+  GHOSTS: 'GHOSTS'
+};
diff --git a/server/sonar-web/src/main/js/apps/projects/create-view.js b/server/sonar-web/src/main/js/apps/projects/create-view.js
new file mode 100644 (file)
index 0000000..6307b70
--- /dev/null
@@ -0,0 +1,46 @@
+import ModalForm from 'components/common/modal-form';
+import {createProject} from '../../api/components';
+import './templates';
+
+export default ModalForm.extend({
+  template: Templates['projects-create-form'],
+
+  onRender: function () {
+    this._super();
+    this.$('[data-toggle="tooltip"]').tooltip({ container: 'body', placement: 'bottom' });
+  },
+
+  onDestroy: function () {
+    this._super();
+    this.$('[data-toggle="tooltip"]').tooltip('destroy');
+  },
+
+  onFormSubmit: function (e) {
+    this._super(e);
+    this.sendRequest();
+  },
+
+  sendRequest: function () {
+    let data = {
+      name: this.$('#create-project-name').val(),
+      branch: this.$('#create-project-branch').val(),
+      key: this.$('#create-project-key').val()
+    };
+    this.disableForm();
+    return createProject({
+      data,
+      statusCode: {
+        // do not show global error
+        400: null
+      }
+    }).done(() => {
+      if (this.options.refresh) {
+        this.options.refresh();
+      }
+      this.destroy();
+    }).fail((jqXHR) => {
+      this.enableForm();
+      this.showErrors([{ msg: jqXHR.responseJSON.err_msg }]);
+    });
+  }
+});
diff --git a/server/sonar-web/src/main/js/apps/projects/delete-view.js b/server/sonar-web/src/main/js/apps/projects/delete-view.js
new file mode 100644 (file)
index 0000000..56d5f15
--- /dev/null
@@ -0,0 +1,14 @@
+import ModalForm from 'components/common/modal-form';
+import './templates';
+
+export default ModalForm.extend({
+  template: Templates['projects-delete'],
+
+  onFormSubmit: function (e) {
+    this._super(e);
+    this.options.deleteProjects();
+    this.destroy();
+  }
+});
+
+
diff --git a/server/sonar-web/src/main/js/apps/projects/form-view.js b/server/sonar-web/src/main/js/apps/projects/form-view.js
new file mode 100644 (file)
index 0000000..c5e12ab
--- /dev/null
@@ -0,0 +1,23 @@
+import ModalForm from 'components/common/modal-form';
+import './templates';
+
+export default ModalForm.extend({
+
+  onRender: function () {
+    this._super();
+    this.$('[data-toggle="tooltip"]').tooltip({ container: 'body', placement: 'bottom' });
+  },
+
+  onDestroy: function () {
+    this._super();
+    this.$('[data-toggle="tooltip"]').tooltip('destroy');
+  },
+
+  onFormSubmit: function (e) {
+    this._super(e);
+    this.sendRequest();
+  }
+
+});
+
+
diff --git a/server/sonar-web/src/main/js/apps/projects/header.js b/server/sonar-web/src/main/js/apps/projects/header.js
new file mode 100644 (file)
index 0000000..b7c71f6
--- /dev/null
@@ -0,0 +1,33 @@
+import React from 'react';
+import CreateView from './create-view';
+
+export default React.createClass({
+  propTypes: {
+    hasProvisionPermission: React.PropTypes.bool.isRequired
+  },
+
+  createProject() {
+    new CreateView({
+      refresh: this.props.refresh
+    }).render();
+  },
+
+  renderCreateButton() {
+    if (!this.props.hasProvisionPermission) {
+      return null;
+    }
+    return <button onClick={this.createProject}>Create Project</button>;
+  },
+
+  render() {
+    return (
+        <header className="page-header">
+          <h1 className="page-title">Projects Management</h1>
+          <div className="page-actions">{this.renderCreateButton()}</div>
+          <p className="page-description">Use this page to delete multiple projects at once, or to provision projects
+            if you would like to configure them before the first analysis. Note that once a project is provisioned, you
+            have access to perform all project configurations on it.</p>
+        </header>
+    );
+  }
+});
diff --git a/server/sonar-web/src/main/js/apps/projects/main.js b/server/sonar-web/src/main/js/apps/projects/main.js
new file mode 100644 (file)
index 0000000..8bb5492
--- /dev/null
@@ -0,0 +1,190 @@
+import _ from 'underscore';
+import React from 'react';
+import Header from './header';
+import Search from './search';
+import Projects from './projects';
+import {PAGE_SIZE, TYPE} from './constants';
+import {getComponents, getProvisioned, getGhosts, deleteComponents} from '../../api/components';
+import ListFooter from '../../components/shared/list-footer';
+
+export default React.createClass({
+  propTypes: {
+    hasProvisionPermission: React.PropTypes.bool.isRequired,
+    topLevelQualifiers: React.PropTypes.array.isRequired
+  },
+
+  getInitialState() {
+    return {
+      projects: [],
+      total: 0,
+      page: 1,
+      query: '',
+      qualifiers: 'TRK',
+      type: TYPE.ALL,
+      selection: []
+    };
+  },
+
+  componentWillMount: function () {
+    this.requestProjects = _.debounce(this.requestProjects, 250);
+  },
+
+  componentDidMount() {
+    this.requestProjects();
+  },
+
+  getFilters() {
+    let filters = { ps: PAGE_SIZE };
+    if (this.state.page !== 1) {
+      filters.p = this.state.page;
+    }
+    if (this.state.query) {
+      filters.q = this.state.query;
+    }
+    return filters;
+  },
+
+  requestProjects() {
+    switch (this.state.type) {
+      case TYPE.ALL:
+        this.requestAllProjects();
+        break;
+      case TYPE.PROVISIONED:
+        this.requestProvisioned();
+        break;
+      case TYPE.GHOSTS:
+        this.requestGhosts();
+        break;
+      default:
+      // should never happen
+    }
+  },
+
+  requestGhosts() {
+    let data = this.getFilters();
+    getGhosts(data).done(r => {
+      let projects = r.projects.map(project => {
+        return _.extend(project, { id: project.uuid, qualifier: 'TRK' });
+      });
+      if (this.state.page > 1) {
+        projects = [].concat(this.state.projects, projects);
+      }
+      this.setState({ projects: projects, total: r.total });
+    });
+  },
+
+  requestProvisioned() {
+    let data = this.getFilters();
+    getProvisioned(data).done(r => {
+      let projects = r.projects.map(project => {
+        return _.extend(project, { id: project.uuid, qualifier: 'TRK' });
+      });
+      if (this.state.page > 1) {
+        projects = [].concat(this.state.projects, projects);
+      }
+      this.setState({ projects: projects, total: r.total });
+    });
+  },
+
+  requestAllProjects() {
+    let data = this.getFilters();
+    data.qualifiers = this.state.qualifiers;
+    getComponents(data).done(r => {
+      let projects = r.components;
+      if (this.state.page > 1) {
+        projects = [].concat(this.state.projects, projects);
+      }
+      this.setState({ projects: projects, total: r.paging.total });
+    });
+  },
+
+  loadMore() {
+    this.setState({ page: this.state.page + 1 }, this.requestProjects);
+  },
+
+  onSearch(query) {
+    this.setState({
+      page: 1,
+      query,
+      selection: []
+    }, this.requestProjects);
+  },
+
+  onTypeChanged(newType) {
+    this.setState({
+      page: 1,
+      query: '',
+      type: newType,
+      qualifiers: 'TRK',
+      selection: []
+    }, this.requestProjects);
+  },
+
+  onQualifierChanged(newQualifier) {
+    this.setState({
+      page: 1,
+      query: '',
+      type: TYPE.ALL,
+      qualifiers: newQualifier,
+      selection: []
+    }, this.requestProjects);
+  },
+
+  onProjectSelected(project) {
+    let newSelection = _.uniq([].concat(this.state.selection, project.id));
+    this.setState({ selection: newSelection });
+  },
+
+  onProjectDeselected(project) {
+    let newSelection = _.without(this.state.selection, project.id);
+    this.setState({ selection: newSelection });
+  },
+
+  onAllSelected() {
+    let newSelection = this.state.projects.map(project => {
+      return project.id;
+    });
+    this.setState({ selection: newSelection });
+  },
+
+  onAllDeselected() {
+    this.setState({ selection: [] });
+  },
+
+  deleteProjects() {
+    let ids = this.state.selection.join(',');
+    deleteComponents({ ids }).done(() => {
+      this.setState({ selection: [] }, this.requestProjects);
+    });
+  },
+
+  render() {
+    return (
+        <div className="page">
+          <Header
+              hasProvisionPermission={this.props.hasProvisionPermission}
+              refresh={this.requestProjects}/>
+
+          <Search {...this.props} {...this.state}
+              onSearch={this.onSearch}
+              onTypeChanged={this.onTypeChanged}
+              onQualifierChanged={this.onQualifierChanged}
+              onAllSelected={this.onAllSelected}
+              onAllDeselected={this.onAllDeselected}
+              deleteProjects={this.deleteProjects}/>
+
+          <Projects
+              projects={this.state.projects}
+              refresh={this.requestProjects}
+              selection={this.state.selection}
+              onProjectSelected={this.onProjectSelected}
+              onProjectDeselected={this.onProjectDeselected}/>
+
+          <ListFooter
+              count={this.state.projects.length}
+              total={this.state.total}
+              loadMore={this.loadMore}/>
+        </div>
+    );
+  }
+});
diff --git a/server/sonar-web/src/main/js/apps/projects/projects.js b/server/sonar-web/src/main/js/apps/projects/projects.js
new file mode 100644 (file)
index 0000000..6aa42f5
--- /dev/null
@@ -0,0 +1,52 @@
+import React from 'react';
+import {getProjectUrl} from '../../helpers/Url';
+import Checkbox from '../../components/shared/checkbox';
+import QualifierIcon from '../../components/shared/qualifier-icon';
+
+export default React.createClass({
+  propTypes: {
+    projects: React.PropTypes.array.isRequired,
+    selection: React.PropTypes.array.isRequired,
+    refresh: React.PropTypes.func.isRequired
+  },
+
+  onProjectCheck(project, checked) {
+    if (checked) {
+      this.props.onProjectSelected(project);
+    } else {
+      this.props.onProjectDeselected(project);
+    }
+  },
+
+  isProjectSelected(project) {
+    return this.props.selection.indexOf(project.id) !== -1;
+  },
+
+  renderProject(project) {
+    return (
+        <tr key={project.id}>
+          <td className="thin">
+            <Checkbox onCheck={this.onProjectCheck.bind(this, project)}
+                      initiallyChecked={this.isProjectSelected(project)}/>
+          </td>
+          <td className="thin">
+            <QualifierIcon qualifier={project.qualifier}/>
+          </td>
+          <td className="nowrap">
+            <a href={getProjectUrl(project.key)}>{project.name}</a>
+          </td>
+          <td className="nowrap">
+            <span className="note">{project.key}</span>
+          </td>
+        </tr>
+    );
+  },
+
+  render() {
+    return (
+        <table className="data zebra">
+          <tbody>{this.props.projects.map(this.renderProject)}</tbody>
+        </table>
+    );
+  }
+});
diff --git a/server/sonar-web/src/main/js/apps/projects/search.js b/server/sonar-web/src/main/js/apps/projects/search.js
new file mode 100644 (file)
index 0000000..5c5668a
--- /dev/null
@@ -0,0 +1,118 @@
+import _ from 'underscore';
+import React from 'react';
+import {TYPE, QUALIFIERS_ORDER} from './constants';
+import DeleteView from './delete-view';
+import RadioToggle from '../../components/shared/radio-toggle';
+import Checkbox from '../../components/shared/checkbox';
+
+export default React.createClass({
+  propTypes: {
+    onSearch: React.PropTypes.func.isRequired
+  },
+
+  onSubmit(e) {
+    e.preventDefault();
+    this.search();
+  },
+
+  search() {
+    let q = React.findDOMNode(this.refs.input).value;
+    this.props.onSearch(q);
+  },
+
+  getTypeOptions() {
+    return [
+      { value: TYPE.ALL, label: 'All' },
+      { value: TYPE.PROVISIONED, label: 'Provisioned' },
+      { value: TYPE.GHOSTS, label: 'Ghosts' }
+    ];
+  },
+
+  getQualifierOptions() {
+    let options = this.props.topLevelQualifiers.map(q => {
+      return { value: q, label: window.t('qualifiers', q) };
+    });
+    return _.sortBy(options, option => {
+      return QUALIFIERS_ORDER.indexOf(option.value);
+    });
+  },
+
+  renderCheckbox() {
+    let isAllChecked = this.props.projects.length > 0 &&
+            this.props.selection.length === this.props.projects.length,
+        thirdState = this.props.projects.length > 0 &&
+            this.props.selection.length > 0 &&
+            this.props.selection.length < this.props.projects.length,
+        isChecked = isAllChecked || thirdState;
+    return <Checkbox onCheck={this.onCheck} initiallyChecked={isChecked} thirdState={thirdState}/>;
+  },
+
+  onCheck(checked) {
+    if (checked) {
+      this.props.onAllSelected();
+    } else {
+      this.props.onAllDeselected();
+    }
+  },
+
+  renderGhostsDescription () {
+    if (this.props.type !== TYPE.GHOSTS) {
+      return null;
+    }
+    return <div className="spacer-top alert alert-info">{window.t('bulk_deletion.ghosts.description')}</div>;
+  },
+
+  deleteProjects() {
+    new DeleteView({
+      deleteProjects: this.props.deleteProjects
+    }).render();
+  },
+
+  renderQualifierFilter() {
+    let options = this.getQualifierOptions();
+    if (options.length < 2) {
+      return null;
+    }
+    return (
+        <td className="thin nowrap text-middle">
+          <RadioToggle options={this.getQualifierOptions()} value={this.props.qualifiers}
+                       name="projects-qualifier" onCheck={this.props.onQualifierChanged}/>
+        </td>
+    );
+  },
+
+  render() {
+    let isSomethingSelected = this.props.projects.length > 0 && this.props.selection.length > 0;
+    return (
+        <div className="panel panel-vertical bordered-bottom spacer-bottom">
+          <table className="data">
+            <tr>
+              <td className="thin text-middle">
+                {this.renderCheckbox()}
+              </td>
+              {this.renderQualifierFilter()}
+              <td className="thin nowrap text-middle">
+                <RadioToggle options={this.getTypeOptions()} value={this.props.type}
+                             name="projects-type" onCheck={this.props.onTypeChanged}/>
+              </td>
+              <td className="text-middle">
+                <form onSubmit={this.onSubmit} className="search-box">
+                  <button className="search-box-submit button-clean">
+                    <i className="icon-search"></i>
+                  </button>
+                  <input onChange={this.search} value={this.props.query} ref="input" className="search-box-input"
+                         type="search" placeholder="Search"/>
+                </form>
+              </td>
+              <td className="thin text-middle">
+                <button onClick={this.deleteProjects} className="button-red"
+                        disabled={!isSomethingSelected}>Delete
+                </button>
+              </td>
+            </tr>
+          </table>
+          {this.renderGhostsDescription()}
+        </div>
+    );
+  }
+});
diff --git a/server/sonar-web/src/main/js/apps/projects/templates/projects-create-form.hbs b/server/sonar-web/src/main/js/apps/projects/templates/projects-create-form.hbs
new file mode 100644 (file)
index 0000000..e931b66
--- /dev/null
@@ -0,0 +1,30 @@
+<form id="create-project-form" autocomplete="off">
+  <div class="modal-head">
+    <h2>Create Project</h2>
+  </div>
+  <div class="modal-body">
+    <div class="js-modal-messages"></div>
+    <div class="modal-field">
+      <label for="create-project-name">Name<em class="mandatory">*</em></label>
+      {{! keep this fake field to hack browser autofill }}
+      <input id="create-project-name-fake" name="name-fake" type="text" class="hidden">
+      <input id="create-project-name" name="name" type="text" size="50" maxlength="200" required>
+    </div>
+    <div class="modal-field">
+      <label for="create-project-branch">Branch</label>
+      {{! keep this fake field to hack browser autofill }}
+      <input id="create-project-branch-fake" name="branch-fake" type="text" class="hidden">
+      <input id="create-project-branch" name="branch" type="text" size="50" maxlength="200">
+    </div>
+    <div class="modal-field">
+      <label for="create-project-key">Key<em class="mandatory">*</em></label>
+      {{! keep this fake field to hack browser autofill }}
+      <input id="create-project-key-fake" name="key-fake" type="text" class="hidden">
+      <input id="create-project-key" name="key" type="text" size="50" maxlength="50" required>
+    </div>
+  </div>
+  <div class="modal-foot">
+    <button id="create-project-submit">Create</button>
+    <a href="#" class="js-modal-close" id="create-project-cancel">Cancel</a>
+  </div>
+</form>
diff --git a/server/sonar-web/src/main/js/apps/projects/templates/projects-delete.hbs b/server/sonar-web/src/main/js/apps/projects/templates/projects-delete.hbs
new file mode 100644 (file)
index 0000000..2ab69b2
--- /dev/null
@@ -0,0 +1,13 @@
+<form id="delete-project-form">
+  <div class="modal-head">
+    <h2>Delete Projects</h2>
+  </div>
+  <div class="modal-body">
+    <div class="js-modal-messages"></div>
+    Are you sure you want to delete selected projects?
+  </div>
+  <div class="modal-foot">
+    <button id="delete-project-submit" class="button-red">Delete</button>
+    <a href="#" class="js-modal-close" id="delete-project-cancel">Cancel</a>
+  </div>
+</form>
index 8504da5038d87f069edff1109fa248d3f745f0ba..633c23c8354fbf5eb9210376882a4e31a97dc9c3 100644 (file)
@@ -3,7 +3,8 @@ import React from 'react';
 export default React.createClass({
   propTypes: {
     onCheck: React.PropTypes.func.isRequired,
-    initiallyChecked: React.PropTypes.bool
+    initiallyChecked: React.PropTypes.bool,
+    thirdState:  React.PropTypes.bool
   },
 
   getInitialState() {
@@ -23,7 +24,14 @@ export default React.createClass({
   },
 
   render() {
-    const className = this.state.checked ? 'icon-checkbox icon-checkbox-checked' : 'icon-checkbox';
+    let classNames = ['icon-checkbox'];
+    if (this.state.checked) {
+      classNames.push('icon-checkbox-checked');
+    }
+    if (this.props.thirdState) {
+      classNames.push('icon-checkbox-single');
+    }
+    let className = classNames.join(' ');
     return <a onClick={this.toggle} className={className} href="#"/>;
   }
 });
index 46fbc5d3ec31c3a14481540968321ecc0f4e5ed8..fd6f2f7f7568c9e8e473eaf9fc970745e8959a85 100644 (file)
@@ -48,6 +48,8 @@
 
 .page-actions {
   float: right;
+  margin-bottom: 10px;
+  margin-left: 10px;
 
   .badge {
     margin: 3px 0;
@@ -55,7 +57,6 @@
 }
 
 .page-description {
-  float: left;
   clear: left;
   font-size: @smallFontSize;
   line-height: 1.5;
index cea19f373b89b1b1e5e7199594c58e2fe7901a24..4f63315ce920684b7f35476af1c911502b1d3a9c 100644 (file)
@@ -37,6 +37,10 @@ table.data > tbody > tr > td {
   padding: 5px 5px;
   vertical-align: text-top;
   line-height: 16px;
+
+  &.text-middle {
+    vertical-align: middle;
+  }
 }
 
 table.data td.small, table.data th.small {
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/projects_controller.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/projects_controller.rb
new file mode 100644 (file)
index 0000000..6661201
--- /dev/null
@@ -0,0 +1,30 @@
+#
+# 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 ProjectsController < ApplicationController
+
+  before_filter :admin_required
+
+  SECTION=Navigation::SECTION_CONFIGURATION
+
+  def index
+    
+  end
+
+end
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/views/projects/index.html.erb b/server/sonar-web/src/main/webapp/WEB-INF/app/views/projects/index.html.erb
new file mode 100644 (file)
index 0000000..0ccfdf3
--- /dev/null
@@ -0,0 +1,7 @@
+<% content_for :extra_script do %>
+  <script>
+    require(['apps/projects/app'], function (App) {
+      App.start({ el: '#content' });
+    });
+  </script>
+<% end %>
diff --git a/server/sonar-web/tests/apps/projects-test.js b/server/sonar-web/tests/apps/projects-test.js
new file mode 100644 (file)
index 0000000..0bb891f
--- /dev/null
@@ -0,0 +1,35 @@
+import React from 'react/addons';
+import Projects from '../../src/main/js/apps/projects/projects';
+
+let TestUtils = React.addons.TestUtils;
+let expect = require('chai').expect;
+let sinon = require('sinon');
+
+describe('Projects', function () {
+  describe('Projects', () => {
+    it('should render list of projects with no selection', () => {
+      let projects = [
+        { id: '1', key: 'a', name: 'A', qualifier: 'TRK' },
+        { id: '2', key: 'b', name: 'B', qualifier: 'TRK' }
+      ];
+
+      let result = TestUtils.renderIntoDocument(
+          <Projects projects={projects} selection={[]} refresh={sinon.spy()}/>);
+      expect(TestUtils.scryRenderedDOMComponentsWithTag(result, 'tr')).to.have.length(2);
+      expect(TestUtils.scryRenderedDOMComponentsWithClass(result, 'icon-checkbox-checked')).to.be.empty;
+    });
+
+    it('should render list of projects with one selected', () => {
+      let projects = [
+            { id: '1', key: 'a', name: 'A', qualifier: 'TRK' },
+            { id: '2', key: 'b', name: 'B', qualifier: 'TRK' }
+          ],
+          selection = ['1'];
+
+      let result = TestUtils.renderIntoDocument(
+          <Projects projects={projects} selection={selection} refresh={sinon.spy()}/>);
+      expect(TestUtils.scryRenderedDOMComponentsWithTag(result, 'tr')).to.have.length(2);
+      expect(TestUtils.scryRenderedDOMComponentsWithClass(result, 'icon-checkbox-checked')).to.have.length(1);
+    });
+  });
+});
index 82e2eabf235fd9edc9915575e7d90ab25b814743..cfc4626d9c95e76d70f2968df2706d45b3da6a92 100644 (file)
@@ -2182,7 +2182,7 @@ bulk_deletion.deletion_manager.deletion_completed=Component deletion completed.
 bulk_deletion.deletion_manager.however_failures_occurred=However, some failures occurred.
 bulk_deletion.started_since_x=Started {0} ago
 bulk_deletion.ghosts=Ghosts
-bulk_deletion.ghosts.description=A ghost is the result of constantly failed attempts to analyse a project. In such a case, the project is not linked to any successful analysis, and therefore cannot be displayed in SonarQube.<br/>When the user authentication is forced, leaving a ghost can even prevent further analyses of the corresponding project.
+bulk_deletion.ghosts.description=A ghost is the result of constantly failed attempts to analyse a project. In such a case, the project is not linked to any successful analysis, and therefore cannot be displayed in SonarQube. When the user authentication is forced, leaving a ghost can even prevent further analyses of the corresponding project.
 bulk_deletion.no_ghosts=There is currently no ghost.
 bulk_deletion.following_ghosts_can_be_deleted=The following ghosts can be safely deleted:
 bulk_deletion.delete_all_ghosts=Delete all ghosts