Merge pull request #388 from warpc/347-javascript_refactoring

[issue #347] Union users and groups into one table in collaborators page. Use backbone(JS framework) for it. Improve UI for collaborators page (AJAX).
This commit is contained in:
Vladimir Sharshov 2012-04-20 08:15:12 -07:00
commit 24922cc43c
38 changed files with 967 additions and 115 deletions

View File

@ -44,6 +44,8 @@ gem 'rails3-jquery-autocomplete', '~> 1.0.6'
gem 'will_paginate', '~> 3.0.3'
gem 'meta-tags', '~> 1.2.5', :require => 'meta_tags'
gem "haml-rails", '~> 0.3.4'
gem 'ruby-haml-js'
gem 'rails-backbone'
gem 'jquery-rails', '~> 2.0.1'
group :assets do

View File

@ -110,6 +110,7 @@ GEM
warden (~> 1.1.1)
diff-display (0.0.1)
diff-lcs (1.1.3)
ejs (1.0.0)
erubis (2.7.0)
eventmachine (0.12.10)
eventmachine (0.12.10-java)
@ -218,6 +219,10 @@ GEM
activesupport (= 3.2.2)
bundler (~> 1.0)
railties (= 3.2.2)
rails-backbone (0.7.1)
coffee-script (~> 2.2.0)
ejs (~> 1.0.0)
railties (>= 3.1.0)
rails3-generators (0.17.4)
railties (>= 3.0.0)
rails3-jquery-autocomplete (1.0.6)
@ -249,6 +254,9 @@ GEM
activesupport (>= 3.0)
railties (>= 3.0)
rspec (~> 2.9.0)
ruby-haml-js (0.0.2)
execjs
sprockets (>= 2.0.0)
ruby-openid (2.1.8)
russian (0.6.0)
i18n (>= 0.5.0)
@ -351,6 +359,7 @@ DEPENDENCIES
paperclip (~> 2.7.0)
pg (~> 0.13.2)
rails (= 3.2.2)
rails-backbone
rails3-generators
rails3-jquery-autocomplete (~> 1.0.6)
rdiscount
@ -358,6 +367,7 @@ DEPENDENCIES
redhillonrails_core!
rr (~> 1.0.4)
rspec-rails (~> 2.9.0)
ruby-haml-js
russian (~> 0.6.0)
sass-rails (~> 3.2.5)
shotgun

View File

@ -4,8 +4,15 @@
//= require autocomplete-rails
//= require vendor
//= require jquery.dataTables_ext
//= require_tree ./lib
//= require_tree ./design
//= require_tree ./extra
//= require underscore
//= require backbone
//= require backbone_rails_sync
//= require backbone_datalink
//= require backbone/rosa
//= require_self
function disableNotifierCbx(global_cbx) {
@ -19,6 +26,10 @@ function disableNotifierCbx(global_cbx) {
}
$(document).ready(function() {
// setup all placeholders on page
$('input[placeholder], textarea[placeholder]').placeholder();
$('input.user_role_chbx').click(function() {
var current = $(this);
current.parent().find('input.user_role_chbx').each(function(i,el) {

View File

@ -0,0 +1 @@
Rosa.bootstrapedData.ROLES = <%= Relation::ROLES.to_json %>;

View File

@ -0,0 +1,97 @@
Rosa.Models.Collaborator = Backbone.Model.extend({
paramRoot: 'collaborator',
defaults: {
id: null,
actor_id: null,
actor_name: null,
actor_type: null,
avatar: null,
actor_path: null,
project_id: null,
role: null,
removed: false
},
changeRole: function(r) {
var self = this;
this._prevState = this.get('role');
this.save({role: r},
{wait: true,
success: function(model, response) {
self.trigger('sync_success');
},
error: function(model, response) {
model.set({role: model._prevState});
self.trigger('sync_failed');
}
});
return this;
},
setRole: function(r) {
this.set({ role: r });
return this;
},
toggleRemoved: function() {
if (this.get('removed') === false) {
this.set({removed: true});
} else {
this.set({removed: false});
}
return this;
}
});
Rosa.Collections.CollaboratorsCollection = Backbone.Collection.extend({
model: Rosa.Models.Collaborator,
initialize: function(coll, opts) {
if (opts === undefined || opts['url'] === undefined) {
this.url = window.location.pathname;
} else {
this.url = opts['url'];
}
this.on('change:removed change:id add', this.sort, this);
},
comparator: function(m) {
var res = ''
if (m.get('removed') === true) {
res = 0;
} else if (m.isNew()) {
res = 1;
} else { res = 2 }
return res + m.get('actor_name');
},
removeMarked: function() {
var marked = this.where({removed: true});
marked.forEach(function(el) {
el.destroy({wait: true, silent: true});
});
},
saveAndAdd: function(model) {
model.urlRoot = this.url;
var self = this;
model.save({}, {
wait: true,
success: function(m) {
self.add(m.toJSON());
}
});
},
filterByName: function(term, options) {
if (term == "") return this;
console.log(term);
var pattern = new RegExp(term, "i");
return _(this.filter(function(data) {
console.log(data.get("actor_name"));
console.log(pattern.test(data.get("actor_name")));
return pattern.test(data.get("actor_name"));
}));
}
});

View File

@ -0,0 +1,15 @@
//= require_self
//= require ./additionals
//= require_tree ./templates
//= require_tree ./models
//= require_tree ./views
//= require_tree ./routers
window.Rosa = {
Models: {},
Collections: {},
Routers: {},
Views: {},
bootstrapedData: {}
}

View File

@ -0,0 +1,15 @@
Rosa.Routers.CollaboratorsRouter = Backbone.Router.extend({
routes: {},
initialize: function() {
this.collaboratorsCollection = new Rosa.Collections.CollaboratorsCollection(Rosa.bootstrapedData.collaborators, { url: window.location.pathname });
this.searchCollection = new Rosa.Collections.CollaboratorsCollection(null, { url: window.location.pathname + '/find' });
this.tableView = new Rosa.Views.CollaboratorsView({ collection: this.collaboratorsCollection });
this.addView = new Rosa.Views.AddCollaboratorView({ collection: this.searchCollection });
this.addView.on('collaborator_prepared', this.collaboratorsCollection.saveAndAdd, this.collaboratorsCollection);
this.tableView.render();
this.addView.render();
}
});

View File

@ -0,0 +1,39 @@
%td
%span#niceCheckbox1.nicecheck-main{ style: "background-position: 0px 0px; " }
- if (removed === true) {
%input{ type: 'checkbox', checked: 'checked' }
- } else {
%input{ type: 'checkbox' }
- }
%td
.img
%img{ src: avatar, alt: 'avatar' }
.forimg
%a{ href: actor_path }
= actor_name
- var ROLES = Rosa.bootstrapedData.ROLES;
- for (var i = 0; i < ROLES.length; i++) {
%td
.radio
- var radio_id = id + '_' + ROLES[i];
- var radio_type = 'role[' + id + ']';
- if (ROLES[i] === role) {
- if ( removed ) {
%input.niceRadio{type: 'radio', value: ROLES[i], id: radio_id, name: radio_type, disabled: 'disabled', checked: 'checked'}
- } else {
%input.niceRadio{type: 'radio', value: ROLES[i], id: radio_id, name: radio_type, checked: 'checked'}
- };
- } else {
- if ( removed ) {
%input.niceRadio{type: 'radio', value: ROLES[i], id: radio_id, name: radio_type, disabled: 'disabled' }
- } else {
%input.niceRadio{type: 'radio', value: ROLES[i], id: radio_id, name: radio_type }
- };
- }
.forradio
%label{ for: radio_id }
= ROLES[i]
- }

View File

@ -0,0 +1,7 @@
%a
.collaborator
.img
%img{width: '16px', src: avatar, alt: 'avatar'}
.name
= actor_name
.both

View File

@ -0,0 +1,3 @@
%li.empty_result
%span
Nothing found

View File

@ -0,0 +1,92 @@
Rosa.Views.AddCollaboratorView = Backbone.View.extend({
result_empty: JST['backbone/templates/shared/autocomplete_result_empty'],
events: {
'click #add_collaborator_button': 'addCollaborator'
},
initialize: function() {
_.bindAll(this, 'getData', 'renderAll', 'onFocus', 'selectItem', 'addCollaborator');
this.$el = $('#add_collaborator_form');
this.$_search_input = this.$('#collaborator_name');
this.$_image = this.$('.admin-search.withimage img');
this.$_role = this.$('#role');
this.ac = this.$_search_input.autocomplete({
minLength: 1,
source: this.getData,
focus: this.onFocus,
select: this.selectItem
});
this.ac.data("autocomplete")._renderItem = this.addOne;
this.ac.data("autocomplete")._renderMenu = this.renderAll;
},
render: function() {
return this;
},
getData: function(request, response) {
var self = this;
var res = this.collection.fetch({
data: {term: request.term},
wait: true,
success: function(collection) {
self.$_image.hide();
if (collection.length !== 0) {
response(collection.models);
} else {
response([{result_empty: true}]);
}
}
});
},
addOne: function(ul, item) {
var v = new Rosa.Views.SearchedCollaboratorView({ model: item });
return v.render().$el.appendTo(ul);
},
renderAll: function( ul, items ) {
var self = this;
if (items[0]['result_empty'] !== undefined && items[0]['result_empty'] === true) {
ul.removeClass('has_results').append(this.result_empty());
} else {
ul.addClass('has_results');
_.each( items, function( item ) {
self.addOne( ul, item );
});
var factor = (items.length > 10) ? 10 : items.length;
ul.height(ul.children('li').first() * factor);
}
},
onFocus: function( event, ui ) {
this.$_search_input.val(ui.item.get('actor_name'));
return false;
},
selectItem: function( event, ui ) {
var model = ui.item;
this.$_image.attr('src', model.get('avatar')).show();
this.__selected_item = model;
return false;
},
addCollaborator: function(e) {
e.preventDefault();
var model = this.__selected_item;
if ( model !== undefined ) {
model.setRole(this.$_role.val());
this.trigger('collaborator_prepared', model);
this.__selected_item = undefined;
this.$_image.hide();
this.$_search_input.val('');
}
return false;
}
});

View File

@ -0,0 +1,69 @@
Rosa.Views.CollaboratorView = Backbone.View.extend({
template: JST['backbone/templates/collaborators/collaborator'],
tagName: 'tr',
className: 'regular',
events: {
'change input[type="radio"]': 'changeRole',
'change input[type="checkbox"]': 'toggleRemoved'
},
initialize: function() {
this.$el.attr('id', 'admin-table-members-row' + this.options.model.get('id') + this.options.model.get('actor_type'));
this.model.on('change', this.render, this);
this.model.on('destroy', this.hide, this);
this.model.on('sync_failed', this.syncError, this);
this.model.on('sync_success', this.syncSuccess, this);
},
render: function() {
if (this.model.get('removed')) {
this.$el.addClass('removed');
} else {
this.$el.removeClass('removed');
};
this.$el.html(this.template(this.model.toJSON()));
return this;
},
changeRole: function(e) {
this.$('input[type="radio"]').attr('disabled', 'disabled');
this.model.changeRole(e.target.value);
},
toggleRemoved: function(e) {
this.model.toggleRemoved();
},
hide: function() {
this.remove();
},
syncError: function() {
var self = this;
this.$el.addClass('sync_error');
this.$('td').animate({
'background-color': '#FFFFFF'
}, {
duration: 800,
easing: 'easeInCirc',
complete: function() {
self.$el.removeClass('sync_error');
}
});
},
syncSuccess: function() {
var self = this;
this.$el.addClass('sync_success');
this.$('td').animate({
'background-color': '#FFFFFF'
}, {
duration: 800,
easing: 'easeInCirc',
complete: function() {
self.$el.removeClass('sync_success');
}
});
}
});

View File

@ -0,0 +1,57 @@
Rosa.Views.CollaboratorsView = Backbone.View.extend({
initialize: function() {
_.bindAll(this, 'deleterClick', 'processFilter', 'addOne');
this.setupDeleter();
this.setupFilter();
this.$el = $('#collaborators > tbody');
this.collection.on('reset', this.render, this);
this.collection.on('add', this.clearFilter, this);
},
addOne: function(collaborator) {
var cView = new Rosa.Views.CollaboratorView({ model: collaborator });
this.$el.append(cView.render().$el);
},
render: function() {
this.clearFilter();
this.$el.empty();
this.collection.forEach(this.addOne, this);
this._$deleter.show();
return this;
},
renderList: function(list) {
this.$el.empty();
list.each(this.addOne);
return this;
},
setupDeleter: function() {
this._$deleter = $('#collaborators_deleter');
this._$deleter.on('click.deleter', this.deleterClick);
this._$deleter.attr('title', 'Remove selected rows');
},
deleterClick: function() {
this.collection.removeMarked();
},
setupFilter: function() {
this._$filter = $('#collaborators thead input[type="text"]');
this._$filter.on('keyup', this.processFilter);
this.clearFilter();
},
clearFilter: function() {
this._$filter.val('');
},
processFilter: function() {
var term = this._$filter.val();
var list = this.collection.filterByName(term, {excludeRemoved: true});
console.log(list);
this.renderList(list);
}
});

View File

@ -0,0 +1,12 @@
Rosa.Views.SearchedCollaboratorView = Backbone.View.extend({
template: JST['backbone/templates/collaborators/searched_collaborator'],
tagName: 'li',
className: 'item',
render: function() {
this.$el.empty();
this.$el.data( "item.autocomplete", this.model )
.append(this.template(this.model.toJSON()));
return this;
}
})

View File

@ -76,7 +76,9 @@ function changeRadioStart(el) {
}
el.next().bind("mousedown", function(e) {
changeRadio($(this));
if (e.which === 0) {
changeRadio($(this));
};
$(this).find("input:radio").change();
});
if($.browser.msie) {

View File

@ -0,0 +1,106 @@
/*
* Placeholder plugin for jQuery
* ---
* Copyright 2010, Daniel Stocks (http://webcloud.se)
* Released under the MIT, BSD, and GPL Licenses.
*/
(function($) {
function Placeholder(input) {
this.input = input;
if (input.attr('type') == 'password') {
this.handlePassword();
}
// Prevent placeholder values from submitting
$(input[0].form).submit(function() {
if (input.hasClass('placeholder') && input[0].value == input.attr('placeholder')) {
input[0].value = '';
}
});
}
Placeholder.prototype = {
show : function(loading) {
// FF and IE saves values when you refresh the page. If the user refreshes the page with
// the placeholders showing they will be the default values and the input fields won't be empty.
if (this.input[0].value === '' || (loading && this.valueIsPlaceholder())) {
if (this.isPassword) {
try {
this.input[0].setAttribute('type', 'text');
} catch (e) {
this.input.before(this.fakePassword.show()).hide();
}
}
this.input.addClass('placeholder');
this.input[0].value = this.input.attr('placeholder');
}
},
hide : function() {
if (this.valueIsPlaceholder() && this.input.hasClass('placeholder')) {
this.input.removeClass('placeholder');
this.input[0].value = '';
if (this.isPassword) {
try {
this.input[0].setAttribute('type', 'password');
} catch (e) { }
// Restore focus for Opera and IE
this.input.show();
this.input[0].focus();
}
}
},
valueIsPlaceholder : function() {
return this.input[0].value == this.input.attr('placeholder');
},
handlePassword: function() {
var input = this.input;
input.attr('realType', 'password');
this.isPassword = true;
// IE < 9 doesn't allow changing the type of password inputs
if ($.browser.msie && input[0].outerHTML) {
var fakeHTML = $(input[0].outerHTML.replace(/type=(['"])?password\1/gi, 'type=$1text$1'));
this.fakePassword = fakeHTML.val(input.attr('placeholder')).addClass('placeholder').focus(function() {
input.trigger('focus');
$(this).hide();
});
$(input[0].form).submit(function() {
fakeHTML.remove();
input.show()
});
}
}
};
var NATIVE_SUPPORT = !!("placeholder" in document.createElement( "input" ));
$.fn.placeholder = function() {
return NATIVE_SUPPORT ? this : this.each(function() {
var input = $(this);
var placeholder = new Placeholder(input);
placeholder.show(true);
input.focus(function() {
placeholder.hide();
});
input.blur(function() {
placeholder.show(false);
});
// On page refresh, IE doesn't re-populate user input
// until the window.onload event is fired.
if ($.browser.msie) {
$(window).load(function() {
if(input.val()) {
input.removeClass("placeholder");
}
placeholder.show(true);
});
// What's even worse, the text cursor disappears
// when tabbing between text inputs, here's a fix
input.focus(function() {
if(this.value == "") {
var range = this.createTextRange();
range.collapse(true);
range.moveStart('character', 0);
range.select();
}
});
}
});
}
})(jQuery);

View File

@ -1,9 +1,13 @@
// PUT custom styles here ONLY
span.error {
span.error, .hidden {
display: none;
}
.centered {
text-align: center;
}
a#manage-labels {
margin-bottom: 10px;
}
@ -456,10 +460,12 @@ table.tablesorter tr td.buttons {
text-align: center;
}
table.tablesorter tr td.buttons a span.delete {
table.tablesorter tr td.buttons a span.delete,
span.delete {
background: image-url('x.png') no-repeat 0 0 transparent;
width: 12px;
display: inline-block;
cursor: pointer;
}
#fork-and-edit {display:block;}
@ -658,6 +664,7 @@ table.dataTable {
table.tablesorter tr.search th {
background: none repeat scroll 0 0 #DCECFA;
padding: 0 17px 0 5px;
}
table.tablesorter tr.search th input[type="text"] {
@ -667,7 +674,8 @@ table.tablesorter tr.search th input[type="text"] {
font-size: 12px;
height: 16px;
padding: 5px;
width: 830px;
width: 100%;
margin-top: 2px;
}
@ -765,6 +773,85 @@ div.tos_sidebar ul li a {
text-decoration: none;
}
table.tablesorter tbody tr.regular td {
background-color: #FFFFFF;
}
table.tablesorter tbody tr.removed td,
table.tablesorter tbody tr.sync_error td {
background-color: #FFECEC;
}
table.tablesorter tbody tr.sync_success td {
background-color: #E0ECFF;
}
ul.ui-autocomplete li.item {
/* padding: 1px 0; */
}
ul.ui-autocomplete li.item div.collaborator {
padding: 0px 10px;
}
ul.ui-autocomplete li.item a.ui-corner-all {
padding: 0;
}
ul.ui-autocomplete li.item a.ui-corner-all.ui-state-hover {
background: #DCECFA;
border: 1px solid #65A6F7;
}
ul.ui-autocomplete li.item div.collaborator div.img {
padding: 0;
padding-top: 1px;
}
ul.ui-autocomplete li.item div.collaborator div.name {
padding: 0;
margin-left: 5px;
}
ul.ui-autocomplete.has_results {
overflow: auto;
}
#add_collaborator_form div.search_string {
margin-left: 5px;
float: left;
padding-top: 6px;
}
#add_collaborator_form div.img {
width: 25px;
height: 25px;
// border: 1px solid #DDDDDD;
// border-radius: 2px;
}
#add_collaborator_form div.img img {
// margin-top: 1px;
// margin-left: 1px;
}
#add_collaborator_form .admin-role .lineForm {
padding-top: 7px;
}
input:-moz-placeholder,
textarea:-moz-placeholder {
color: #CFCFCF;
}
input::-webkit-input-placeholder,
textarea::-webkit-input-placeholder {
color: #CFCFCF;
}
input.placeholder,
textarea.placeholder {
color: #CFCFCF;
}
div.description-top div.git_help {
float: left;
margin-top: 11px;
@ -833,4 +920,4 @@ div#git_help_data p {
.dropdown.open .dropdown-toggle {
background: none repeat scroll 0 0;
}
}

View File

@ -909,6 +909,41 @@ article input.black {
color: #333333;
}
article input.gray.withimage, article input.black.withimage {
padding-left: 30px;
width: 275px;
background-size: 25px 25px;
}
div.admin-search.withimage {
width: 300px;
margin-right: 5px;
height: 25px;
border: 1px solid #dedede;
border-radius: 3px;
margin-top: 1px;
}
div.admin-search.withimage img {
width: 25px;
height: 25px;
float: left;
}
.right div.admin-search.withimage input {
width: 270px;
float: left;
margin-right: 0px;
height: 22px;
border: none;
border-radius: none;
margin-top: 1px;
font-size: 12px;
font-family: Tahoma, Geneva, Helvetica, sans-serif;
padding-left: 5px;
}
.right div.admin-add {
float: left;
padding-top: 1px;

View File

@ -1,5 +1,7 @@
# -*- encoding : utf-8 -*-
class CollaboratorsController < ApplicationController
respond_to :html, :json
before_filter :authenticate_user!
before_filter :find_project
@ -10,7 +12,8 @@ class CollaboratorsController < ApplicationController
before_filter :authorize_collaborators
def index
redirect_to edit_project_collaborators_path(@project)
@collaborators = Collaborator.find_by_project(@project)
respond_with @collaborators
end
def show
@ -19,108 +22,48 @@ class CollaboratorsController < ApplicationController
def new
end
def edit
if params[:id]
@user = User.find params[:id]
render :edit_rights and return
def find
users = User.not_member_of(@project)
groups = Group.not_member_of(@project)
if params[:term].present?
users = users.search(params[:term])
groups = groups.search(params[:term])
end
@collaborators = (users | groups).map{|act| Collaborator.new(:actor => act, :project => @project)}
respond_with @collaborators do |format|
format.json { render 'index' }
end
end
def edit
end
def create
puts params.inspect
@collaborator = Collaborator.new(params[:collaborator])
@collaborator.project = @project
if @collaborator.save
respond_with @collaborator do |format|
format.json { render :partial => 'collaborator', :locals => {:collaborator => @collaborator} }
end
else
raise
end
end
def update
params['user'].keys.each { |user_id|
role = params['user'][user_id]
if relation = @project.relations.find_by_object_id_and_object_type(user_id, 'User')
unless @project.owner_type == 'User' and @project.owner_id.to_i == user_id.to_i
relation.update_attribute(:role, role)
end
else
relation = @project.relations.build(:object_id => user_id, :object_type => 'User', :role => role)
relation.save
end
} if params['user']
params['group'].keys.each { |group_id|
role = params['group'][group_id]
if relation = @project.relations.find_by_object_id_and_object_type(group_id, 'Group')
unless @project.owner_type == 'Group' and @project.owner_id.to_i == group_id.to_i
relation.update_attribute(:role, role)
end
else
relation = @project.relations.build(:object_id => user_id, :object_type => 'Group', :role => role)
relation.save
end
} if params['group']
if @project.save
flash[:notice] = t("flash.collaborators.successfully_changed")
@c = Collaborator.find(params[:id])
if @c.update_attributes(params[:collaborator])
respond_with @c
else
flash[:error] = t("flash.collaborators.error_in_changing")
raise
end
redirect_to edit_project_collaborators_path(@project)
end
def remove
all_user_ids = []
all_group_ids = []
params['user_remove'].keys.each { |user_id|
all_user_ids << user_id if params['user_remove'][user_id] == ["1"]
} if params['user_remove']
params['group_remove'].keys.each { |group_id|
all_group_ids << group_id if params['group_remove'][group_id] == ["1"]
} if params['group_remove']
all_user_ids.each do |user_id|
u = User.find(user_id)
Relation.by_object(u).by_target(@project).each {|r| r.destroy} unless u.id == @project.owner_id and @project.owner_type == 'User'
end
all_group_ids.each do |group_id|
g = Group.find(group_id)
Relation.by_object(g).by_target(@project).each {|r| r.destroy} unless g.id == @project.owner_id and @project.owner_type == 'Group'
end
redirect_to edit_project_collaborators_path(@project) + "##{params['user_remove'].present? ? 'users' : 'groups'}"
end
def add
# TODO: Here is used Chelyabinsk method to display Flash messages.
member = User.find(params['member_id']) if params['member_id'] && !params['member_id'].empty?
group = Group.find(params['group_id']) if params['group_id'] && !params['group_id'].empty?
flash[:notice], flash[:error], flash[:warning] = [], [], []
[member, group].compact.each do |mem|
if mem and @project.relations.exists?(:object_id => mem.id, :object_type => mem.class.to_s)
flash[:warning] << [t('flash.collaborators.member_already_added'), mem.uname]
end
unless @project.relations.exists?(:object_id => mem.id, :object_type => mem.class.to_s)
rel = @project.relations.build(:role => params[:role])
rel.object = mem
if rel.save
flash[:notice] << [t('flash.collaborators.successfully_added'), mem.uname]
else
flash[:notice] << [t('flash.collaborators.error_in_adding'), mem.uname]
end
end
end
[:notice, :warning, :error].each do |k|
if flash[k].size > 0
flash[k] = flash[k].map{|i| (i.is_a? Array) ? sprintf(i.first, i.last) : i}.join('; ')
else
flash.delete k
end
end
# if add an anchor, adding will be more pleasant, but flash message wouldn't be shown.
redirect_to edit_project_collaborators_path(@project) # + "##{(params['member_id'].present?) ? 'users' : 'groups'}"
def destroy
@cb = Collaborator.find(params[:id])
@cb.destroy if @cb
respond_with @cb
end
protected

View File

@ -30,4 +30,8 @@ module ProjectsHelper
def alone_member?(project)
Relation.by_target(project).by_object(current_user).size > 0
end
def participant_path(participant)
participant.kind_of?(User) ? user_path(participant) : group_path(participant)
end
end

View File

@ -6,6 +6,7 @@ module UsersHelper
end
def avatar_url(user, size = :small)
return image_path('group32.png') if user.kind_of? Group
if user.try('avatar?')
user.avatar.url(size)
else

153
app/models/collaborator.rb Normal file
View File

@ -0,0 +1,153 @@
# -*- encoding : utf-8 -*-
class Collaborator
include ActiveModel::Conversion
include ActiveModel::Validations
include ActiveModel::Serializers::JSON
include ActiveModel::MassAssignmentSecurity
extend ActiveModel::Naming
attr_accessor :role, :actor, :project, :relation
attr_reader :id, :actor_id, :actor_type, :actor_name, :project_id
attr_accessible :role
delegate :new_record?, :to => :relation
class << self
def find_by_project(project)
res = []
project.relations.each do |r|
res << from_relation(r) unless project.owner_id == r.object_id and project.owner_type == r.object_type
end
return res
end
def find(id)
return self.from_relation(Relation.find(id)) || nil
end
def create(args)
self.new(args).save
end
def create!(args)
self.new(args).save!
end
end
def initialize(args = {})
args.to_options!
acc_options = args.select{ |(k, v)| k.in? [:actor, :project, :relation] }
acc_options.each_pair do |name, value|
send("#{name}=", value)
end
if args[:project_id].present?
@project = Project.find(args[:project_id])
end
if args[:actor_id].present? and args[:actor_type].present?
@actor = args[:actor_type].classify.constantize.find(args[:actor_id])
end
if @relation.nil? and @actor.present? and @project.present?
@relation = Relation.by_object(@actor).by_target(@project).limit(1).first
@relation ||= Relation.new(:object => @actor, :target => @project)
end
@relation.role = args[:role] if @relation.present? and args[:role].present?
end
def update_attributes(attributes, options = {})
sanitize_for_mass_assignment(attributes, options[:as]).each_pair do |k, v|
send("#{k}=", v)
end
save
end
def relation=(model)
@relation = model
@actor = @relation.object
@project = @relation.target
end
def id
@relation.try(:id)
end
def actor_id
@actor.try(:id)
end
def actor_type
@actor.class.to_s.underscore
end
def actor_name
if @actor.present?
@actor.instance_of?(User) ? "#{@actor.uname}#{ @actor.try(:name) and !@actor.name.empty? ? " (#{@actor.name})" : ''}" : @actor.uname
else
nil
end
end
def project_id
@project.try(:id)
end
def role
@relation.try(:role)
end
def role=(arg)
@relation.role = arg
end
def save
@relation.try(:save)
end
def save!
@relation.try(:save!)
end
def destroy
@relation.try(:destroy)
end
def attributes
%w{ id actor_id actor_type actor_name project_id role}.inject({}) do |h, e|
h.merge(e => send(e))
end
end
def persisted?
false
end
protected
class << self
def from_relation(relation)
return nil unless relation.present?
return self.new(:relation => relation)
end
end
def relation
return @relation if @relation.present? and @relation.object == @actor and @relation.target == @project
if @actor.present? and @project.present?
@relation = Relation.by_object(@actor).by_target(@project).limit(1).first
@relation ||= Relation.new(:object_id => @actor.id, :object_type => @actor.class.to_s,
:target_id => @project.id, :target_type => 'Project')
else
@relation = Relation.new
@relation.object = @actor
@relation.target = @project
end
@relation
end
end
Collaborator.include_root_in_json = false

View File

@ -16,13 +16,12 @@ class Group < ActiveRecord::Base
validates :uname, :presence => true, :uniqueness => {:case_sensitive => false}, :format => { :with => /^[a-z0-9_]+$/ }
validate { errors.add(:uname, :taken) if User.where('uname LIKE ?', uname).present? }
scope :search_order, order("CHAR_LENGTH(uname) ASC")
scope :without, lambda {|a| where("groups.id NOT IN (?)", a)}
scope :search, lambda {|q| where("uname ILIKE ?", "%#{q.to_s.strip}%")}
scope :opened, where('1=1')
scope :by_owner, lambda {|owner| where(:owner_id => owner.id)}
scope :by_admin, lambda {|admin| joins(:objects).where(:'relations.role' => 'admin', :'relations.object_id' => admin.id, :'relations.object_type' => 'User')}
include Modules::Models::ActsLikeMember
attr_accessible :description
attr_readonly :own_projects_count

View File

@ -47,14 +47,13 @@ class User < ActiveRecord::Base
attr_readonly :uname, :own_projects_count
attr_accessor :login
scope :search_order, order("CHAR_LENGTH(uname) ASC")
scope :without, lambda {|a| where("users.id NOT IN (?)", a)}
scope :search, lambda {|q| where("uname ILIKE ?", "%#{q.to_s.strip}%")}
scope :opened, where('1=1')
scope :banned, where(:role => 'banned')
scope :admin, where(:role => 'admin')
scope :real, where(:role => ['', nil])
include Modules::Models::ActsLikeMember
after_create lambda { self.create_notifier }
before_create :ensure_authentication_token

View File

@ -0,0 +1,10 @@
json.id collaborator.id
json.actor_id collaborator.actor_id
json.actor_name collaborator.actor_name
json.actor_type collaborator.actor_type
json.avatar avatar_url(collaborator.actor)
json.actor_path participant_path(collaborator.actor)
json.project_id collaborator.project_id
json.role collaborator.role

View File

@ -0,0 +1,12 @@
json.array!(collaborators) do |json, cb|
json.id cb.id
json.actor_id cb.actor_id
json.actor_name cb.actor_name
json.actor_type cb.actor_type
json.avatar avatar_url(cb.actor)
json.actor_path participant_path(cb.actor)
json.project_id cb.project_id
json.role cb.role
end

View File

@ -0,0 +1,43 @@
-set_meta_tags :title => [title_object(@project), t('layout.projects.members')]
= render :partial => 'projects/sidebar'
= render :partial => 'projects/submenu'
%a{:name => 'users'}
%h3= t("layout.users.list_header")
#add_collaborator_form
.admin-search.withimage
.img
%img{ :alt => 'avatar', :src => '', :style => 'display: none;' }
= text_field_tag :collaborator_name, nil
.both
.admin-role
.lineForm
= select_tag 'role', options_for_collaborators_roles_select
%a{:id => 'add_collaborator_button', :class => 'button', :rel => 'nofollow', :href => 'javascript:void(0)'}
= t('layout.add')
.both
%table#collaborators.tablesorter{:cellpadding => "0", :cellspacing => "0"}
%thead
%tr
%th.centered
%span#collaborators_deleter.hidden
%span.delete &nbsp; 
%th
= t("layout.collaborators.members")
%th{:colspan => "3"}
= t("layout.collaborators.roles")
%tr.search
%th{:colspan => "5"}
%input{ :type => "text", :placeholder => "#{ t('layout.filter_by_name') }"}
%tbody
%br
.both
:javascript
$(function() {
Rosa.bootstrapedData.collaborators = #{ render :partial => 'collaborators.json.jbuilder', :locals => {:collaborators => @collaborators } };
r = new Rosa.Routers.CollaboratorsRouter();
});

View File

@ -0,0 +1 @@
json.partial! 'collaborators', :collaborators => @collaborators

View File

@ -12,4 +12,4 @@
= link_to t("layout.projects.sections"), sections_project_path(@project)
- if can? :manage_collaborators, @project
%li{:class => (act == :edit && contr == :collaborators) ? 'active' : ''}
= link_to t("layout.projects.edit_collaborators"), edit_project_collaborators_path(@project)
= link_to t("layout.projects.edit_collaborators"), project_collaborators_path(@project)

View File

@ -33,7 +33,7 @@ Rosa::Application.configure do
config.assets.compress = false
# Expands the lines which load the assets
config.assets.debug = false
config.assets.debug = true
# Raise exception on mass assignment protection for Active Record models
config.active_record.mass_assignment_sanitizer = :strict

View File

@ -16,6 +16,7 @@ en:
remove: Remove
find_project: Find project...
filter_by_name: Filter by name
visibilities:
open: open

View File

@ -16,6 +16,7 @@ ru:
remove: Убрать
find_project: Найти проект...
filter_by_name: Фильтр по имени
visibilities:
open: открытая

View File

@ -112,16 +112,8 @@ Rosa::Application.routes.draw do
resources :build_lists, :only => [:index, :new, :create] do
collection { post :search }
end
resources :collaborators, :only => [:index, :edit, :update, :add] do
collection do
get :edit
post :update
post :add
delete :remove
end
member do
post :update
end
resources :collaborators do
get :find, :on => :collection
end
member do
post :fork

View File

@ -0,0 +1,33 @@
# -*- encoding : utf-8 -*-
module Modules
module Models
module ActsLikeMember
extend ActiveSupport::Concern
included do |klass|
scope :not_member_of, lambda { |item|
where("
#{klass.table_name}.id NOT IN (
SELECT relations.object_id
FROM relations
WHERE (
relations.object_type = '#{klass.to_s}'
AND relations.target_type = '#{item.class.to_s}'
AND relations.target_id = #{item.id}
)
)
")
}
scope :search_order, order("CHAR_LENGTH(uname) ASC")
scope :without, lambda {|a| where("#{klass.table_name}.id NOT IN (?)", a)}
scope :search, lambda {|q| where("#{klass.table_name}.uname ILIKE ?", "%#{q.strip}%")}
end
module ClassMethods
end
end
end
end