'build-app:metrics'
'build-app:nav'
'build-app:permission-templates'
+ 'build-app:projects'
'build-app:project-permissions'
'build-app:provisioning'
'build-app:quality-gates'
'<%= 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:
--- /dev/null
+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);
+}
--- /dev/null
+import $ from 'jquery';
+
+export function getGlobalNavigation () {
+ let url = baseUrl + '/api/navigation/global';
+ return $.get(url);
+}
--- /dev/null
+import $ from 'jquery';
+
+export function getCurrentUser () {
+ let url = baseUrl + '/api/users/current';
+ return $.get(url);
+}
{window.t('sidebar.projects')} <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>
--- /dev/null
+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);
+ });
+ }
+};
--- /dev/null
+export const PAGE_SIZE = 30;
+
+export const QUALIFIERS_ORDER = ['TRK', 'VW', 'DEV'];
+
+export const TYPE = {
+ ALL: 'ALL',
+ PROVISIONED: 'PROVISIONED',
+ GHOSTS: 'GHOSTS'
+};
--- /dev/null
+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 }]);
+ });
+ }
+});
--- /dev/null
+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();
+ }
+});
+
+
--- /dev/null
+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();
+ }
+
+});
+
+
--- /dev/null
+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>
+ );
+ }
+});
--- /dev/null
+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>
+ );
+ }
+});
--- /dev/null
+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>
+ );
+ }
+});
--- /dev/null
+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>
+ );
+ }
+});
--- /dev/null
+<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>
--- /dev/null
+<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>
export default React.createClass({
propTypes: {
onCheck: React.PropTypes.func.isRequired,
- initiallyChecked: React.PropTypes.bool
+ initiallyChecked: React.PropTypes.bool,
+ thirdState: React.PropTypes.bool
},
getInitialState() {
},
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="#"/>;
}
});
.page-actions {
float: right;
+ margin-bottom: 10px;
+ margin-left: 10px;
.badge {
margin: 3px 0;
}
.page-description {
- float: left;
clear: left;
font-size: @smallFontSize;
line-height: 1.5;
padding: 5px 5px;
vertical-align: text-top;
line-height: 16px;
+
+ &.text-middle {
+ vertical-align: middle;
+ }
}
table.data td.small, table.data th.small {
--- /dev/null
+#
+# 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
--- /dev/null
+<% content_for :extra_script do %>
+ <script>
+ require(['apps/projects/app'], function (App) {
+ App.start({ el: '#content' });
+ });
+ </script>
+<% end %>
--- /dev/null
+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);
+ });
+ });
+});
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