diff --git a/app/assets/javascripts/relationships.coffee b/app/assets/javascripts/relationships.coffee new file mode 100644 index 0000000000000000000000000000000000000000..24f83d18bbd38c24c4f7c3c2fc360cd68e857a2a --- /dev/null +++ b/app/assets/javascripts/relationships.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/stylesheets/custom.scss b/app/assets/stylesheets/custom.scss index 8a48231ec731e52f8b29ea89b2d1a4a37b6efbaa..83536672a299f270fe15c90bc8436a6a7e3c9676 100644 --- a/app/assets/stylesheets/custom.scss +++ b/app/assets/stylesheets/custom.scss @@ -150,6 +150,44 @@ aside { margin-right: 15px; } +.stats { + overflow: auto; + margin-top: 0; + padding: 0; + a { + float: left; + padding: 0 10px; + border-left: 1px solid $gray-lighter; + 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; + } + a { + padding: 0; + } +} + +.users.follow { + padding: 0; +} + /* forms */ input, textarea, select, .uneditable-input { diff --git a/app/assets/stylesheets/relationships.scss b/app/assets/stylesheets/relationships.scss new file mode 100644 index 0000000000000000000000000000000000000000..ca5c640489227bc717f3cd00e8fac8fe4c321a40 --- /dev/null +++ b/app/assets/stylesheets/relationships.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Relationships controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..e9dbff98766071045f36e005060c139bd63f5c88 --- /dev/null +++ b/app/controllers/relationships_controller.rb @@ -0,0 +1,21 @@ +class RelationshipsController < ApplicationController + before_action :logged_in_user + + def create + @user = User.find(params[: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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 70dc406446b95ca44af51353d63261e4ba2bf1c0..a85da675a69a3cb2777da423bbe0b3306994b9bc 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,5 +1,6 @@ class UsersController < ApplicationController - before_action :logged_in_user, only: [:index, :edit, :update, :destroy] + before_action :logged_in_user, only: [:index, :edit, :update, :destroy, + :following, :followers] before_action :correct_user, only: [:edit, :update] before_action :admin_user, only: :destroy @@ -48,6 +49,20 @@ class UsersController < ApplicationController redirect_to users_url end + def following + @title = "Following" + @user = User.find(params[:id]) + @users = @user.following.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/helpers/relationships_helper.rb b/app/helpers/relationships_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..3b96a9c022f134dc01c3b3dc085173ee6c95db72 --- /dev/null +++ b/app/helpers/relationships_helper.rb @@ -0,0 +1,2 @@ +module RelationshipsHelper +end diff --git a/app/models/relationship.rb b/app/models/relationship.rb new file mode 100644 index 0000000000000000000000000000000000000000..027ce413bd8861db4eb56a721500b56a608531c7 --- /dev/null +++ b/app/models/relationship.rb @@ -0,0 +1,6 @@ +class Relationship < ApplicationRecord + 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 da2bd215f1a309c04fb5747d1eb74f4341049194..88ac923245c3696cbebba1d861ec377bdaa05c43 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,13 @@ class User < ApplicationRecord has_many :microposts, dependent: :destroy + has_many :active_relationships, class_name: "Relationship", + foreign_key: "follower_id", + dependent: :destroy + has_many :passive_relationships, class_name: "Relationship", + foreign_key: "followed_id", + dependent: :destroy + has_many :following, through: :active_relationships, source: :followed + has_many :followers, through: :passive_relationships, source: :follower attr_accessor :remember_token, :activation_token, :activation_token, :reset_token before_save :downcase_email @@ -60,7 +68,20 @@ class User < ApplicationRecord end def feed - Micropost.where("user_id = ?", id) + following_ids = "SELECT followed_id FROM relationships WHERE follower_id = :user_id" + Micropost.where("user_id IN (#{following_ids}) OR user_id = :user_id", user_id: id) + end + + def follow(other_user) + active_relationships.create(followed_id: other_user.id) + end + + def unfollow(other_user) + active_relationships.find_by(followed_id: other_user.id).destroy + end + + def following?(other_user) + following.include?(other_user) end private diff --git a/app/views/relationships/create.js.erb b/app/views/relationships/create.js.erb new file mode 100644 index 0000000000000000000000000000000000000000..b9f9f7aefbf3701ae7595ec377ebd6c9700249ff --- /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 %>'); diff --git a/app/views/relationships/destroy.js.erb b/app/views/relationships/destroy.js.erb new file mode 100644 index 0000000000000000000000000000000000000000..3c45f4d79bc285e223cb8cb5d64f64fcddc1493f --- /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 %>'); diff --git a/app/views/shared/_stats.html.erb b/app/views/shared/_stats.html.erb new file mode 100644 index 0000000000000000000000000000000000000000..d60c3f4f05df6cdea6644d031049121ebfd1fc52 --- /dev/null +++ b/app/views/shared/_stats.html.erb @@ -0,0 +1,15 @@ +<% @user ||= current_user %> +
+ + + <%= @user.following.count %> + + following + + + + <%= @user.followers.count %> + + followers + +
diff --git a/app/views/static_pages/home.html.erb b/app/views/static_pages/home.html.erb index 63d50b959b57f88b215b779315880925ee6f8677..804b9b136338ea24007c4e688211635c34165389 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 0000000000000000000000000000000000000000..bcd4859b6bb079c73d0e17d16a21aa1cb51e93a8 --- /dev/null +++ b/app/views/users/_follow.html.erb @@ -0,0 +1,4 @@ +<%= form_for(current_user.active_relationships.build, remote: true) do |f| %> +
<%= hidden_field_tag :followed_id, @user.id %>
+ <%= f.submit 'Follow', class: "btn btn-primary" %> +<% end %> diff --git a/app/views/users/_follow_form.html.erb b/app/views/users/_follow_form.html.erb new file mode 100644 index 0000000000000000000000000000000000000000..0b17f7bca11f264d714b19126479cfa53d114a9d --- /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 %> diff --git a/app/views/users/_unfollow.html.erb b/app/views/users/_unfollow.html.erb new file mode 100644 index 0000000000000000000000000000000000000000..8ecf1f8016df4c33ae7f5684e09619699a6661ef --- /dev/null +++ b/app/views/users/_unfollow.html.erb @@ -0,0 +1,5 @@ +<%= form_for(current_user.active_relationships.find_by(followed_id: @user.id), + html: { method: :delete }, + remote: true) do |f| %> + <%= f.submit 'Unfollow', class: "btn" %> +<% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 9065d9a113fa0b875d40bd447e9e22ea98639b3d..27c2cae2a0b7ceebb60e2291d4062133f7fbb999 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -7,8 +7,12 @@ <%= @user.name %> +
+ <%= render 'shared/stats' %> +
+ <%= render 'follow_form' if logged_in? %> <% if @user.microposts.any? %>

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

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

    <%= @title %>

    + <% if @users.any? %> + + <%= will_paginate %> + <% end %> +
    +
    diff --git a/config/application.rb b/config/application.rb index 4b92804e0cec1660b3d0262d91503085b9991214..c9e7a2ea7b358037b1e5b932d126b63984557204 100644 --- a/config/application.rb +++ b/config/application.rb @@ -11,5 +11,6 @@ module SampleApp # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. + config.action_view.embed_authenticity_token_in_remote_forms = true end end diff --git a/config/routes.rb b/config/routes.rb index 3992b3c13a266cd21ae608edc617432171a45ee6..231cf4dd8d4bb09dec5a3cf225ef6f20101c0c6a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,8 +14,13 @@ Rails.application.routes.draw do get '/login', to: 'sessions#new' post '/login', to: 'sessions#create' delete '/logout', to: 'sessions#destroy' - resources :users + resources :users do + member do + get :following, :followers + end + end resources :account_activations, only: [:edit] resources :password_resets, only: [:new, :create, :edit, :update] resources :microposts, only: [:create, :destroy] + resources :relationships, only: [:create, :destroy] end diff --git a/db/migrate/20170303152632_create_relationships.rb b/db/migrate/20170303152632_create_relationships.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f0648f313efb6d7964e1f4a785c4bd54b24daa5 --- /dev/null +++ b/db/migrate/20170303152632_create_relationships.rb @@ -0,0 +1,13 @@ +class CreateRelationships < ActiveRecord::Migration[5.0] + 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 e0cb80dbb09cd60058b35ad867cc4d0535970910..bc1da47efd0df4ee4a82a563d56305e32de95e78 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170302144658) do +ActiveRecord::Schema.define(version: 20170303152632) do create_table "microposts", force: :cascade do |t| t.text "content" @@ -22,6 +22,16 @@ ActiveRecord::Schema.define(version: 20170302144658) do t.index ["user_id"], name: "index_microposts_on_user_id" end + create_table "relationships", force: :cascade do |t| + t.integer "follower_id" + t.integer "followed_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["followed_id"], name: "index_relationships_on_followed_id" + t.index ["follower_id", "followed_id"], name: "index_relationships_on_follower_id_and_followed_id", unique: true + t.index ["follower_id"], name: "index_relationships_on_follower_id" + end + create_table "users", force: :cascade do |t| t.string "name" t.string "email" diff --git a/db/seeds.rb b/db/seeds.rb index c13912411b6b296aa8fd1a56d2065b2b6568fcd4..2db0ecbf363a6b7e9e78b28de2ade2570f0fc613 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,11 +1,4 @@ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). -# -# Examples: -# -# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) -# Character.create(name: 'Luke', movie: movies.first) - +# User User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", @@ -26,8 +19,17 @@ User.create!(name: "Example User", activated_at: Time.zone.now) end +# Microposts users = User.order(:created_at).take(6) 50.times do content = Faker::Lorem.sentence(5) users.each { |user| user.microposts.create!(content: content) } end + +# Following relationships +users = User.all +user = users.first +following = users[2...50] +followers = users[3...40] +following.each { |followed| user.follow(followed) } +followers.each { |follower| follower.follow(user) } diff --git a/test/controllers/relationships_controller_test.rb b/test/controllers/relationships_controller_test.rb new file mode 100644 index 0000000000000000000000000000000000000000..4db36216c6ef5baec8064af97a2b27e5fcb24015 --- /dev/null +++ b/test/controllers/relationships_controller_test.rb @@ -0,0 +1,17 @@ +require 'test_helper' + +class RelationshipsControllerTest < ActionDispatch::IntegrationTest + test "create should require logged-in user" do + assert_no_difference 'Relationship.count' do + post relationships_path + end + assert_redirected_to login_url + end + + test "destroy should require logged-in user" do + assert_no_difference 'Relationship.count' do + delete relationship_path(relationships(:one)) + end + assert_redirected_to login_url + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index b3e2762d839a3db5eab85f71b7618344d32bc83f..67339377871336ae5bbd51d4611679bfb27198fd 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -47,4 +47,14 @@ class UsersControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to root_url end + + test "should redirect following when not logged in" do + get following_user_path(@user) + assert_redirected_to login_url + end + + test "should redirect followers when not logged in" do + get followers_user_path(@user) + assert_redirected_to login_url + end end diff --git a/test/fixtures/relationships.yml b/test/fixtures/relationships.yml new file mode 100644 index 0000000000000000000000000000000000000000..b567e7152c10b1d622cd2e101ca36a2f027c84dd --- /dev/null +++ b/test/fixtures/relationships.yml @@ -0,0 +1,15 @@ +one: + follower: michael + followed: lana + +two: + follower: michael + followed: malory + +three: + follower: lana + followed: michael + +four: + follower: archer + followed: michael diff --git a/test/integration/following_test.rb b/test/integration/following_test.rb new file mode 100644 index 0000000000000000000000000000000000000000..5daa8c08b03965a6227b4255bbe70eca20470ae5 --- /dev/null +++ b/test/integration/following_test.rb @@ -0,0 +1,55 @@ +require 'test_helper' + +class FollowingTest < ActionDispatch::IntegrationTest + def setup + @user = users(:michael) + @other = users(:archer) + log_in_as(@user) + end + + test "following page" do + get following_user_path(@user) + assert_not @user.following.empty? + assert_match @user.following.count.to_s, response.body + @user.following.each do |user| + assert_select "a[href=?]", user_path(user) + end + end + + test "followers page" do + get followers_user_path(@user) + assert_not @user.followers.empty? + assert_match @user.followers.count.to_s, response.body + @user.followers.each do |user| + assert_select "a[href=?]", user_path(user) + end + end + + test "should follow a user the standard way" do + assert_difference '@user.following.count', 1 do + post relationships_path, params: { followed_id: @other.id } + end + end + + test "should follow a user with Ajax" do + assert_difference '@user.following.count', 1 do + post relationships_path, xhr: true, params: { followed_id: @other.id } + end + end + + test "should unfollow a user the standard way" do + @user.follow(@other) + relationship = @user.active_relationships.find_by(followed_id: @other.id) + assert_difference '@user.following.count', -1 do + delete relationship_path(relationship) + end + end + + test "should unfollow a user with Ajax" do + @user.follow(@other) + relationship = @user.active_relationships.find_by(followed_id: @other.id) + assert_difference '@user.following.count', -1 do + delete relationship_path(relationship), xhr: true + end + end +end diff --git a/test/models/relationship_test.rb b/test/models/relationship_test.rb new file mode 100644 index 0000000000000000000000000000000000000000..bbd5f22fbbe1ebb09bf583f4a40f09fe0e7ebb92 --- /dev/null +++ b/test/models/relationship_test.rb @@ -0,0 +1,22 @@ +require 'test_helper' + +class RelationshipTest < ActiveSupport::TestCase + def setup + @relationship = Relationship.new(follower_id: users(:michael).id, + followed_id: users(:archer).id) + end + + test "should be valid" do + assert @relationship.valid? + end + + test "should require a follower_id" do + @relationship.follower_id = nil + assert_not @relationship.valid? + end + + test "should require a followed_id" do + @relationship.followed_id = nil + assert_not @relationship.valid? + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 97f3ce984b0ebefceafe6b8c2827c5878da24380..0204fe4891a349bd2c0b85271ffb499fcfb6dd44 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -83,4 +83,32 @@ class UserTest < ActiveSupport::TestCase @user.destroy end end + + test "should follow and unfollow a user" do + michael = users(:michael) + archer = users(:archer) + assert_not michael.following?(archer) + michael.follow(archer) + assert michael.following?(archer) + assert archer.followers.include?(michael) + michael.unfollow(archer) + assert_not michael.following?(archer) + end + + test "feed should have the right posts" do + michael = users(:michael) + archer = users(:archer) + lana = users(:lana) + lana.microposts.each do |post_following| + assert michael.feed.include?(post_following) + end + + michael.microposts.each do |post_self| + assert michael.feed.include?(post_self) + end + + archer.microposts.each do |post_unfollowed| + assert_not michael.feed.include?(post_unfollowed) + end + end end