diff --git a/app/assets/stylesheets/custom.css.scss b/app/assets/stylesheets/custom.css.scss index 11290ad..a16eab4 100644 --- a/app/assets/stylesheets/custom.css.scss +++ b/app/assets/stylesheets/custom.css.scss @@ -145,6 +145,35 @@ aside { margin-right: 10px; } +.stats { + overflow: auto; + a { + float: left; + padding: 0 10px; + border-left: 1px solid $grayLighter; + color: gray; + &:first-child { + padding-left: 0; + border: 0; + } + &:hover { + text-decoration: none; + color: $blue; + } + } + strong { + display: block; + } +} + +.user_avatars { + overflow: auto; + margin-top: 10px; + .gravatar { + margin: 1px 1px; + } +} + /* forms */ input, textarea, select, .uneditable-input { diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb new file mode 100644 index 0000000..fbadfec --- /dev/null +++ b/app/controllers/relationships_controller.rb @@ -0,0 +1,21 @@ +class RelationshipsController < ApplicationController + before_action :signed_in_user + + def create + @user = User.find(params[:relationship][:followed_id]) + current_user.follow!(@user) + respond_to do |format| + format.html { redirect_to @user } + format.js + end + end + + def destroy + @user = Relationship.find(params[:id]).followed + current_user.unfollow!(@user) + respond_to do |format| + format.html { redirect_to @user } + format.js + end + end +end \ No newline at end of file diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7f81eff..7db17c8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,5 +1,6 @@ class UsersController < ApplicationController - before_action :signed_in_user, only: [:index, :edit, :update, :destroy] + before_action :signed_in_user, + only: [:index, :edit, :update, :destroy, :following, :followers] before_action :correct_user, only: [:edit, :update] before_action :admin_user, only: :destroy def index @@ -40,6 +41,21 @@ def destroy flash[:success] = "User deleted." redirect_to users_url end + + def following + @title = "Following" + @user = User.find(params[:id]) + @users = @user.followed_users.paginate(page: params[:page]) + render 'show_follow' + end + + def followers + @title = "Followers" + @user = User.find(params[:id]) + @users = @user.followers.paginate(page: params[:page]) + render 'show_follow' + end + private def user_params diff --git a/app/models/micropost.rb b/app/models/micropost.rb index 70493fe..fd3a147 100644 --- a/app/models/micropost.rb +++ b/app/models/micropost.rb @@ -3,4 +3,11 @@ class Micropost < ActiveRecord::Base default_scope -> { order('created_at DESC') } validates :content, presence: true, length: { maximum: 140 } validates :user_id, presence: true + + def self.from_users_followed_by(user) + followed_user_ids = "SELECT followed_id FROM relationships + WHERE follower_id = :user_id" + where("user_id IN (#{followed_user_ids}) OR user_id = :user_id", + user_id: user.id) + end end \ No newline at end of file diff --git a/app/models/relationship.rb b/app/models/relationship.rb new file mode 100644 index 0000000..deb76a8 --- /dev/null +++ b/app/models/relationship.rb @@ -0,0 +1,6 @@ +class Relationship < ActiveRecord::Base + belongs_to :follower, class_name: "User" + belongs_to :followed, class_name: "User" + validates :follower_id, presence: true + validates :followed_id, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 3dede30..bc29e56 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,12 @@ class User < ActiveRecord::Base has_many :microposts, dependent: :destroy + has_many :relationships, foreign_key: "follower_id", dependent: :destroy + has_many :followed_users, through: :relationships, source: :followed + has_many :reverse_relationships, foreign_key: "followed_id", + class_name: "Relationship", + dependent: :destroy + has_many :followers, through: :reverse_relationships, source: :follower + before_save { self.email = email.downcase } before_create :create_remember_token validates :name, presence: true, length: { maximum: 50 } @@ -19,8 +26,19 @@ def User.encrypt(token) end def feed - # This is preliminary. See "Following users" for the full implementation. - Micropost.where("user_id = ?", id) + Micropost.from_users_followed_by(self) + end + + def following?(other_user) + self.relationships.find_by(followed_id: other_user.id) + end + + def follow!(other_user) + self.relationships.create!(followed_id: other_user.id) + end + + def unfollow!(other_user) + self.relationships.find_by(followed_id: other_user.id).destroy! end private diff --git a/app/views/relationships/create.js.erb b/app/views/relationships/create.js.erb new file mode 100644 index 0000000..d5509e8 --- /dev/null +++ b/app/views/relationships/create.js.erb @@ -0,0 +1,2 @@ +$("#follow_form").html("<%= escape_javascript(render('users/unfollow')) %>") +$("#followers").html('<%= @user.followers.count %>') \ No newline at end of file diff --git a/app/views/relationships/destroy.js.erb b/app/views/relationships/destroy.js.erb new file mode 100644 index 0000000..fa23b16 --- /dev/null +++ b/app/views/relationships/destroy.js.erb @@ -0,0 +1,2 @@ +$("#follow_form").html("<%= escape_javascript(render('users/follow')) %>") +$("#followers").html('<%= @user.followers.count %>') \ No newline at end of file diff --git a/app/views/shared/_stats.html.erb b/app/views/shared/_stats.html.erb new file mode 100644 index 0000000..e68c491 --- /dev/null +++ b/app/views/shared/_stats.html.erb @@ -0,0 +1,15 @@ +<% @user ||= current_user %> +
+ + + <%= @user.followed_users.count %> + + following + + + + <%= @user.followers.count %> + + followers + +
\ No newline at end of file diff --git a/app/views/static_pages/home.html.erb b/app/views/static_pages/home.html.erb index eaf406c..9fa38d7 100644 --- a/app/views/static_pages/home.html.erb +++ b/app/views/static_pages/home.html.erb @@ -4,6 +4,9 @@
<%= render 'shared/user_info' %>
+
+ <%= render 'shared/stats' %> +
<%= render 'shared/micropost_form' %>
diff --git a/app/views/users/_follow.html.erb b/app/views/users/_follow.html.erb new file mode 100644 index 0000000..021ed24 --- /dev/null +++ b/app/views/users/_follow.html.erb @@ -0,0 +1,4 @@ +<%= form_for(current_user.relationships.build(followed_id: @user.id)) do |f| %> +
<%= f.hidden_field :followed_id %>
+ <%= f.submit "Follow", class: "btn btn-large btn-primary" %> +<% end %> \ No newline at end of file diff --git a/app/views/users/_follow_form.html.erb b/app/views/users/_follow_form.html.erb new file mode 100644 index 0000000..3bc7433 --- /dev/null +++ b/app/views/users/_follow_form.html.erb @@ -0,0 +1,9 @@ +<% unless current_user?(@user) %> +
+ <% if current_user.following?(@user) %> + <%= render 'unfollow' %> + <% else %> + <%= render 'follow' %> + <% end %> +
+<% end %> \ No newline at end of file diff --git a/app/views/users/_unfollow.html.erb b/app/views/users/_unfollow.html.erb new file mode 100644 index 0000000..7773024 --- /dev/null +++ b/app/views/users/_unfollow.html.erb @@ -0,0 +1,4 @@ +<%= form_for(current_user.relationships.find_by(followed_id: @user), + html: { method: :delete }) do |f| %> + <%= f.submit "Unfollow", class: "btn btn-large" %> +<% end %> \ No newline at end of file diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 7b2c264..26f2074 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -7,9 +7,13 @@ <%= @user.name %> +
+ <%= render 'shared/stats' %> +
<% if @user.microposts.any? %> + <%= render 'follow_form' if signed_in? %>

Microposts (<%= @user.microposts.count %>)

    <%= render @microposts %> diff --git a/app/views/users/show_follow.html.erb b/app/views/users/show_follow.html.erb new file mode 100644 index 0000000..c60e3d6 --- /dev/null +++ b/app/views/users/show_follow.html.erb @@ -0,0 +1,30 @@ +<% provide(:title, @title) %> +
    + +
    +

    <%= @title %>

    + <% if @users.any? %> +
      + <%= render @users %> +
    + <%= will_paginate %> + <% end %> +
    +
    \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 60f7c41..a4891f6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,12 @@ SampleApp::Application.routes.draw do - resources :users + resources :users do + member do + get :following, :followers + end + end resources :sessions, only: [:new, :create, :destroy] resources :microposts, only: [:create, :destroy] + resources :relationships, only: [:create, :destroy] root 'static_pages#home' match '/signup', to: 'users#new', via: 'get' match '/signin', to: 'sessions#new', via: 'get' diff --git a/db/migrate/20131230170729_create_relationships.rb b/db/migrate/20131230170729_create_relationships.rb new file mode 100644 index 0000000..fe36b5a --- /dev/null +++ b/db/migrate/20131230170729_create_relationships.rb @@ -0,0 +1,13 @@ +class CreateRelationships < ActiveRecord::Migration + def change + create_table :relationships do |t| + t.integer :follower_id + t.integer :followed_id + + t.timestamps + end + add_index :relationships, :follower_id + add_index :relationships, :followed_id + add_index :relationships, [:follower_id, :followed_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index ef92f40..3dd8102 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20131227202627) do +ActiveRecord::Schema.define(version: 20131230170729) do create_table "microposts", force: true do |t| t.string "content" @@ -22,6 +22,17 @@ add_index "microposts", ["user_id", "created_at"], name: "index_microposts_on_user_id_and_created_at" + create_table "relationships", force: true do |t| + t.integer "follower_id" + t.integer "followed_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "relationships", ["followed_id"], name: "index_relationships_on_followed_id" + add_index "relationships", ["follower_id", "followed_id"], name: "index_relationships_on_follower_id_and_followed_id", unique: true + add_index "relationships", ["follower_id"], name: "index_relationships_on_follower_id" + create_table "users", force: true do |t| t.string "name" t.string "email" diff --git a/lib/tasks/sample_data.rake b/lib/tasks/sample_data.rake index a5ce24d..9263350 100644 --- a/lib/tasks/sample_data.rake +++ b/lib/tasks/sample_data.rake @@ -1,28 +1,42 @@ namespace :db do desc "Fill database with sample data" task populate: :environment do - admin = User.create!(name: "Behdad Baniani", - email: "baniani@gmail.com", - password: "foobar", - password_confirmation: "foobar", - admin: true) - User.create!(name: "Example User", - email: "example@railstutorial.org", - password: "foobar", - password_confirmation: "foobar") - 99.times do |n| - name = Faker::Name.name - email = "example-#{n+1}@railstutorial.org" - password = "password" - User.create!(name: name, - email: email, - password: password, - password_confirmation: password) - end - users = User.all(limit: 6) - 50.times do - content = Faker::Lorem.sentence(5) - users.each { |user| user.microposts.create!(content: content) } - end + make_users + make_microposts + make_relationships end +end + +def make_users + admin = User.create!(name: "Example User", + email: "example@railstutorial.org", + password: "foobar", + password_confirmation: "foobar", + admin: true) + 99.times do |n| + name = Faker::Name.name + email = "example-#{n+1}@railstutorial.org" + password = "password" + User.create!(name: name, + email: email, + password: password, + password_confirmation: password) + end +end + +def make_microposts + users = User.all(limit: 6) + 50.times do + content = Faker::Lorem.sentence(5) + users.each { |user| user.microposts.create!(content: content) } + end +end + +def make_relationships + users = User.all + user = users.first + followed_users = users[2..50] + followers = users[3..40] + followed_users.each { |followed| user.follow!(followed) } + followers.each { |follower| follower.follow!(user) } end \ No newline at end of file diff --git a/spec/controllers/relationships_controller_spec.rb b/spec/controllers/relationships_controller_spec.rb new file mode 100644 index 0000000..e08d550 --- /dev/null +++ b/spec/controllers/relationships_controller_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe RelationshipsController do + + let(:user) { FactoryGirl.create(:user) } + let(:other_user) { FactoryGirl.create(:user) } + + before { sign_in user, no_capybara: true } + + describe "creating a relationship with Ajax" do + + it "should increment the Relationship count" do + expect do + xhr :post, :create, relationship: { followed_id: other_user.id } + end.to change(Relationship, :count).by(1) + end + + it "should respond with success" do + xhr :post, :create, relationship: { followed_id: other_user.id } + expect(response).to be_success + end + end + + describe "destroying a relationship with Ajax" do + + before { user.follow!(other_user) } + let(:relationship) { user.relationships.find_by(followed_id: other_user) } + + it "should decrement the Relationship count" do + expect do + xhr :delete, :destroy, id: relationship.id + end.to change(Relationship, :count).by(-1) + end + + it "should respond with success" do + xhr :delete, :destroy, id: relationship.id + expect(response).to be_success + end + end +end \ No newline at end of file diff --git a/spec/models/relationships_spec.rb b/spec/models/relationships_spec.rb new file mode 100644 index 0000000..00f9d9a --- /dev/null +++ b/spec/models/relationships_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe Relationships do + + let(:follower) { FactoryGirl.create(:user) } + let(:followed) { FactoryGirl.create(:user) } + let(:relationship) { follower.relationships.build(followed_id: followed.id) } + + subject { relationship } + + it { should be_valid } + + describe "follower methods" do + it { should respond_to(:follower) } + it { should respond_to(:followed) } + its(:follower) { should eq follower } + its(:followed) { should eq followed } + end + + describe "when followed id is not present" do + before { relationship.followed_id = nil } + it { should_not be_valid } + end + + describe "when follower id is not present" do + before { relationship.follower_id = nil } + it { should_not be_valid } + end +end \ No newline at end of file diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2b63187..7312a68 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -17,6 +17,10 @@ it { should respond_to(:admin) } it { should respond_to(:microposts) } it { should respond_to(:feed) } + it { should respond_to(:relationships) } + it { should respond_to(:followed_users) } + it { should respond_to(:reverse_relationships) } + it { should respond_to(:followers) } it { should be_valid } it { should_not be_admin } @@ -148,5 +152,48 @@ its(:feed) { should include(older_micropost) } its(:feed) { should_not include(unfollowed_post) } end + + describe "status" do + let(:unfollowed_post) do + FactoryGirl.create(:micropost, user: FactoryGirl.create(:user)) + end + let(:followed_user) { FactoryGirl.create(:user) } + + before do + @user.follow!(followed_user) + 3.times { followed_user.microposts.create!(content: "Lorem ipsum") } + end + + its(:feed) { should include(newer_micropost) } + its(:feed) { should include(older_micropost) } + its(:feed) { should_not include(unfollowed_post) } + its(:feed) do + followed_user.microposts.each do |micropost| + should include(micropost) + end + end + end + end + describe "following" do + let(:other_user) { FactoryGirl.create(:user) } + before do + @user.save + @user.follow!(other_user) + end + + it { should be_following(other_user) } + its(:followed_users) { should include(other_user) } + + describe "followed user" do + subject { other_user } + its(:followers) { should include(@user) } + end + + describe "and unfollowing" do + before { @user.unfollow!(other_user) } + + it { should_not be_following(other_user) } + its(:followed_users) { should_not include(other_user) } + end end end \ No newline at end of file diff --git a/spec/requests/authentication_pages_spec.rb b/spec/requests/authentication_pages_spec.rb index 53e174e..690e53c 100644 --- a/spec/requests/authentication_pages_spec.rb +++ b/spec/requests/authentication_pages_spec.rb @@ -58,6 +58,18 @@ expect(page).to have_title('Edit user') end end + + describe "in the Relationships controller" do + describe "submitting to the create action" do + before { post relationships_path } + specify { expect(response).to redirect_to(signin_path) } + end + + describe "submitting to the destroy action" do + before { delete relationship_path(1) } + specify { expect(response).to redirect_to(signin_path) } + end + end end describe "in the Microposts controller" do @@ -89,6 +101,16 @@ before { visit users_path } it { should have_title('Sign in') } end + + describe "visiting the following page" do + before { visit following_user_path(user) } + it { should have_title('Sign in') } + end + + describe "visiting the followers page" do + before { visit followers_user_path(user) } + it { should have_title('Sign in') } + end end end describe "as wrong user" do diff --git a/spec/requests/static_pages_spec.rb b/spec/requests/static_pages_spec.rb index 4e6df49..56bf8aa 100644 --- a/spec/requests/static_pages_spec.rb +++ b/spec/requests/static_pages_spec.rb @@ -14,8 +14,8 @@ describe "for signed-in users" do let(:user) { FactoryGirl.create(:user) } before do - FactoryGirl.create(:micropost, user: user, content: "Lorem ipsum") - FactoryGirl.create(:micropost, user: user, content: "Dolor sit amet") + FactoryGirl.create(:micropost, user: user, content: "Lorem") + FactoryGirl.create(:micropost, user: user, content: "Ipsum") sign_in user visit root_path end @@ -25,7 +25,19 @@ expect(page).to have_selector("li##{item.id}", text: item.content) end end + + describe "follower/following counts" do + let(:other_user) { FactoryGirl.create(:user) } + before do + other_user.follow!(user) + visit root_path + end + + it { should have_link("0 following", href: following_user_path(user)) } + it { should have_link("1 followers", href: followers_user_path(user)) } + end end + end describe "Help page" do diff --git a/spec/requests/user_pages_spec.rb b/spec/requests/user_pages_spec.rb index ffec756..9a3aa9c 100644 --- a/spec/requests/user_pages_spec.rb +++ b/spec/requests/user_pages_spec.rb @@ -72,6 +72,55 @@ it { should have_content(m2.content) } it { should have_content(user.microposts.count) } end + describe "follow/unfollow buttons" do + let(:other_user) { FactoryGirl.create(:user) } + before { sign_in user } + + describe "following a user" do + before { visit user_path(other_user) } + + it "should increment the followed user count" do + expect do + click_button "Follow" + end.to change(user.followed_users, :count).by(1) + end + + it "should increment the other user's followers count" do + expect do + click_button "Follow" + end.to change(other_user.followers, :count).by(1) + end + + describe "toggling the button" do + before { click_button "Follow" } + it { should have_xpath("//input[@value='Unfollow']") } + end + end + + describe "unfollowing a user" do + before do + user.follow!(other_user) + visit user_path(other_user) + end + + it "should decrement the followed user count" do + expect do + click_button "Unfollow" + end.to change(user.followed_users, :count).by(-1) + end + + it "should decrement the other user's followers count" do + expect do + click_button "Unfollow" + end.to change(other_user.followers, :count).by(-1) + end + + describe "toggling the button" do + before { click_button "Unfollow" } + it { should have_xpath("//input[@value='Follow']") } + end + end + end end describe "signup page" do @@ -149,4 +198,33 @@ specify { expect(user.reload.email).to eq new_email } end end + + describe "following/followers" do + let(:user) { FactoryGirl.create(:user) } + let(:other_user) { FactoryGirl.create(:user) } + before { user.follow!(other_user) } + + describe "followed users" do + before do + sign_in user + visit following_user_path(user) + end + + it { should have_title(full_title('Following')) } + it { should have_selector('h3', text: 'Following') } + it { should have_link(other_user.name, href: user_path(other_user)) } + end + + describe "followers" do + before do + sign_in other_user + visit followers_user_path(other_user) + end + + it { should have_title(full_title('Followers')) } + it { should have_selector('h3', text: 'Followers') } + it { should have_link(user.name, href: user_path(user)) } + end + end + end \ No newline at end of file