diff --git a/app/controllers/follows_controller.rb b/app/controllers/follows_controller.rb new file mode 100644 index 000000000..32fea6813 --- /dev/null +++ b/app/controllers/follows_controller.rb @@ -0,0 +1,28 @@ +class FollowsController < ApplicationController + before_action :authenticate_user! + load_and_authorize_resource + + def create + @followable = find_followable + @follow = Follow.create(user: current_user, followable: @followable) + render :refresh_follow_button + end + + def destroy + @follow = Follow.find(params[:id]) + @followable = @follow.followable + @follow.destroy + render :refresh_follow_button + end + + private + + def find_followable + params.each do |name, value| + if name =~ /(.+)_id$/ + return $1.classify.constantize.find(value) + end + end + nil + end +end diff --git a/app/helpers/follows_helper.rb b/app/helpers/follows_helper.rb new file mode 100644 index 000000000..19c8f9ca6 --- /dev/null +++ b/app/helpers/follows_helper.rb @@ -0,0 +1,28 @@ +module FollowsHelper + + def show_follow_action?(followable) + current_user && !followed?(followable) + end + + def show_unfollow_action?(followable) + current_user && followed?(followable) + end + + def follow_entity_text(entity) + t('shared.follow_entity', entity: t("activerecord.models.#{entity}.one").downcase) + end + + def unfollow_entity_text(entity) + t('shared.unfollow_entity', entity: t("activerecord.models.#{entity}.one").downcase) + end + + def entity_name(followable) + followable.class.name.downcase + end + private + + def followed?(followable) + Follow.followed?(current_user, followable) + end + +end diff --git a/app/models/abilities/common.rb b/app/models/abilities/common.rb index 2b32abd07..cf183ec53 100644 --- a/app/models/abilities/common.rb +++ b/app/models/abilities/common.rb @@ -34,6 +34,8 @@ module Abilities can [:flag, :unflag], Proposal cannot [:flag, :unflag], Proposal, author_id: user.id + can [:create, :destroy], Follow + unless user.organization? can :vote, Debate can :vote, Comment diff --git a/app/models/concerns/followable.rb b/app/models/concerns/followable.rb new file mode 100644 index 000000000..7ac30ddc4 --- /dev/null +++ b/app/models/concerns/followable.rb @@ -0,0 +1,8 @@ +module Followable + extend ActiveSupport::Concern + + included do + has_many :follows, as: :followable + end + +end diff --git a/app/models/follow.rb b/app/models/follow.rb new file mode 100644 index 000000000..571635b15 --- /dev/null +++ b/app/models/follow.rb @@ -0,0 +1,32 @@ +class Follow < ActiveRecord::Base + belongs_to :user + #TODO Rock&RoR: Check touch usage on cache system + belongs_to :followable, polymorphic: true + + validates :user_id, presence: true + validates :followable_id, presence: true + validates :followable_type, presence: true + + scope(:by_user_and_followable, lambda do |user, followable| + where(user_id: user.id, + followable_type: followable.class.to_s, + followable_id: followable.id) + end) + + # def self.follow(user, followable) + # return false if interested?(user, followable) + # create(user: user, followable: followable) + # end + # + # def self.unfollow(user, followable) + # interests = by_user_and_followable(user, followable) + # return false if interests.empty? + # interests.destroy_all + # end + # + def self.followed?(user, followable) + return false unless user + !! by_user_and_followable(user, followable).try(:first) + end + +end diff --git a/app/models/proposal.rb b/app/models/proposal.rb index 9e02c197e..054c00c4a 100644 --- a/app/models/proposal.rb +++ b/app/models/proposal.rb @@ -8,6 +8,7 @@ class Proposal < ActiveRecord::Base include Filterable include HasPublicAuthor include Graphqlable + include Followable acts_as_votable acts_as_paranoid column: :hidden_at diff --git a/app/models/user.rb b/app/models/user.rb index b41057157..fb11aacf9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -31,6 +31,7 @@ class User < ActiveRecord::Base has_many :direct_messages_sent, class_name: 'DirectMessage', foreign_key: :sender_id has_many :direct_messages_received, class_name: 'DirectMessage', foreign_key: :receiver_id has_many :legislation_answers, class_name: 'Legislation::Answer', dependent: :destroy, inverse_of: :user + has_many :follows belongs_to :geozone validates :username, presence: true, if: :username_required? diff --git a/app/views/follows/_followable_button.html.erb b/app/views/follows/_followable_button.html.erb new file mode 100644 index 000000000..2f838448e --- /dev/null +++ b/app/views/follows/_followable_button.html.erb @@ -0,0 +1,20 @@ + + <% if show_follow_action? followable %> + + <%= t('shared.follow') %> + + + <% end %> + + <% if show_unfollow_action? followable %> + <% follow = followable.follows.where(user: current_user).first %> + + <%= t('shared.unfollow') %> + + + <% end %> + diff --git a/app/views/follows/refresh_follow_button.js.erb b/app/views/follows/refresh_follow_button.js.erb new file mode 100644 index 000000000..1e8dc89f9 --- /dev/null +++ b/app/views/follows/refresh_follow_button.js.erb @@ -0,0 +1 @@ +$("#<%= dom_id(@followable) %> .js-follow").html('<%= j render("followable_button", followable: @followable) %>'); diff --git a/app/views/proposals/show.html.erb b/app/views/proposals/show.html.erb index d678214d2..c82503f27 100644 --- a/app/views/proposals/show.html.erb +++ b/app/views/proposals/show.html.erb @@ -53,6 +53,10 @@ <%= render 'proposals/flag_actions', proposal: @proposal %> +  •  + + <%= render 'follows/followable_button', followable: @proposal %> +
diff --git a/config/locales/en/general.yml b/config/locales/en/general.yml index 8c0763b32..408489fa1 100644 --- a/config/locales/en/general.yml +++ b/config/locales/en/general.yml @@ -500,6 +500,8 @@ en: check_none: None collective: Collective flag: Flag as inappropriate + follow: "Follow" + follow_entity: "Follow %{entity}" hide: Hide print: print_button: Print this info @@ -532,6 +534,8 @@ en: target_blank_html: " (link opens in new window)" you_are_in: "You are in" unflag: Unflag + unfollow: "Unfollow" + unfollow_entity: "Unfollow %{entity}" outline: debates: Debates proposals: Proposals @@ -708,4 +712,3 @@ en: invisible_captcha: sentence_for_humans: "If you are human, ignore this field" timestamp_error_message: "Sorry, that was too quick! Please resubmit." - diff --git a/config/locales/es/general.yml b/config/locales/es/general.yml index d38184647..72bed0a59 100644 --- a/config/locales/es/general.yml +++ b/config/locales/es/general.yml @@ -500,6 +500,8 @@ es: check_none: Ninguno collective: Colectivo flag: Denunciar como inapropiado + follow: "Seguir" + follow_entity: "Seguir %{entity}" hide: Ocultar print: print_button: Imprimir esta información @@ -532,6 +534,8 @@ es: target_blank_html: " (se abre en ventana nueva)" you_are_in: "Estás en" unflag: Deshacer denuncia + unfollow: Dejar de seguir + unfollow_entity: "Dejar de seguir %{entity}" outline: debates: Debates proposals: Propuestas @@ -707,4 +711,4 @@ es: user_permission_votes: Participar en las votaciones finales* invisible_captcha: sentence_for_humans: "Si eres humano, por favor ignora este campo" - timestamp_error_message: "Eso ha sido demasiado rápido. Por favor, reenvía el formulario." \ No newline at end of file + timestamp_error_message: "Eso ha sido demasiado rápido. Por favor, reenvía el formulario." diff --git a/config/routes.rb b/config/routes.rb index bdf1e22f3..ab54fd739 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -93,6 +93,8 @@ Rails.application.routes.draw do end end + resources :follows, only: [:create, :destroy] + resources :stats, only: [:index] resources :legacy_legislations, only: [:show], path: 'legislations' diff --git a/db/migrate/20170626180127_create_follows.rb b/db/migrate/20170626180127_create_follows.rb new file mode 100644 index 000000000..d4225cab8 --- /dev/null +++ b/db/migrate/20170626180127_create_follows.rb @@ -0,0 +1,12 @@ +class CreateFollows < ActiveRecord::Migration + def change + create_table :follows do |t| + t.references :user, index: true, foreign_key: true + t.references :followable, polymorphic: true, index: true + + t.timestamps null: false + end + + add_index :follows, [:user_id, :followable_type, :followable_id], name: "access_follows" + end +end diff --git a/db/schema.rb b/db/schema.rb index 0787b5ed1..0ad6a401f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -326,6 +326,18 @@ ActiveRecord::Schema.define(version: 20170704105112) do add_index "flags", ["user_id", "flaggable_type", "flaggable_id"], name: "access_inappropiate_flags", using: :btree add_index "flags", ["user_id"], name: "index_flags_on_user_id", using: :btree + create_table "follows", force: :cascade do |t| + t.integer "user_id" + t.integer "followable_id" + t.string "followable_type" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "follows", ["followable_type", "followable_id"], name: "index_follows_on_followable_type_and_followable_id", using: :btree + add_index "follows", ["user_id", "followable_type", "followable_id"], name: "access_follows", using: :btree + add_index "follows", ["user_id"], name: "index_follows_on_user_id", using: :btree + create_table "geozones", force: :cascade do |t| t.string "name" t.string "html_map_coordinates" @@ -1036,6 +1048,7 @@ ActiveRecord::Schema.define(version: 20170704105112) do add_foreign_key "failed_census_calls", "poll_officers" add_foreign_key "failed_census_calls", "users" add_foreign_key "flags", "users" + add_foreign_key "follows", "users" add_foreign_key "geozones_polls", "geozones" add_foreign_key "geozones_polls", "polls" add_foreign_key "identities", "users" diff --git a/spec/factories.rb b/spec/factories.rb index 9a3c292b3..b7caa73b4 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -166,8 +166,8 @@ FactoryGirl.define do end trait :flagged do - after :create do |debate| - Flag.flag(FactoryGirl.create(:user), debate) + after :create do |proposal| + Flag.flag(FactoryGirl.create(:user), proposal) end end @@ -349,6 +349,14 @@ FactoryGirl.define do association :user, factory: :user end + factory :follow do + association :user, factory: :user + + trait :followed_proposal do + association :followable, factory: :proposal + end + end + factory :comment do association :commentable, factory: :debate user diff --git a/spec/features/proposals_spec.rb b/spec/features/proposals_spec.rb index 8faa899f9..30bfa7faa 100644 --- a/spec/features/proposals_spec.rb +++ b/spec/features/proposals_spec.rb @@ -1223,6 +1223,73 @@ feature 'Proposals' do expect(Flag.flagged?(user, proposal)).to_not be end + feature "Follows" do + + scenario "Should not show follow button when there is no logged user" do + proposal = create(:proposal) + + visit proposal_path(proposal) + + within "#proposal_#{proposal.id}" do + expect(page).not_to have_link("Follow citizen proposal") + end + end + + scenario "Following", :js do + user = create(:user) + proposal = create(:proposal) + login_as(user) + + visit proposal_path(proposal) + within "#proposal_#{proposal.id}" do + page.find("#follow-expand-proposal-#{proposal.id}").click + page.find("#follow-proposal-#{proposal.id}").click + + expect(page).to have_css("#unfollow-expand-proposal-#{proposal.id}") + end + + expect(Follow.followed?(user, proposal)).to be + end + + scenario "Show unfollow button when user already follow this proposal" do + user = create(:user) + follow = create(:follow, :followed_proposal, user: user) + login_as(user) + + visit proposal_path(follow.followable) + + expect(page).to have_link("Unfollow citizen proposal") + end + + scenario "Unfollowing", :js do + user = create(:user) + proposal = create(:proposal) + follow = create(:follow, :followed_proposal, user: user, followable: proposal) + login_as(user) + + visit proposal_path(proposal) + within "#proposal_#{proposal.id}" do + page.find("#unfollow-expand-proposal-#{proposal.id}").click + page.find("#unfollow-proposal-#{proposal.id}").click + + expect(page).to have_css("#follow-expand-proposal-#{proposal.id}") + end + + expect(Follow.followed?(user, proposal)).not_to be + end + + scenario "Show follow button when user is not following this proposal" do + user = create(:user) + proposal = create(:proposal) + login_as(user) + + visit proposal_path(proposal) + + expect(page).to have_link("Follow citizen proposal") + end + + end + scenario 'Erased author' do user = create(:user) proposal = create(:proposal, author: user) diff --git a/spec/models/follow_spec.rb b/spec/models/follow_spec.rb new file mode 100644 index 000000000..07e891dd8 --- /dev/null +++ b/spec/models/follow_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +describe Follow do + + let(:follow) { build(:follow, :followed_proposal) } + + # it_behaves_like "has_public_author" + + it "should be valid" do + expect(follow).to be_valid + end + + it "should not be valid without an user_id" do + follow.user_id = nil + expect(follow).to_not be_valid + end + + it "should not be valid without an followable_id" do + follow.followable_id = nil + expect(follow).to_not be_valid + end + + it "should not be valid without an followable_type" do + follow.followable_type = nil + expect(follow).to_not be_valid + end + + + # describe "proposal" do + # + # let(:proposal) { create(:proposal) } + # + # describe 'create' do + # + # it 'creates a interest when there is none' do + # expect { described_class.follow(user, proposal) }.to change{ Interest.count }.by(1) + # expect(Interest.last.user).to eq(user) + # expect(Interest.last.interestable).to eq(proposal) + # end + # + # it 'does nothing if the interest already exists' do + # described_class.follow(user, proposal) + # expect(described_class.follow(user, proposal)).to eq(false) + # expect(Interest.by_user_and_interestable(user, proposal).count).to eq(1) + # end + # + # it 'increases the interest count' do + # expect { described_class.follow(user, proposal) }.to change{ proposal.reload.interests_count }.by(1) + # end + # end + # + # describe 'destroy' do + # it 'raises an error if the interest does not exist' do + # expect(described_class.unfollow(user, proposal)).to eq(false) + # end + # + # describe 'when the interest already exists' do + # before(:each) { described_class.follow(user, proposal) } + # + # it 'removes an existing interest' do + # expect { described_class.unfollow(user, proposal) }.to change{ Interest.count }.by(-1) + # end + # + # it 'decreases the interest count' do + # expect { described_class.unfollow(user, proposal) }.to change{ proposal.reload.interests_count }.by(-1) + # end + # end + # end + # + # describe '.interested?' do + # it 'returns false when the user has not flagged the proposal' do + # expect(described_class.interested?(user, proposal)).to_not be + # end + # + # it 'returns true when the user has interested the proposal' do + # described_class.follow(user, proposal) + # expect(described_class.interested?(user, proposal)).to be + # end + # end + # end + # + # describe "debate" do + # + # let(:debate) { create(:debate) } + # + # describe 'create' do + # + # it 'creates a interest when there is none' do + # expect { described_class.follow(user, debate) }.to change{ Interest.count }.by(1) + # expect(Interest.last.user).to eq(user) + # expect(Interest.last.interestable).to eq(debate) + # end + # + # it 'does nothing if the interest already exists' do + # described_class.follow(user, debate) + # expect(described_class.follow(user, debate)).to eq(false) + # expect(Interest.by_user_and_interestable(user, debate).count).to eq(1) + # end + # + # it 'increases the interest count' do + # expect { described_class.follow(user, debate) }.to change{ debate.reload.interests_count }.by(1) + # end + # end + # + # describe 'destroy' do + # it 'raises an error if the interest does not exist' do + # expect(described_class.unfollow(user, debate)).to eq(false) + # end + # + # describe 'when the interest already exists' do + # before(:each) { described_class.follow(user, debate) } + # + # it 'removes an existing interest' do + # expect { described_class.unfollow(user, debate) }.to change{ Interest.count }.by(-1) + # end + # + # it 'decreases the interest count' do + # expect { described_class.unfollow(user, debate) }.to change{ debate.reload.interests_count }.by(-1) + # end + # end + # end + # + # describe '.interested?' do + # it 'returns false when the user has not flagged the debate' do + # expect(described_class.interested?(user, debate)).to_not be + # end + # + # it 'returns true when the user has interested the debate' do + # described_class.follow(user, debate) + # expect(described_class.interested?(user, debate)).to be + # end + # end + # end +end