diff --git a/app/assets/javascripts/app/collections/tag_followings.js b/app/assets/javascripts/app/collections/tag_followings.js new file mode 100644 index 000000000..d969a598b --- /dev/null +++ b/app/assets/javascripts/app/collections/tag_followings.js @@ -0,0 +1,17 @@ +app.collections.TagFollowings = Backbone.Collection.extend({ + + model: app.models.TagFollowing, + + url : "/tag_followings", + + create : function(model) { + var name = model.name || model.get("name"); + if(!this.any( + function(tagFollowing){ + return tagFollowing.get("name") === name; + })) { + Backbone.Collection.prototype.create.apply(this, arguments); + } + } + +}); diff --git a/app/assets/javascripts/app/models/tag_following.js b/app/assets/javascripts/app/models/tag_following.js new file mode 100644 index 000000000..b2ab35977 --- /dev/null +++ b/app/assets/javascripts/app/models/tag_following.js @@ -0,0 +1,3 @@ +app.models.TagFollowing = Backbone.Model.extend({ + urlRoot: "/tag_followings" +}); diff --git a/app/assets/javascripts/app/router.js b/app/assets/javascripts/app/router.js index 1e9a19425..6fcd6c95b 100644 --- a/app/assets/javascripts/app/router.js +++ b/app/assets/javascripts/app/router.js @@ -16,8 +16,8 @@ app.Router = Backbone.Router.extend({ "commented": "stream", "liked": "stream", "mentions": "stream", - "followed_tags": "stream", - "tags/:name": "stream", + "followed_tags": "followed_tags", + "tags/:name": "followed_tags", "people/:id/photos": "photos", "people/:id": "stream", @@ -63,6 +63,24 @@ app.Router = Backbone.Router.extend({ app.photos = new app.models.Stream([], {collection: app.collections.Photos}); app.page = new app.views.Photos({model : app.photos}); $("#main_stream").html(app.page.render().el); + }, + + followed_tags : function(name) { + this.stream(); + + app.tagFollowings = new app.collections.TagFollowings(); + var followedTagsView = new app.views.TagFollowingList({collection: app.tagFollowings}); + $("#tags_list").replaceWith(followedTagsView.render().el); + followedTagsView.setupAutoSuggest(); + + app.tagFollowings.add(preloads.tagFollowings); + + if(name) { + var followedTagsAction = new app.views.TagFollowingAction( + {tagText: name} + ); + $("#author_info").prepend(followedTagsAction.render().el) + } } }); diff --git a/app/assets/javascripts/app/views/tag_following_action_view.js b/app/assets/javascripts/app/views/tag_following_action_view.js new file mode 100644 index 000000000..fbfe2bae2 --- /dev/null +++ b/app/assets/javascripts/app/views/tag_following_action_view.js @@ -0,0 +1,62 @@ +app.views.TagFollowingAction = app.views.Base.extend({ + + templateName: "tag_following_action", + + events : { + "mouseenter .button.red_on_hover": "mouseIn", + "mouseleave .button.red_on_hover": "mouseOut", + "click .button": "tagAction" + }, + + initialize : function(options){ + this.tagText = options.tagText; + this.getTagFollowing(); + app.tagFollowings.bind("remove add", this.getTagFollowing, this); + }, + + presenter : function() { + return _.extend(this.defaultPresenter(), { + tag_is_followed : this.tag_is_followed(), + followString : this.followString() + }) + }, + + followString : function() { + if(this.tag_is_followed()) { + return Diaspora.I18n.t("stream.tags.following", {"tag" : this.model.attributes.name}); + } else { + return Diaspora.I18n.t("stream.tags.follow", {"tag" : this.model.attributes.name}); + } + }, + + tag_is_followed : function() { + return !this.model.isNew(); + }, + + getTagFollowing : function(tagFollowing) { + this.model = app.tagFollowings.where({"name":this.tagText})[0] || + new app.models.TagFollowing({"name":this.tagText}); + this.model.bind("change", this.render, this); + this.render(); + }, + + mouseIn : function(){ + this.$("input").removeClass("in_aspects"); + this.$("input").val( Diaspora.I18n.t('stream.tags.stop_following', {tag: this.model.attributes.name} ) ); + }, + + mouseOut : function() { + this.$("input").addClass("in_aspects"); + this.$("input").val( Diaspora.I18n.t('stream.tags.following', {"tag" : this.model.attributes.name} ) ); + }, + + tagAction : function(evt){ + if(evt){ evt.preventDefault(); } + + if(this.tag_is_followed()) { + this.model.destroy(); + } else { + app.tagFollowings.create(this.model); + } + } +}); \ No newline at end of file diff --git a/app/assets/javascripts/app/views/tag_following_list_view.js b/app/assets/javascripts/app/views/tag_following_list_view.js new file mode 100644 index 000000000..192d276ea --- /dev/null +++ b/app/assets/javascripts/app/views/tag_following_list_view.js @@ -0,0 +1,75 @@ +//= require jquery.autoSuggest.custom +app.views.TagFollowingList = app.views.Base.extend({ + + templateName: "tag_following_list", + + className : "sub_nav", + + id : "tags_list", + + tagName : "ul", + + events: { + "submit form": "createTagFollowing" + }, + + initialize : function(){ + this.collection.bind("add", this.appendTagFollowing, this); + }, + + postRenderTemplate : function() { + this.collection.each(this.appendTagFollowing, this); + }, + + setupAutoSuggest : function() { + this.$("input").autoSuggest("/tags", { + selectedItemProp: "name", + selectedValuesProp: "name", + searchObjProps: "name", + asHtmlID: "tags", + neverSubmit: true, + retrieveLimit: 10, + selectionLimit: false, + minChars: 2, + keyDelay: 200, + startText: "", + emptyText: "no_results", + selectionAdded: _.bind(this.suggestSelection, this) + }); + + this.$("input").bind('keydown', function(evt){ + if(evt.keyCode == 13 || evt.keyCode == 9 || evt.keyCode == 32){ + evt.preventDefault(); + if( $('li.as-result-item.active').length == 0 ){ + $('li.as-result-item').first().click(); + } + } + }); + }, + + presenter : function() { + return this.defaultPresenter(); + }, + + suggestSelection : function(elem) { + this.$(".tag_input").val($(elem[0]).text().substring(2)); + elem.remove(); + this.createTagFollowing(); + }, + + createTagFollowing: function(evt) { + if(evt){ evt.preventDefault(); } + var name = this.$(".tag_input").val(); + + this.collection.create({"name":this.$(".tag_input").val()}); + this.$(".tag_input").val(""); + return this; + }, + + appendTagFollowing: function(tag) { + this.$el.prepend(new app.views.TagFollowing({ + model: tag + }).render().el); + } + +}); \ No newline at end of file diff --git a/app/assets/javascripts/app/views/tag_following_view.js b/app/assets/javascripts/app/views/tag_following_view.js new file mode 100644 index 000000000..f59defb32 --- /dev/null +++ b/app/assets/javascripts/app/views/tag_following_view.js @@ -0,0 +1,33 @@ +app.views.TagFollowing = app.views.Base.extend({ + + templateName: "tag_following", + + className : "unfollow", + + tagName: "li", + + events : { + "click .tag_following_delete": "destroyModel" + }, + + initialize : function(){ + this.el.id = "tag-following-" + this.model.get("name"); + this.model.bind("destroy", this.hide, this); + }, + + hide : function() { + this.$el.slideUp(); + }, + + postRenderTemplate : function() { + this.$el.hide(); + this.$el.slideDown(); + }, + + presenter : function() { + return _.extend(this.defaultPresenter(), { + tag : this.model + }) + } + +}); \ No newline at end of file diff --git a/app/assets/javascripts/pages/users-getting-started.js b/app/assets/javascripts/pages/users-getting-started.js index ec59feb8f..c402ee35c 100644 --- a/app/assets/javascripts/pages/users-getting-started.js +++ b/app/assets/javascripts/pages/users-getting-started.js @@ -36,12 +36,10 @@ Diaspora.Pages.UsersGettingStarted = function() { }); $("#awesome_button").bind("click", function(evt){ - evt.preventDefault(); var confirmMessage = Diaspora.I18n.t("getting_started.no_tags"); if(($("#as-selections-tags").find(".as-selection-item").length > 0) || confirm(confirmMessage)) { - $('.tag_input').submit(); /* flash message prompt */ var message = Diaspora.I18n.t("getting_started.preparing_your_stream"); @@ -55,18 +53,25 @@ Diaspora.Pages.UsersGettingStarted = function() { /* ------ */ var autocompleteInput = $("#follow_tags"); + var tagFollowings = new app.collections.TagFollowings(); autocompleteInput.autoSuggest("/tags", { selectedItemProp: "name", + selectedValuesProp: "name", searchObjProps: "name", asHtmlID: "tags", neverSubmit: true, - retriveLimit: 10, + retrieveLimit: 10, selectionLimit: false, minChars: 2, keyDelay: 200, startText: "", - emptyText: "no_results" + emptyText: "no_results", + selectionAdded: function(elem){tagFollowings.create({"name":$(elem[0]).text().substring(2)})}, + selectionRemoved: function(elem){ + tagFollowings.where({"name":$(elem[0]).text().substring(2)})[0].destroy(); + elem.remove(); + } }); autocompleteInput.bind('keydown', function(evt){ diff --git a/app/assets/stylesheets/application.css.sass b/app/assets/stylesheets/application.css.sass index 50343261e..f2dc85d55 100644 --- a/app/assets/stylesheets/application.css.sass +++ b/app/assets/stylesheets/application.css.sass @@ -2778,6 +2778,7 @@ a.toggle_selector :left 24px input[type='text'] + :width 100% :font :size 13px diff --git a/app/assets/templates/tag_following_action_tpl.jst.hbs b/app/assets/templates/tag_following_action_tpl.jst.hbs new file mode 100644 index 000000000..606f6778f --- /dev/null +++ b/app/assets/templates/tag_following_action_tpl.jst.hbs @@ -0,0 +1,10 @@ +
+
+ +
+
\ No newline at end of file diff --git a/app/assets/templates/tag_following_list_tpl.jst.hbs b/app/assets/templates/tag_following_list_tpl.jst.hbs new file mode 100644 index 000000000..279fbefa7 --- /dev/null +++ b/app/assets/templates/tag_following_list_tpl.jst.hbs @@ -0,0 +1,5 @@ +
  • +
    + +
    +
  • \ No newline at end of file diff --git a/app/assets/templates/tag_following_tpl.jst.hbs b/app/assets/templates/tag_following_tpl.jst.hbs new file mode 100644 index 000000000..d19868afb --- /dev/null +++ b/app/assets/templates/tag_following_tpl.jst.hbs @@ -0,0 +1,9 @@ + + + + #{{ name }} + diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 7bf708867..b659c5f1a 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -165,7 +165,8 @@ class PeopleController < ApplicationController def redirect_if_tag_search if search_query.starts_with?('#') if search_query.length > 1 - redirect_to tag_path(:name => search_query.delete('#.'), :q => search_query) + + redirect_to tag_path(:name => search_query.delete('#.')) else flash[:error] = I18n.t('tags.show.none', :name => search_query) redirect_to :back diff --git a/app/controllers/streams_controller.rb b/app/controllers/streams_controller.rb index a28f47715..4d6f2852c 100644 --- a/app/controllers/streams_controller.rb +++ b/app/controllers/streams_controller.rb @@ -52,6 +52,7 @@ class StreamsController < ApplicationController end def followed_tags + gon.tagFollowings = tags stream_responder(Stream::FollowedTag) end diff --git a/app/controllers/tag_followings_controller.rb b/app/controllers/tag_followings_controller.rb index fc5aa0ca4..c447d9e92 100644 --- a/app/controllers/tag_followings_controller.rb +++ b/app/controllers/tag_followings_controller.rb @@ -6,7 +6,7 @@ class TagFollowingsController < ApplicationController before_filter :authenticate_user! - respond_to :html, :json + respond_to :json # POST /tag_followings # POST /tag_followings.xml @@ -14,52 +14,38 @@ class TagFollowingsController < ApplicationController name_normalized = ActsAsTaggableOn::Tag.normalize(params['name']) if name_normalized.nil? || name_normalized.empty? - flash[:error] = I18n.t('tag_followings.create.none') + render :nothing => true, :status => 403 else @tag = ActsAsTaggableOn::Tag.find_or_create_by_name(name_normalized) @tag_following = current_user.tag_followings.new(:tag_id => @tag.id) if @tag_following.save - flash[:notice] = I18n.t('tag_followings.create.success', :name => name_normalized) + render :json => @tag.to_json, :status => 201 else - flash[:error] = I18n.t('tag_followings.create.failure', :name => name_normalized) + render :nothing => true, :status => 403 end end - redirect_to :back end # DELETE /tag_followings/1 # DELETE /tag_followings/1.xml def destroy - @tag = ActsAsTaggableOn::Tag.find_by_name(params[:name]) - tag_following = current_user.tag_followings.find_by_tag_id( @tag.id ) + tag_following = current_user.tag_followings.find_by_tag_id( params['id'] ) + if tag_following && tag_following.destroy - tag_unfollowed = true + respond_to do |format| + format.any(:js, :json) { render :nothing => true, :status => 204 } + end else - tag_unfollowed = false - end - - respond_to do |format| - format.js { render 'tags/update' } - format.any { - if tag_unfollowed - flash[:notice] = I18n.t('tag_followings.destroy.success', :name => params[:name]) - else - flash[:error] = I18n.t('tag_followings.destroy.failure', :name => params[:name]) - end - redirect_to tag_path(:name => params[:name]) - } - end - end - - def create_multiple - if params[:tags].present? - params[:tags].split(",").each do |name| - name_normalized = ActsAsTaggableOn::Tag.normalize(name) - @tag = ActsAsTaggableOn::Tag.find_or_create_by_name(name_normalized) - @tag_following = current_user.tag_followings.create(:tag_id => @tag.id) + respond_to do |format| + format.any(:js, :json) {render :nothing => true, :status => 403} end end - redirect_to stream_path + end + + def index + respond_to do |format| + format.json{ render(:json => tags.to_json, :status => 200) } + end end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 45a8ee954..13766ac13 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -32,8 +32,10 @@ class TagsController < ApplicationController end def show + if user_signed_in? + gon.tagFollowings = tags + end @stream = Stream::Tag.new(current_user, params[:name], :max_time => max_time, :page => params[:page]) - respond_with do |format| format.json { render :json => @stream.stream_posts.map { |p| LastThreeCommentsDecorator.new(PostPresenter.new(p, current_user)) }} end @@ -47,18 +49,10 @@ class TagsController < ApplicationController def prep_tags_for_javascript @tags.map! do |tag| - { - :name => ("#" + tag.name), - :value => ("#" + tag.name), - :url => tag_path(tag.name) - } + { :name => ("#" + tag.name) } end - @tags << { - :name => ('#' + params[:q]), - :value => ("#" + params[:q]), - :url => tag_path(params[:q].downcase) - } + @tags << { :name => ('#' + params[:q]) } @tags.uniq! end end diff --git a/app/models/acts_as_taggable_on/tag.rb b/app/models/acts_as_taggable_on/tag.rb index 1806262e7..70b77f69b 100644 --- a/app/models/acts_as_taggable_on/tag.rb +++ b/app/models/acts_as_taggable_on/tag.rb @@ -1,5 +1,7 @@ class ActsAsTaggableOn::Tag + self.include_root_in_json = false + def followed_count @followed_count ||= TagFollowing.where(:tag_id => self.id).count end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index af2d720fc..3aa6a640b 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -46,6 +46,8 @@ = yield(:head) = csrf_meta_tag + = include_gon(:camel_case => true, :namespace => :preloads) + %body = flash_messages diff --git a/app/views/tags/_followed_tags_listings.haml b/app/views/tags/_followed_tags_listings.haml index d61837c18..defb05517 100644 --- a/app/views/tags/_followed_tags_listings.haml +++ b/app/views/tags/_followed_tags_listings.haml @@ -7,15 +7,4 @@ %li %b=link_to t('streams.followed_tag.title'), followed_tags_stream_path, :class => 'home_selector' - - if @stream.is_a?(Stream::FollowedTag) - %ul.sub_nav - - if tags.size > 0 - - for tg in tags - %li.unfollow{:id => "tag-following-#{tg.name}"} - .unfollow_icon.hidden - = link_to image_tag("icons/monotone_close_exit_delete.png", :height => 16, :title => t('aspects.index.unfollow_tag', :tag => tg.name)), tag_tag_followings_path(:name => tg.name, :remote => true), :data => { :confirm => t('are_you_sure') }, :method => :delete, :remote => true, :id => "unfollow_" + tg.name - = link_to "##{tg.name}", tag_path(:name => tg.name), :class => "tag_selector" - %li - = form_for TagFollowing.new do |tg| - = text_field_tag :name, "", :class => "tag_input", :placeholder => t('streams.followed_tag.add_a_tag') - = tg.submit t('streams.followed_tag.follow'), :class => "button" + %ul.sub_nav#tags_list diff --git a/app/views/tags/show.haml b/app/views/tags/show.haml index 02f11a6b8..d04b53c5c 100644 --- a/app/views/tags/show.haml +++ b/app/views/tags/show.haml @@ -9,27 +9,6 @@ - else = t('.whatup', :pod => @pod_url) -- content_for :head do - = javascript_include_tag :home - :javascript - $(document).ready(function(){ - // Change the text and color of the "follow this tag" button on hover. - $(".button.tag_following").hover(function(){ - $this = $(this); - $this.removeClass("in_aspects"); - $this.val("#{t('.stop_following', :tag => @stream.tag_name)}"); - }, - function(){ - $this = $(this); - $this.addClass("in_aspects"); - $this.val("#{t('.following', :tag => @stream.tag_name)}"); - }); - }); - $(".people_stream .pagination a").live("click", function() { - $.getScript(this.href); - return false; - }); - - content_for :body_class do = "tags_show" @@ -49,12 +28,6 @@ .span-15.last .stream_container #author_info - - if user_signed_in? - .right - - unless tag_followed? - = button_to t('.follow', :tag => @stream.tag_name), tag_tag_followings_path(:name => @stream.tag_name), :method => :post, :class => 'button take_action' - - else - = button_to t('.following', :tag => @stream.tag_name), tag_tag_followings_path(:name => @stream.tag_name), :method => :delete, :class => 'button red_on_hover tag_following in_aspects take_action' %h2 = @stream.display_tag_name %small diff --git a/app/views/users/getting_started.haml b/app/views/users/getting_started.haml index 54a511ac8..29195de13 100644 --- a/app/views/users/getting_started.haml +++ b/app/views/users/getting_started.haml @@ -65,7 +65,7 @@ .row .span13 - = form_tag(multiple_tag_followings_path, :method => 'post', :class => "tag_input search_form") do + = form_tag(tag_followings_path, :method => 'get', :class => "tag_input search_form") do %fieldset .clearfix = label_tag 'follow_tags', t('.hashtag_suggestions'), :class => "bootstrapped" @@ -77,5 +77,5 @@ %br %br .input - = link_to "#{t('.awesome_take_me_to_diaspora')} »", "#", :id => "awesome_button", :class => "btn primary" + = link_to "#{t('.awesome_take_me_to_diaspora')} »", stream_path, :id => "awesome_button", :class => "btn primary" diff --git a/config/locales/javascript/javascript.en.yml b/config/locales/javascript/javascript.en.yml index 851f5236d..d5f960636 100644 --- a/config/locales/javascript/javascript.en.yml +++ b/config/locales/javascript/javascript.en.yml @@ -106,6 +106,17 @@ en: one: "Show <%= count %> more comment" other: "Show <%= count %> more comments" + followed_tag: + title: "#Followed Tags" + contacts_title: "People who dig these tags" + add_a_tag: "Add a tag" + follow: "Follow" + + tags: + follow: "Follow #<%= tag %>" + following: "Following #<%= tag %>" + stop_following: "Stop Following #<%= tag %>" + header: home: "Home" profile: "Profile" diff --git a/config/routes.rb b/config/routes.rb index 89f2e91d2..781166415 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,13 +74,8 @@ Diaspora::Application.routes.draw do end resources :tags, :only => [:index] - scope "tags/:name" do - post "tag_followings" => "tag_followings#create", :as => 'tag_tag_followings' - delete "tag_followings" => "tag_followings#destroy", :as => 'tag_tag_followings' - end - post "multiple_tag_followings" => "tag_followings#create_multiple", :as => 'multiple_tag_followings' - resources "tag_followings", :only => [:create] + resources "tag_followings", :only => [:create, :destroy, :index] get 'tags/:name' => 'tags#show', :as => 'tag' diff --git a/spec/controllers/people_controller_spec.rb b/spec/controllers/people_controller_spec.rb index fb311c9b1..21bbcc87c 100644 --- a/spec/controllers/people_controller_spec.rb +++ b/spec/controllers/people_controller_spec.rb @@ -67,12 +67,12 @@ describe PeopleController do context 'query is a tag' do it 'goes to a tag page' do get :index, :q => '#babies' - response.should redirect_to(tag_path('babies', :q => '#babies')) + response.should redirect_to(tag_path('babies')) end it 'removes dots from the query' do get :index, :q => '#babi.es' - response.should redirect_to(tag_path('babies', :q => '#babi.es')) + response.should redirect_to(tag_path('babies')) end it 'stay on the page if you search for the empty hash' do diff --git a/spec/javascripts/app/collections/tag_following_collection_spec.js b/spec/javascripts/app/collections/tag_following_collection_spec.js new file mode 100644 index 000000000..6567a3b0a --- /dev/null +++ b/spec/javascripts/app/collections/tag_following_collection_spec.js @@ -0,0 +1,13 @@ +describe("app.collections.TagFollowings", function(){ + beforeEach(function(){ + this.collection = new app.collections.TagFollowings(); + }) + + describe("create", function(){ + it("should not allow duplicates", function(){ + this.collection.create({"name":"name"}) + this.collection.create({"name":"name"}) + expect(this.collection.length).toBe(1) + }) + }) +}) diff --git a/spec/javascripts/app/views/tag_following_action_view_spec.js b/spec/javascripts/app/views/tag_following_action_view_spec.js new file mode 100644 index 000000000..f82d60bb3 --- /dev/null +++ b/spec/javascripts/app/views/tag_following_action_view_spec.js @@ -0,0 +1,50 @@ +describe("app.views.TagFollowingAction", function(){ + beforeEach(function(){ + app.tagFollowings = new app.collections.TagFollowings(); + this.tagName = "test_tag"; + this.view = new app.views.TagFollowingAction({tagName : this.tagName}) + }) + + describe("render", function(){ + it("shows the output of followString", function(){ + spyOn(this.view, "tag_is_followed").andReturn(false) + spyOn(this.view, "followString").andReturn("a_follow_string") + expect(this.view.render().$('input').val()).toMatch(/^a_follow_string$/) + }) + + it("should have the extra classes if the tag is followed", function(){ + spyOn(this.view, "tag_is_followed").andReturn(true) + expect(this.view.render().$('input').hasClass("red_on_hover")).toBe(true) + expect(this.view.render().$('input').hasClass("in_aspects")).toBe(true) + }) + }) + + describe("tagAction", function(){ + it("toggles the tagFollowed from followed to unfollowed", function(){ + // first set the tag to followed + var origModel = this.view.model; + this.view.model.set("id", 3); + + expect(this.view.tag_is_followed()).toBe(true); + spyOn(this.view.model, "destroy").andCallFake(_.bind(function(){ + // model.destroy leads to collection.remove, which is bound to getTagFollowing + this.view.getTagFollowing(); + }, this) ) + this.view.tagAction(); + expect(origModel.destroy).toHaveBeenCalled() + + expect(this.view.tag_is_followed()).toBe(false); + }) + + + it("toggles the tagFollowed from unfollowed to followed", function(){ + expect(this.view.tag_is_followed()).toBe(false); + spyOn(app.tagFollowings, "create").andCallFake(function(model){ + // 'save' the model by giving it an id + model.set("id", 3) + }) + this.view.tagAction(); + expect(this.view.tag_is_followed()).toBe(true); + }) + }) +})