diff --git a/Gemfile b/Gemfile index a8d3c9714..a11e9337c 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 893a65ef5..786cb8c3c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 18817b37f..f2389da4d 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -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) { diff --git a/app/assets/javascripts/backbone/additionals.js.erb b/app/assets/javascripts/backbone/additionals.js.erb new file mode 100644 index 000000000..36cd3700a --- /dev/null +++ b/app/assets/javascripts/backbone/additionals.js.erb @@ -0,0 +1 @@ +Rosa.bootstrapedData.ROLES = <%= Relation::ROLES.to_json %>; diff --git a/app/assets/javascripts/backbone/models/.gitkeep b/app/assets/javascripts/backbone/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/backbone/models/collaborator.js b/app/assets/javascripts/backbone/models/collaborator.js new file mode 100644 index 000000000..e203bbf36 --- /dev/null +++ b/app/assets/javascripts/backbone/models/collaborator.js @@ -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")); + })); + } +}); diff --git a/app/assets/javascripts/backbone/rosa.js b/app/assets/javascripts/backbone/rosa.js new file mode 100644 index 000000000..9ed5f3096 --- /dev/null +++ b/app/assets/javascripts/backbone/rosa.js @@ -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: {} +} diff --git a/app/assets/javascripts/backbone/routers/.gitkeep b/app/assets/javascripts/backbone/routers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/backbone/routers/collaborators_router.js b/app/assets/javascripts/backbone/routers/collaborators_router.js new file mode 100644 index 000000000..b3f12ed69 --- /dev/null +++ b/app/assets/javascripts/backbone/routers/collaborators_router.js @@ -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(); + } +}); diff --git a/app/assets/javascripts/backbone/templates/.gitkeep b/app/assets/javascripts/backbone/templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/backbone/templates/collaborators/collaborator.jst.hamljs b/app/assets/javascripts/backbone/templates/collaborators/collaborator.jst.hamljs new file mode 100644 index 000000000..b1ad6b5c2 --- /dev/null +++ b/app/assets/javascripts/backbone/templates/collaborators/collaborator.jst.hamljs @@ -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] +- } diff --git a/app/assets/javascripts/backbone/templates/collaborators/searched_collaborator.jst.hamljs b/app/assets/javascripts/backbone/templates/collaborators/searched_collaborator.jst.hamljs new file mode 100644 index 000000000..7b8a3e86f --- /dev/null +++ b/app/assets/javascripts/backbone/templates/collaborators/searched_collaborator.jst.hamljs @@ -0,0 +1,7 @@ +%a + .collaborator + .img + %img{width: '16px', src: avatar, alt: 'avatar'} + .name + = actor_name + .both diff --git a/app/assets/javascripts/backbone/templates/shared/autocomplete_result_empty.jst.hamljs b/app/assets/javascripts/backbone/templates/shared/autocomplete_result_empty.jst.hamljs new file mode 100644 index 000000000..8ed5f5d10 --- /dev/null +++ b/app/assets/javascripts/backbone/templates/shared/autocomplete_result_empty.jst.hamljs @@ -0,0 +1,3 @@ +%li.empty_result + %span + Nothing found diff --git a/app/assets/javascripts/backbone/views/.gitkeep b/app/assets/javascripts/backbone/views/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/javascripts/backbone/views/add_collaborator_view.js b/app/assets/javascripts/backbone/views/add_collaborator_view.js new file mode 100644 index 000000000..bdddba49f --- /dev/null +++ b/app/assets/javascripts/backbone/views/add_collaborator_view.js @@ -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; + } + +}); diff --git a/app/assets/javascripts/backbone/views/collaborator_view.js b/app/assets/javascripts/backbone/views/collaborator_view.js new file mode 100644 index 000000000..c785baa0e --- /dev/null +++ b/app/assets/javascripts/backbone/views/collaborator_view.js @@ -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'); + } + }); + } +}); diff --git a/app/assets/javascripts/backbone/views/collaborators_view.js b/app/assets/javascripts/backbone/views/collaborators_view.js new file mode 100644 index 000000000..cf6aafe84 --- /dev/null +++ b/app/assets/javascripts/backbone/views/collaborators_view.js @@ -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); + } +}); diff --git a/app/assets/javascripts/backbone/views/searched_collaborator_view.js b/app/assets/javascripts/backbone/views/searched_collaborator_view.js new file mode 100644 index 000000000..05911be42 --- /dev/null +++ b/app/assets/javascripts/backbone/views/searched_collaborator_view.js @@ -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; + } +}) diff --git a/app/assets/javascripts/design/radio.js b/app/assets/javascripts/design/radio.js index 179027de2..01f13bbca 100644 --- a/app/assets/javascripts/design/radio.js +++ b/app/assets/javascripts/design/radio.js @@ -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) { diff --git a/app/assets/javascripts/lib/jquery.placeholder.js b/app/assets/javascripts/lib/jquery.placeholder.js new file mode 100644 index 000000000..cb4a2f42a --- /dev/null +++ b/app/assets/javascripts/lib/jquery.placeholder.js @@ -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); diff --git a/app/assets/stylesheets/design/custom.scss b/app/assets/stylesheets/design/custom.scss index 081dbc57f..edc2c37f2 100644 --- a/app/assets/stylesheets/design/custom.scss +++ b/app/assets/stylesheets/design/custom.scss @@ -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; -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/design/main.scss b/app/assets/stylesheets/design/main.scss index 3bd65fb09..2a2e18cb1 100644 --- a/app/assets/stylesheets/design/main.scss +++ b/app/assets/stylesheets/design/main.scss @@ -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; diff --git a/app/controllers/collaborators_controller.rb b/app/controllers/collaborators_controller.rb index ac237350a..8860cae19 100644 --- a/app/controllers/collaborators_controller.rb +++ b/app/controllers/collaborators_controller.rb @@ -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 diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 11472533c..653b5fa9b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -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 diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 3ca90f5e2..94d80dcd0 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -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 diff --git a/app/models/collaborator.rb b/app/models/collaborator.rb new file mode 100644 index 000000000..5a3bb7a4c --- /dev/null +++ b/app/models/collaborator.rb @@ -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 diff --git a/app/models/group.rb b/app/models/group.rb index 3f9b976a2..393069884 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index a6b7d6b32..5803ef8f2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/collaborators/_collaborator.json.jbuilder b/app/views/collaborators/_collaborator.json.jbuilder new file mode 100644 index 000000000..f1d105a32 --- /dev/null +++ b/app/views/collaborators/_collaborator.json.jbuilder @@ -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 diff --git a/app/views/collaborators/_collaborators.json.jbuilder b/app/views/collaborators/_collaborators.json.jbuilder new file mode 100644 index 000000000..9e8d099a2 --- /dev/null +++ b/app/views/collaborators/_collaborators.json.jbuilder @@ -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 diff --git a/app/views/collaborators/index.html.haml b/app/views/collaborators/index.html.haml new file mode 100644 index 000000000..a9f3b96e8 --- /dev/null +++ b/app/views/collaborators/index.html.haml @@ -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    + %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(); + }); diff --git a/app/views/collaborators/index.json.jbuilder b/app/views/collaborators/index.json.jbuilder new file mode 100644 index 000000000..259ed6bc4 --- /dev/null +++ b/app/views/collaborators/index.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'collaborators', :collaborators => @collaborators diff --git a/app/views/projects/_sidebar.html.haml b/app/views/projects/_sidebar.html.haml index 48e3f03d8..a0ce94f88 100644 --- a/app/views/projects/_sidebar.html.haml +++ b/app/views/projects/_sidebar.html.haml @@ -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) diff --git a/config/environments/development.rb b/config/environments/development.rb index 3b763e0c3..890a1ec7f 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 diff --git a/config/locales/layout.en.yml b/config/locales/layout.en.yml index c793a70ed..94659ac41 100644 --- a/config/locales/layout.en.yml +++ b/config/locales/layout.en.yml @@ -16,6 +16,7 @@ en: remove: Remove find_project: Find project... + filter_by_name: Filter by name visibilities: open: open diff --git a/config/locales/layout.ru.yml b/config/locales/layout.ru.yml index 9bb3755b1..7b66a589e 100644 --- a/config/locales/layout.ru.yml +++ b/config/locales/layout.ru.yml @@ -16,6 +16,7 @@ ru: remove: Убрать find_project: Найти проект... + filter_by_name: Фильтр по имени visibilities: open: открытая diff --git a/config/routes.rb b/config/routes.rb index d5c34c86b..1ede68a47 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/lib/modules/models/acts_like_member.rb b/lib/modules/models/acts_like_member.rb new file mode 100644 index 000000000..0db4dd577 --- /dev/null +++ b/lib/modules/models/acts_like_member.rb @@ -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 +