aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStas Vilchik <vilchiks@gmail.com>2015-06-02 13:53:17 +0200
committerStas Vilchik <vilchiks@gmail.com>2015-06-02 15:08:42 +0200
commitc04875f418197895a512cc77026327c3da99d1d2 (patch)
treedbc81566b6da483d907ba75b97e9b41b15b29570
parentc0f8ed1eca7185bcb63454ba8757a0290721e1dc (diff)
downloadsonarqube-c04875f418197895a512cc77026327c3da99d1d2.tar.gz
sonarqube-c04875f418197895a512cc77026327c3da99d1d2.zip
SONAR-6602 refactor groups page
-rw-r--r--server/sonar-web/Gruntfile.coffee7
-rw-r--r--server/sonar-web/src/main/js/apps/groups/app.js47
-rw-r--r--server/sonar-web/src/main/js/apps/groups/create-view.js30
-rw-r--r--server/sonar-web/src/main/js/apps/groups/delete-view.js32
-rw-r--r--server/sonar-web/src/main/js/apps/groups/form-view.js25
-rw-r--r--server/sonar-web/src/main/js/apps/groups/group.js35
-rw-r--r--server/sonar-web/src/main/js/apps/groups/groups.js40
-rw-r--r--server/sonar-web/src/main/js/apps/groups/header-view.js25
-rw-r--r--server/sonar-web/src/main/js/apps/groups/layout.js16
-rw-r--r--server/sonar-web/src/main/js/apps/groups/list-footer-view.js34
-rw-r--r--server/sonar-web/src/main/js/apps/groups/list-item-view.js59
-rw-r--r--server/sonar-web/src/main/js/apps/groups/list-view.js11
-rw-r--r--server/sonar-web/src/main/js/apps/groups/search-view.js49
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-delete.hbs13
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-form.hbs24
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-header.hbs9
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-layout.hbs6
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-list-footer.hbs6
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-list-item.hbs16
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs6
-rw-r--r--server/sonar-web/src/main/js/apps/groups/templates/groups-users.hbs10
-rw-r--r--server/sonar-web/src/main/js/apps/groups/update-view.js29
-rw-r--r--server/sonar-web/src/main/js/apps/groups/users-view.js36
-rw-r--r--server/sonar-web/src/main/js/apps/users/groups-view.js13
-rw-r--r--server/sonar-web/src/main/less/components/panels.less2
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/controllers/groups_controller.rb7
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/views/groups/index.html.erb71
-rw-r--r--server/sonar-web/src/test/js/groups-spec.js355
-rw-r--r--server/sonar-web/src/test/json/groups-spec/error.json7
-rw-r--r--server/sonar-web/src/test/json/groups-spec/search-big-1.json13
-rw-r--r--server/sonar-web/src/test/json/groups-spec/search-big-2.json13
-rw-r--r--server/sonar-web/src/test/json/groups-spec/search-created.json25
-rw-r--r--server/sonar-web/src/test/json/groups-spec/search-filtered.json13
-rw-r--r--server/sonar-web/src/test/json/groups-spec/search-updated.json19
-rw-r--r--server/sonar-web/src/test/json/groups-spec/search.json19
-rw-r--r--server/sonar-web/src/test/json/groups-spec/users.json17
-rw-r--r--server/sonar-web/src/test/views/groups.jade5
37 files changed, 1066 insertions, 78 deletions
diff --git a/server/sonar-web/Gruntfile.coffee b/server/sonar-web/Gruntfile.coffee
index ff402d13cb0..fc55da5fceb 100644
--- a/server/sonar-web/Gruntfile.coffee
+++ b/server/sonar-web/Gruntfile.coffee
@@ -123,6 +123,7 @@ module.exports = (grunt) ->
'build-app:coding-rules'
'build-app:computation'
'build-app:drilldown'
+ 'build-app:groups'
'build-app:markdown'
'build-app:measures'
'build-app:nav'
@@ -156,6 +157,7 @@ module.exports = (grunt) ->
'casper:ui'
'casper:workspace'
'casper:users'
+ 'casper:groups'
'casper:provisioning'
'casper:computation'
]
@@ -215,6 +217,9 @@ module.exports = (grunt) ->
'<%= BUILD_PATH %>/js/apps/users/templates.js': [
'<%= SOURCE_PATH %>/js/apps/users/templates/**/*.hbs'
]
+ '<%= BUILD_PATH %>/js/apps/groups/templates.js': [
+ '<%= SOURCE_PATH %>/js/apps/groups/templates/**/*.hbs'
+ ]
'<%= BUILD_PATH %>/js/apps/provisioning/templates.js': [
'<%= SOURCE_PATH %>/js/apps/provisioning/templates/**/*.hbs'
]
@@ -319,6 +324,8 @@ module.exports = (grunt) ->
src: ['src/test/js/provisioning*.js']
computation:
src: ['src/test/js/computation*.js']
+ groups:
+ src: ['src/test/js/groups-spec.js']
uglify:
build:
diff --git a/server/sonar-web/src/main/js/apps/groups/app.js b/server/sonar-web/src/main/js/apps/groups/app.js
new file mode 100644
index 00000000000..55c6dfef534
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/app.js
@@ -0,0 +1,47 @@
+define([
+ './layout',
+ './groups',
+ './header-view',
+ './search-view',
+ './list-view',
+ './list-footer-view'
+], function (Layout, Groups, HeaderView, SearchView, ListView, ListFooterView) {
+
+ var App = new Marionette.Application(),
+ init = function (options) {
+ // Layout
+ this.layout = new Layout({ el: options.el });
+ this.layout.render();
+
+ // Collection
+ this.groups = new Groups();
+
+ // Header View
+ this.headerView = new HeaderView({ collection: this.groups });
+ this.layout.headerRegion.show(this.headerView);
+
+ // Search View
+ this.searchView = new SearchView({ collection: this.groups });
+ this.layout.searchRegion.show(this.searchView);
+
+ // List View
+ this.listView = new ListView({ collection: this.groups });
+ this.layout.listRegion.show(this.listView);
+
+ // List Footer View
+ this.listFooterView = new ListFooterView({ collection: this.groups });
+ this.layout.listFooterRegion.show(this.listFooterView);
+
+ // Go!
+ this.groups.fetch();
+ };
+
+ App.on('start', function (options) {
+ window.requestMessages().done(function () {
+ init.call(App, options);
+ });
+ });
+
+ return App;
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/create-view.js b/server/sonar-web/src/main/js/apps/groups/create-view.js
new file mode 100644
index 00000000000..8d5cfce55aa
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/create-view.js
@@ -0,0 +1,30 @@
+define([
+ './group',
+ './form-view'
+], function (Group, FormView) {
+
+ return FormView.extend({
+
+ sendRequest: function () {
+ var that = this,
+ group = new Group({
+ name: this.$('#create-group-name').val(),
+ description: this.$('#create-group-description').val()
+ });
+ this.disableForm();
+ return group.save(null, {
+ statusCode: {
+ // do not show global error
+ 400: null
+ }
+ }).done(function () {
+ that.collection.refresh();
+ that.close();
+ }).fail(function (jqXHR) {
+ that.enableForm();
+ that.showErrors(jqXHR.responseJSON.errors, jqXHR.responseJSON.warnings);
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/delete-view.js b/server/sonar-web/src/main/js/apps/groups/delete-view.js
new file mode 100644
index 00000000000..8fd83d34031
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/delete-view.js
@@ -0,0 +1,32 @@
+define([
+ 'components/common/modal-form',
+ './templates'
+], function (ModalForm) {
+
+ return ModalForm.extend({
+ template: Templates['groups-delete'],
+
+ onFormSubmit: function () {
+ ModalForm.prototype.onFormSubmit.apply(this, arguments);
+ this.sendRequest();
+ },
+
+ sendRequest: function () {
+ var that = this,
+ collection = this.model.collection;
+ return this.model.destroy({
+ wait: true,
+ statusCode: {
+ // do not show global error
+ 400: null
+ }
+ }).done(function () {
+ collection.total--;
+ that.close();
+ }).fail(function (jqXHR) {
+ that.showErrors(jqXHR.responseJSON.errors, jqXHR.responseJSON.warnings);
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/form-view.js b/server/sonar-web/src/main/js/apps/groups/form-view.js
new file mode 100644
index 00000000000..aa872784e1e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/form-view.js
@@ -0,0 +1,25 @@
+define([
+ 'components/common/modal-form',
+ './templates'
+], function (ModalForm) {
+
+ return ModalForm.extend({
+ template: Templates['groups-form'],
+
+ onRender: function () {
+ ModalForm.prototype.onRender.apply(this, arguments);
+ this.$('[data-toggle="tooltip"]').tooltip({ container: 'body', placement: 'bottom' });
+ },
+
+ onClose: function () {
+ ModalForm.prototype.onClose.apply(this, arguments);
+ this.$('[data-toggle="tooltip"]').tooltip('destroy');
+ },
+
+ onFormSubmit: function () {
+ ModalForm.prototype.onFormSubmit.apply(this, arguments);
+ this.sendRequest();
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/group.js b/server/sonar-web/src/main/js/apps/groups/group.js
new file mode 100644
index 00000000000..406f9ba3a3a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/group.js
@@ -0,0 +1,35 @@
+define(function () {
+
+ return Backbone.Model.extend({
+ urlRoot: function () {
+ return baseUrl + '/api/usergroups';
+ },
+
+ sync: function (method, model, options) {
+ var opts = options || {};
+ if (method === 'create') {
+ _.defaults(opts, {
+ url: this.urlRoot() + '/create',
+ type: 'POST',
+ data: _.pick(model.toJSON(), 'name', 'description')
+ });
+ }
+ if (method === 'update') {
+ _.defaults(opts, {
+ url: this.urlRoot() + '/update',
+ type: 'POST',
+ data: _.pick(model.toJSON(), 'id', 'name', 'description')
+ });
+ }
+ if (method === 'delete') {
+ _.defaults(opts, {
+ url: this.urlRoot() + '/delete',
+ type: 'POST',
+ data: { id: this.id }
+ });
+ }
+ return Backbone.ajax(opts);
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/groups.js b/server/sonar-web/src/main/js/apps/groups/groups.js
new file mode 100644
index 00000000000..9ddd6c9f9a1
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/groups.js
@@ -0,0 +1,40 @@
+define([
+ './group'
+], function (Group) {
+
+ return Backbone.Collection.extend({
+ model: Group,
+
+ url: function () {
+ return baseUrl + '/api/usergroups/search';
+ },
+
+ parse: function (r) {
+ this.total = +r.total;
+ this.p = +r.p;
+ this.ps = +r.ps;
+ return r.groups;
+ },
+
+ fetch: function (options) {
+ var d = (options && options.data) || {};
+ this.q = d.q;
+ return Backbone.Collection.prototype.fetch.apply(this, arguments);
+ },
+
+ fetchMore: function () {
+ var p = this.p + 1;
+ return this.fetch({ add: true, remove: false, data: { p: p, ps: this.ps, q: this.q } });
+ },
+
+ refresh: function () {
+ return this.fetch({ reset: true, data: { q: this.q } });
+ },
+
+ hasMore: function () {
+ return this.total > this.p * this.ps;
+ }
+
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/header-view.js b/server/sonar-web/src/main/js/apps/groups/header-view.js
new file mode 100644
index 00000000000..da6f7f60919
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/header-view.js
@@ -0,0 +1,25 @@
+define([
+ './create-view',
+ './templates'
+], function (CreateView) {
+
+ return Marionette.ItemView.extend({
+ template: Templates['groups-header'],
+
+ events: {
+ 'click #groups-create': 'onCreateClick'
+ },
+
+ onCreateClick: function (e) {
+ e.preventDefault();
+ this.createGroup();
+ },
+
+ createGroup: function () {
+ new CreateView({
+ collection: this.collection
+ }).render();
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/layout.js b/server/sonar-web/src/main/js/apps/groups/layout.js
new file mode 100644
index 00000000000..a60fb06f35f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/layout.js
@@ -0,0 +1,16 @@
+define([
+ './templates'
+], function () {
+
+ return Marionette.Layout.extend({
+ template: Templates['groups-layout'],
+
+ regions: {
+ headerRegion: '#groups-header',
+ searchRegion: '#groups-search',
+ listRegion: '#groups-list',
+ listFooterRegion: '#groups-list-footer'
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/list-footer-view.js b/server/sonar-web/src/main/js/apps/groups/list-footer-view.js
new file mode 100644
index 00000000000..cdad034f24a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/list-footer-view.js
@@ -0,0 +1,34 @@
+define([
+ './templates'
+], function () {
+
+ return Marionette.ItemView.extend({
+ template: Templates['groups-list-footer'],
+
+ collectionEvents: {
+ 'all': 'render'
+ },
+
+ events: {
+ 'click #groups-fetch-more': 'onMoreClick'
+ },
+
+ onMoreClick: function (e) {
+ e.preventDefault();
+ this.fetchMore();
+ },
+
+ fetchMore: function () {
+ this.collection.fetchMore();
+ },
+
+ serializeData: function () {
+ return _.extend(Marionette.ItemView.prototype.serializeData.apply(this, arguments), {
+ total: this.collection.total,
+ count: this.collection.length,
+ more: this.collection.hasMore()
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/list-item-view.js b/server/sonar-web/src/main/js/apps/groups/list-item-view.js
new file mode 100644
index 00000000000..43eaa5b0d24
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/list-item-view.js
@@ -0,0 +1,59 @@
+define([
+ './update-view',
+ './delete-view',
+ './users-view',
+ './templates'
+], function (UpdateView, DeleteView, UsersView) {
+
+ return Marionette.ItemView.extend({
+ tagName: 'li',
+ className: 'panel panel-vertical',
+ template: Templates['groups-list-item'],
+
+ events: {
+ 'click .js-group-update': 'onUpdateClick',
+ 'click .js-group-delete': 'onDeleteClick',
+ 'click .js-group-users': 'onUsersClick'
+ },
+
+ onRender: function () {
+ this.$el.attr('data-id', this.model.id);
+ this.$('[data-toggle="tooltip"]').tooltip({ container: 'body', placement: 'bottom' });
+ },
+
+ onClose: function () {
+ this.$('[data-toggle="tooltip"]').tooltip('destroy');
+ },
+
+ onUpdateClick: function (e) {
+ e.preventDefault();
+ this.updateGroup();
+ },
+
+ onDeleteClick: function (e) {
+ e.preventDefault();
+ this.deleteGroup();
+ },
+
+ onUsersClick: function (e) {
+ e.preventDefault();
+ this.showUsers();
+ },
+
+ updateGroup: function () {
+ new UpdateView({
+ model: this.model,
+ collection: this.model.collection
+ }).render();
+ },
+
+ deleteGroup: function () {
+ new DeleteView({ model: this.model }).render();
+ },
+
+ showUsers: function () {
+ new UsersView({ model: this.model }).render();
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/list-view.js b/server/sonar-web/src/main/js/apps/groups/list-view.js
new file mode 100644
index 00000000000..138c36b7619
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/list-view.js
@@ -0,0 +1,11 @@
+define([
+ './list-item-view',
+ './templates'
+], function (ListItemView) {
+
+ return Marionette.CollectionView.extend({
+ tagName: 'ul',
+ itemView: ListItemView
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/search-view.js b/server/sonar-web/src/main/js/apps/groups/search-view.js
new file mode 100644
index 00000000000..1540d7eb36e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/search-view.js
@@ -0,0 +1,49 @@
+define([
+ './templates'
+], function () {
+
+ return Marionette.ItemView.extend({
+ template: Templates['groups-search'],
+
+ events: {
+ 'submit #groups-search-form': 'onFormSubmit',
+ 'search #groups-search-query': 'debouncedOnKeyUp',
+ 'keyup #groups-search-query': 'debouncedOnKeyUp'
+ },
+
+ initialize: function () {
+ this._bufferedValue = null;
+ this.debouncedOnKeyUp = _.debounce(this.onKeyUp, 400);
+ },
+
+ onRender: function () {
+ this.delegateEvents();
+ },
+
+ onFormSubmit: function (e) {
+ e.preventDefault();
+ this.debouncedOnKeyUp();
+ },
+
+ onKeyUp: function () {
+ var q = this.getQuery();
+ if (q === this._bufferedValue) {
+ return;
+ }
+ this._bufferedValue = this.getQuery();
+ if (this.searchRequest != null) {
+ this.searchRequest.abort();
+ }
+ this.searchRequest = this.search(q);
+ },
+
+ getQuery: function () {
+ return this.$('#groups-search-query').val();
+ },
+
+ search: function (q) {
+ return this.collection.fetch({ reset: true, data: { q: q } });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-delete.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-delete.hbs
new file mode 100644
index 00000000000..0644817633e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-delete.hbs
@@ -0,0 +1,13 @@
+<form id="delete-group-form" autocomplete="off">
+ <div class="modal-head">
+ <h2>Delete Group</h2>
+ </div>
+ <div class="modal-body">
+ <div class="js-modal-messages"></div>
+ Are you sure you want to delete "{{name}}"?
+ </div>
+ <div class="modal-foot">
+ <button id="delete-group-submit">Delete</button>
+ <a href="#" class="js-modal-close" id="delete-group-cancel">Cancel</a>
+ </div>
+</form>
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-form.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-form.hbs
new file mode 100644
index 00000000000..a0927b33a73
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-form.hbs
@@ -0,0 +1,24 @@
+<form id="create-group-form" autocomplete="off">
+ <div class="modal-head">
+ <h2>{{#if id}}Update{{else}}Create{{/if}} Group</h2>
+ </div>
+ <div class="modal-body">
+ <div class="js-modal-messages"></div>
+ <div class="modal-field">
+ <label for="create-group-name">Name<em class="mandatory">*</em></label>
+ {{! keep this fake field to hack browser autofill }}
+ <input id="create-group-name-fake" name="name-fake" type="text" class="hidden">
+ <input id="create-group-name" name="name" type="text" size="50" maxlength="255" required value="{{name}}">
+ </div>
+ <div class="modal-field">
+ <label for="create-group-description">Description</label>
+ {{! keep this fake field to hack browser autofill }}
+ <textarea id="create-group-description-fake" name="description-fake" class="hidden"></textarea>
+ <textarea id="create-group-description" name="description">{{description}}</textarea>
+ </div>
+ </div>
+ <div class="modal-foot">
+ <button id="create-group-submit">{{#if id}}Update{{else}}Create{{/if}}</button>
+ <a href="#" class="js-modal-close" id="create-group-cancel">Cancel</a>
+ </div>
+</form>
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-header.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-header.hbs
new file mode 100644
index 00000000000..19ba74febf8
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-header.hbs
@@ -0,0 +1,9 @@
+<header class="page-header">
+ <h1 class="page-title">{{t 'user_groups.page'}}</h1>
+ <div class="page-actions">
+ <div class="button-group">
+ <button id="groups-create">Create Group</button>
+ </div>
+ </div>
+ <p class="page-description">{{t 'user_groups.page.description'}}</p>
+</header>
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-layout.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-layout.hbs
new file mode 100644
index 00000000000..4cad08c767e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-layout.hbs
@@ -0,0 +1,6 @@
+<div class="page">
+ <div id="groups-header"></div>
+ <div id="groups-search"></div>
+ <div id="groups-list"></div>
+ <div id="groups-list-footer"></div>
+</div>
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-list-footer.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-list-footer.hbs
new file mode 100644
index 00000000000..841ab40ecd9
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-list-footer.hbs
@@ -0,0 +1,6 @@
+<footer class="spacer-top note text-center">
+ {{count}}/{{total}} shown
+ {{#if more}}
+ <a id="groups-fetch-more" class="spacer-left" href="#">show more</a>
+ {{/if}}
+</footer>
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-list-item.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-list-item.hbs
new file mode 100644
index 00000000000..611cc382493
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-list-item.hbs
@@ -0,0 +1,16 @@
+<div class="pull-right big-spacer-left nowrap">
+ <a class="js-group-update icon-edit little-spacer-right" title="Update Details" data-toggle="tooltip" href="#"></a>
+ <a class="js-group-delete icon-delete" title="Deactivate" data-toggle="tooltip" href="#"></a>
+</div>
+
+<div class="display-inline-block text-top width-20">
+ <strong class="js-group-name">{{name}}</strong>
+</div>
+
+<div class="display-inline-block text-top big-spacer-left width-10">
+ Members: <a class="js-group-users" href="#">{{membersCount}}</a>
+</div>
+
+<div class="display-inline-block text-top big-spacer-left width-50">
+ <span class="js-group-description">{{description}}</span>
+</div>
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs
new file mode 100644
index 00000000000..5e81ec0b32a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs
@@ -0,0 +1,6 @@
+<div class="panel panel-vertical bordered-bottom spacer-bottom">
+ <form id="groups-search-form" class="search-box">
+ <button id="groups-search-submit" class="search-box-submit button-clean"><i class="icon-search"></i></button>
+ <input id="groups-search-query" class="search-box-input" type="search" name="q" placeholder="Search" maxlength="100">
+ </form>
+</div>
diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-users.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-users.hbs
new file mode 100644
index 00000000000..eb346c0e31b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-users.hbs
@@ -0,0 +1,10 @@
+<div class="modal-head">
+ <h2>Update users</h2>
+</div>
+<div class="modal-body">
+ <div class="js-modal-messages"></div>
+ <div id="groups-users"></div>
+</div>
+<div class="modal-foot">
+ <a href="#" class="js-modal-close" id="groups-users-done">Done</a>
+</div>
diff --git a/server/sonar-web/src/main/js/apps/groups/update-view.js b/server/sonar-web/src/main/js/apps/groups/update-view.js
new file mode 100644
index 00000000000..71383a1793d
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/update-view.js
@@ -0,0 +1,29 @@
+define([
+ './form-view'
+], function (FormView) {
+
+ return FormView.extend({
+
+ sendRequest: function () {
+ var that = this;
+ this.model.set({
+ name: this.$('#create-group-name').val(),
+ description: this.$('#create-group-description').val()
+ });
+ this.disableForm();
+ return this.model.save(null, {
+ statusCode: {
+ // do not show global error
+ 400: null
+ }
+ }).done(function () {
+ that.collection.refresh();
+ that.close();
+ }).fail(function (jqXHR) {
+ that.enableForm();
+ that.showErrors(jqXHR.responseJSON.errors, jqXHR.responseJSON.warnings);
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/groups/users-view.js b/server/sonar-web/src/main/js/apps/groups/users-view.js
new file mode 100644
index 00000000000..59f84d74b08
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/groups/users-view.js
@@ -0,0 +1,36 @@
+define([
+ 'components/common/modals',
+ 'components/common/select-list',
+ './templates'
+], function (Modal) {
+
+ return Modal.extend({
+ template: Templates['groups-users'],
+
+ onRender: function () {
+ Modal.prototype.onRender.apply(this, arguments);
+ new window.SelectList({
+ el: this.$('#groups-users'),
+ width: '100%',
+ readOnly: false,
+ focusSearch: false,
+ format: function (item) {
+ return item.name + '<br><span class="note">' + item.login + '</span>';
+ },
+ searchUrl: baseUrl + '/api/usergroups/users?ps=100&id=' + this.model.id,
+ selectUrl: baseUrl + '/api/usergroups/add_user',
+ deselectUrl: baseUrl + '/api/usergroups/remove_user',
+ extra: {
+ groupId: this.model.id
+ },
+ selectParameter: 'userLogin',
+ selectParameterValue: 'login',
+ parse: function (r) {
+ this.more = false;
+ return r.users;
+ }
+ });
+ }
+ });
+
+});
diff --git a/server/sonar-web/src/main/js/apps/users/groups-view.js b/server/sonar-web/src/main/js/apps/users/groups-view.js
index b242f435773..ee817e66694 100644
--- a/server/sonar-web/src/main/js/apps/users/groups-view.js
+++ b/server/sonar-web/src/main/js/apps/users/groups-view.js
@@ -9,23 +9,24 @@ define([
itemTemplate: Templates['users-group'],
onRender: function () {
+ console.log(this.model);
Modal.prototype.onRender.apply(this, arguments);
new window.SelectList({
el: this.$('#users-groups'),
width: '100%',
- readOnly: true,
+ readOnly: false,
focusSearch: false,
format: function (item) {
return item.name + '<br><span class="note">' + item.description + '</span>';
},
searchUrl: baseUrl + '/api/users/groups?ps=100&login=' + this.model.id,
- selectUrl: baseUrl + '/api/qualityprofiles/add_project',
- deselectUrl: baseUrl + '/api/qualityprofiles/remove_project',
+ selectUrl: baseUrl + '/api/usergroups/add_user',
+ deselectUrl: baseUrl + '/api/usergroups/remove_user',
extra: {
- profileKey: key
+ userLogin: this.model.id
},
- selectParameter: 'projectUuid',
- selectParameterValue: 'uuid',
+ selectParameter: 'groupId',
+ selectParameterValue: 'id',
parse: function (r) {
this.more = false;
return r.groups;
diff --git a/server/sonar-web/src/main/less/components/panels.less b/server/sonar-web/src/main/less/components/panels.less
index 44c2e82b69d..55c32ad5de1 100644
--- a/server/sonar-web/src/main/less/components/panels.less
+++ b/server/sonar-web/src/main/less/components/panels.less
@@ -25,7 +25,7 @@
}
.panel + .panel {
- margin-top: 20px;
+ margin-top: 10px;
padding-top: 20px;
border-top: 1px solid @barBorderColor;
}
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/groups_controller.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/groups_controller.rb
index 51a6a8063ba..9ae565cc3e1 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/groups_controller.rb
+++ b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/groups_controller.rb
@@ -23,12 +23,7 @@ class GroupsController < ApplicationController
before_filter :admin_required
def index
- @groups = Group.find(:all, :order => 'name')
- if params[:id]
- @group = Group.find(params[:id])
- else
- @group = Group.new
- end
+
end
def create_form
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/views/groups/index.html.erb b/server/sonar-web/src/main/webapp/WEB-INF/app/views/groups/index.html.erb
index fbf1a00fa7e..fedf3bd424c 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/app/views/groups/index.html.erb
+++ b/server/sonar-web/src/main/webapp/WEB-INF/app/views/groups/index.html.erb
@@ -1,65 +1,6 @@
-<% content_for :script do %>
- <script>require(['components/common/select-list']);</script>
-<% end %>
-
-<div class="page">
- <header class="page-header">
- <h1 class="page-title"><%= message('user_groups.page') -%></h1>
- <% if is_admin? %>
- <div class="page-actions">
- <a id="link-create-group" href="<%= ApplicationController.root_context -%>/groups/create_form" class="open-modal link-action">Add new group</a>
- </div>
- <% end %>
- <p class="page-description"><%= message('user_groups.page.description') -%> </p>
- </header>
-
-
- <table width="100%">
- <tr>
- <td valign="top">
-
- <table class="data width100 sortable" id="groups">
- <thead>
- <tr>
- <th class="text-left sortfirstasc"><a>Name</a></th>
- <th class="text-left"><a>Description</a></th>
- <th class="text-left"><a>Members</a></th>
- <th class="text-right nosort" nowrap><a>Operations</a></th>
- </tr>
- </thead>
- <tbody>
- <tr id="group-anyone">
- <td class="text-left"><%= h 'Anyone' -%></td>
- <td class="text-left" style="word-break:break-all"><%= message('user_groups.anyone.description') -%></td>
- <td class="text-left">&nbsp;</td>
- <td>&nbsp;</td>
- </tr>
- <% @groups.each do |group| %>
- <tr id="group-<%= group.name.parameterize -%>">
- <td class="text-left"><%= h group.name -%></td>
- <td class="text-left" style="word-break:break-all"><%= h group.description -%></td>
- <td class="text-left">
- <span id="count-<%= group.name.parameterize -%>"><%= group.users.count -%></span>
- (<a id="select-<%= group.name.parameterize -%>" class="open-modal link-action" href="<%= ApplicationController.root_context -%>/groups/select_user/<%= group.id -%>">select</a>)
- </td>
- <td class="text-right">
- <a id='edit-<%= group.name.parameterize -%>' class='open-modal link-action' href="<%= ApplicationController.root_context -%>/groups/edit_form/<%= group.id -%>">Edit</a>
- &nbsp;
- <%= link_to_action message('delete'), "#{ApplicationController.root_context}/groups/delete/#{group.id}",
- :class => 'link-action link-red',
- :id => "delete-#{group.name.parameterize}",
- :confirm_button => message('delete'),
- :confirm_title => 'Delete group: ' + group.name,
- :confirm_msg => 'Are you sure that you want to delete this group? Members will not be deleted.',
- :confirm_msg_params => [group.name]
- -%>
- </td>
- </tr>
- <% end %>
- </tbody>
- </table>
- <script>jQuery('#groups').sortable();</script>
- </td>
- </tr>
- </table>
-</div>
+<div id="groups"></div>
+<script>
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+</script>
diff --git a/server/sonar-web/src/test/js/groups-spec.js b/server/sonar-web/src/test/js/groups-spec.js
new file mode 100644
index 00000000000..ef21e8a8fea
--- /dev/null
+++ b/server/sonar-web/src/test/js/groups-spec.js
@@ -0,0 +1,355 @@
+/* globals casper: false */
+var lib = require('../lib'),
+ testName = lib.testName('Groups');
+
+lib.initMessages();
+lib.changeWorkingDirectory('groups-spec');
+lib.configureCasper();
+
+casper.test.begin(testName('List'), 7, function (test) {
+ casper
+ .start(lib.buildUrl('groups'), function () {
+ lib.setDefaultViewport();
+ lib.mockRequestFromFile('/api/usergroups/search', 'search.json');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('sonar-users');
+ })
+
+ .then(function () {
+ test.assertExists('#groups-list ul');
+ test.assertElementCount('#groups-list li[data-id]', 2);
+ test.assertSelectorContains('#groups-list .js-group-name', 'sonar-users');
+ test.assertSelectorContains('#groups-list .js-group-description',
+ 'Any new users created will automatically join this group');
+ test.assertElementCount('#groups-list .js-group-update', 2);
+ test.assertElementCount('#groups-list .js-group-delete', 2);
+ test.assertSelectorContains('#groups-list-footer', '2/2');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Search'), 4, function (test) {
+ casper
+ .start(lib.buildUrl('groups'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search.json');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('sonar-users');
+ })
+
+ .then(function () {
+ test.assertElementCount('#groups-list li[data-id]', 2);
+ lib.clearRequestMock(this.searchMock);
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search-filtered.json',
+ { data: { q: 'adm' } });
+ casper.evaluate(function () {
+ jQuery('#groups-search-query').val('adm');
+ });
+ casper.click('#groups-search-submit');
+ casper.waitForSelectorTextChange('#groups-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#groups-list li[data-id]', 1);
+ lib.clearRequestMock(this.searchMock);
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search.json');
+ casper.evaluate(function () {
+ jQuery('#groups-search-query').val('');
+ });
+ casper.click('#groups-search-submit');
+ casper.waitForSelectorTextChange('#groups-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#groups-list li[data-id]', 2);
+ test.assert(casper.evaluate(function () {
+ return jQuery('#groups-search-query').val() === '';
+ }));
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Show More'), 4, function (test) {
+ casper
+ .start(lib.buildUrl('groups'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search-big-1.json');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('sonar-users');
+ })
+
+ .then(function () {
+ test.assertElementCount('#groups-list li[data-id]', 1);
+ test.assertSelectorContains('#groups-list-footer', '1/2');
+ lib.clearRequestMock(this.searchMock);
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search-big-2.json', { data: { p: '2' } });
+ casper.click('#groups-fetch-more');
+ casper.waitForSelectorTextChange('#groups-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#groups-list li[data-id]', 2);
+ test.assertSelectorContains('#groups-list-footer', '2/2');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Show Users'), 2, function (test) {
+ casper
+ .start(lib.buildUrl('groups'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search.json');
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/users*', 'users.json');
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('sonar-users');
+ })
+
+ .then(function () {
+ test.assertTextDoesntExist('Bob');
+ casper.click('[data-id="1"] .js-group-users');
+ casper.waitForText('Bob');
+ })
+
+ .then(function () {
+ test.assertTextExist('John');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Create'), 4, function (test) {
+ casper
+ .start(lib.buildUrl('groups'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search.json');
+ this.createMock = lib.mockRequestFromFile('/api/usergroups/create', 'error.json', { status: 400 });
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+ jQuery.ajaxSetup({ dataType: 'json' });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('sonar-users');
+ })
+
+ .then(function () {
+ test.assertElementCount('#groups-list li[data-id]', 2);
+ casper.click('#groups-create');
+ casper.waitForSelector('#create-group-form');
+ })
+
+ .then(function () {
+ casper.click('#create-group-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ lib.clearRequestMock(this.searchMock);
+ lib.mockRequestFromFile('/api/usergroups/search', 'search-created.json');
+ lib.clearRequestMock(this.createMock);
+ lib.mockRequest('/api/usergroups/create', '{}',
+ { data: { name: 'name', description: 'description' } });
+ casper.evaluate(function () {
+ jQuery('#create-group-name').val('name');
+ jQuery('#create-group-description').val('description');
+ });
+ casper.click('#create-group-submit');
+ casper.waitForSelectorTextChange('#groups-list-footer');
+ })
+
+ .then(function () {
+ test.assertElementCount('#groups-list li[data-id]', 3);
+ test.assertSelectorContains('#groups-list .js-group-name', 'name');
+ test.assertSelectorContains('#groups-list .js-group-description', 'description');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Update'), 2, function (test) {
+ casper
+ .start(lib.buildUrl('groups'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search.json');
+ this.updateMock = lib.mockRequestFromFile('/api/usergroups/update', 'error.json', { status: 400 });
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+ jQuery.ajaxSetup({ dataType: 'json' });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('sonar-users');
+ })
+
+ .then(function () {
+ casper.click('[data-id="2"] .js-group-update');
+ casper.waitForSelector('#create-group-form');
+ })
+
+ .then(function () {
+ casper.click('#create-group-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ lib.clearRequestMock(this.searchMock);
+ lib.mockRequestFromFile('/api/usergroups/search', 'search-updated.json');
+ lib.clearRequestMock(this.updateMock);
+ lib.mockRequest('/api/usergroups/update', '{}',
+ { data: { id: '2', name: 'guys', description: 'cool guys' } });
+ casper.evaluate(function () {
+ jQuery('#create-group-name').val('guys');
+ jQuery('#create-group-description').val('cool guys');
+ });
+ casper.click('#create-group-submit');
+ casper.waitForText('guys');
+ })
+
+ .then(function () {
+ test.assertSelectorContains('[data-id="2"] .js-group-name', 'guys');
+ test.assertSelectorContains('[data-id="2"] .js-group-description', 'cool guys');
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
+
+
+casper.test.begin(testName('Delete'), 1, function (test) {
+ casper
+ .start(lib.buildUrl('groups'), function () {
+ lib.setDefaultViewport();
+ this.searchMock = lib.mockRequestFromFile('/api/usergroups/search', 'search.json');
+ this.updateMock = lib.mockRequestFromFile('/api/usergroups/delete', 'error.json', { status: 400 });
+ })
+
+ .then(function () {
+ casper.evaluate(function () {
+ require(['apps/groups/app'], function (App) {
+ App.start({ el: '#groups' });
+ });
+ jQuery.ajaxSetup({ dataType: 'json' });
+ });
+ })
+
+ .then(function () {
+ casper.waitForText('sonar-users');
+ })
+
+ .then(function () {
+ casper.click('[data-id="1"] .js-group-delete');
+ casper.waitForSelector('#delete-group-form');
+ })
+
+ .then(function () {
+ casper.click('#delete-group-submit');
+ casper.waitForSelector('.alert.alert-danger');
+ })
+
+ .then(function () {
+ lib.clearRequestMock(this.updateMock);
+ lib.mockRequest('/api/usergroups/delete', '{}', { data: { id: '1'} });
+ casper.click('#delete-group-submit');
+ casper.waitWhileSelector('[data-id="1"]');
+ })
+
+ .then(function () {
+ test.assert(true);
+ })
+
+ .then(function () {
+ lib.sendCoverage();
+ })
+ .run(function () {
+ test.done();
+ });
+});
diff --git a/server/sonar-web/src/test/json/groups-spec/error.json b/server/sonar-web/src/test/json/groups-spec/error.json
new file mode 100644
index 00000000000..dc1b261128c
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/error.json
@@ -0,0 +1,7 @@
+{
+ "errors": [
+ {
+ "msg": "Some error message"
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/groups-spec/search-big-1.json b/server/sonar-web/src/test/json/groups-spec/search-big-1.json
new file mode 100644
index 00000000000..654742be995
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/search-big-1.json
@@ -0,0 +1,13 @@
+{
+ "total": 2,
+ "p": 1,
+ "ps": 1,
+ "groups": [
+ {
+ "id": "1",
+ "name": "sonar-users",
+ "description": "Any new users created will automatically join this group",
+ "membersCount": 3
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/groups-spec/search-big-2.json b/server/sonar-web/src/test/json/groups-spec/search-big-2.json
new file mode 100644
index 00000000000..9d8a35bd3b6
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/search-big-2.json
@@ -0,0 +1,13 @@
+{
+ "total": 2,
+ "p": 2,
+ "ps": 1,
+ "groups": [
+ {
+ "id": "2",
+ "name": "sonar-administrators",
+ "description": "System administrators",
+ "membersCount": 1
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/groups-spec/search-created.json b/server/sonar-web/src/test/json/groups-spec/search-created.json
new file mode 100644
index 00000000000..1222b0dc009
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/search-created.json
@@ -0,0 +1,25 @@
+{
+ "total": 3,
+ "p": 1,
+ "ps": 50,
+ "groups": [
+ {
+ "id": "1",
+ "name": "sonar-users",
+ "description": "Any new users created will automatically join this group",
+ "membersCount": 3
+ },
+ {
+ "id": "2",
+ "name": "sonar-administrators",
+ "description": "System administrators",
+ "membersCount": 1
+ },
+ {
+ "id": "3",
+ "name": "name",
+ "description": "description",
+ "membersCount": 0
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/groups-spec/search-filtered.json b/server/sonar-web/src/test/json/groups-spec/search-filtered.json
new file mode 100644
index 00000000000..58a0ead4a40
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/search-filtered.json
@@ -0,0 +1,13 @@
+{
+ "total": 1,
+ "p": 1,
+ "ps": 50,
+ "groups": [
+ {
+ "id": "2",
+ "name": "sonar-administrators",
+ "description": "System administrators",
+ "membersCount": 1
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/groups-spec/search-updated.json b/server/sonar-web/src/test/json/groups-spec/search-updated.json
new file mode 100644
index 00000000000..ed1484f245d
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/search-updated.json
@@ -0,0 +1,19 @@
+{
+ "total": 2,
+ "p": 1,
+ "ps": 50,
+ "groups": [
+ {
+ "id": "1",
+ "name": "sonar-users",
+ "description": "Any new users created will automatically join this group",
+ "membersCount": 3
+ },
+ {
+ "id": "2",
+ "name": "guys",
+ "description": "cool guys",
+ "membersCount": 1
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/groups-spec/search.json b/server/sonar-web/src/test/json/groups-spec/search.json
new file mode 100644
index 00000000000..a347403c5ae
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/search.json
@@ -0,0 +1,19 @@
+{
+ "total": 2,
+ "p": 1,
+ "ps": 50,
+ "groups": [
+ {
+ "id": "1",
+ "name": "sonar-users",
+ "description": "Any new users created will automatically join this group",
+ "membersCount": 3
+ },
+ {
+ "id": "2",
+ "name": "sonar-administrators",
+ "description": "System administrators",
+ "membersCount": 1
+ }
+ ]
+}
diff --git a/server/sonar-web/src/test/json/groups-spec/users.json b/server/sonar-web/src/test/json/groups-spec/users.json
new file mode 100644
index 00000000000..dfb28ec4555
--- /dev/null
+++ b/server/sonar-web/src/test/json/groups-spec/users.json
@@ -0,0 +1,17 @@
+{
+ "users": [
+ {
+ "login": "bob",
+ "name": "Bob",
+ "selected": true
+ },
+ {
+ "login": "john",
+ "name": "John",
+ "selected": true
+ }
+ ],
+ "p": 1,
+ "ps": 100,
+ "total": 2
+}
diff --git a/server/sonar-web/src/test/views/groups.jade b/server/sonar-web/src/test/views/groups.jade
new file mode 100644
index 00000000000..ddf0a29c6a3
--- /dev/null
+++ b/server/sonar-web/src/test/views/groups.jade
@@ -0,0 +1,5 @@
+extends layouts/main
+
+block body
+ #content
+ #groups