A web interface for following users

“The Relationship model Section” placed rather heavy demands on our data modeling skills, and it’s fine if it takes a while to soak in. In fact, one of the best ways to understand the associations is to use them in the web interface.

trainjs

You should always update trainjs for this tutorial.

In the introduction to this chapter, we saw a preview of the page flow for user following. In this section, we will implement the basic interface and following/unfollowing functionality shown in those mockups. We will also make separate pages to show the user following and followers arrays.

Sample following data

As in previous chapters, we will find it convenient to use the seed data to fill the database with sample relationships. This will allow us to design the look and feel of the web pages first, deferring the back-end functionality until later in this section.

db/seeds.js

require('trainjs').initServer();
var faker = require('faker');

var time = 1;
function createUser() {
	var name = faker.name.findName();
	var email = 'example-'+time+'@railstutorial.org';
	var password = 'password';
	User.create({
		name: name,
		email: email,
		password: password,
		password_confirmation: password,
		activated: true,
		activated_at: new Date().getTime()
	}).then(function(user_example) {
		if (time < 98) {
			if (time <= 4) {
				createMicroposts(user_example.id);
			}
			time++;
			createUser();
		} else {
			followingRelationships();
		}
	});
}

function createMicroposts(user_id) {
	for (var i = 0; i < 50; i++) {
		var content = faker.lorem.sentence();
		Micropost.create({
			content: content,
			user_id: user_id
		}).then(function() {
		});
	}	
}

function followingRelationships() {
	User.findAll().then(function(users){
		var user1 = users[0];
		for (var i = 1; i <= 50; i++) {
			user1.setFollowing(users[i]);
		}
		for (var j = 2; j <= 40; j++) {
			users[j].setFollowing(user1);
		}
	});
}

User.create({
	name:  "Example User",
	email: "[email protected]",
	password: "123456",
	password_confirmation: "123456",
	admin: true,
	activated: true,
	activated_at: new Date().getTime()
}).then(function(user1) {
	createMicroposts(user1.id);
	User.create({
		name:  "Example User",
		email: "[email protected]",
		password: "password",
		password_confirmation: "password",
		activated: true,
		activated_at: new Date().getTime()
	}).then(function(user2) {
		createMicroposts(user2.id);
		createUser();
	});
});

To execute the code, we reset and reseed the database as usual

~/sample_app $ rm -f db/development.sqlite3
~/sample_app $ sequelize db:migrate
~/sample_app $ node db/seeds.js

Stats and a follow form

Now that our sample users have both followed users and followers, we need to update the profile page and Home page to reflect this. We’ll start by making a partial to display the following and follower statistics on the profile and home pages. We’ll next add a follow/unfollow form, and then make dedicated pages for showing “following” (followed users) and “followers”.

Adding following and followers actions to the Users controller.

config/routes.js

module.exports = [
	{ get: '/static_pages/home', action: 'home' },
	{ resources: 'microposts', only: ['create', 'destroy'] },
	{ put: '/password_resets/:id/valid', action: 'valid' },
	{ post: '/sessions' },
	{ delete: '/sessions', action: 'destroy' },
	{ get: '/sessions', action: 'current_user' },
	{
		resources: 'users',
		member: [
			{ get: 'following' },
			{ get: 'followers' }
		]
	},
	{ resources: 'password_resets', only: ['create', 'update'] },
	{ resources: 'account_activations', only: ['update'] },
];

With the routes defined, we are now in a position to define the stats partial, which involves a couple of links inside a div

public/partials/shared/_stats.html

<div class="stats">
    <a href ui-sref="user_following({id: user.id})">
        <strong id="following" class="stat">{{ user.following.count }}</strong>
        following
    </a>
    <a href ui-sref="user_followers({id: user.id})">
        <strong id="followers" class="stat">{{ user.followers.count }}</strong>
        followers
    </a>
</div>

With the partial in hand, including the stats on the Home page is easy

public/partials/static_pages/home.html

<div ng-if="logged_in" class="row">
	<aside class="col-md-4">
		<section class="user_info" ng-include="'partials/shared/_user_info.html'"></section>
		<section class="stats" ng-include="'partials/shared/_stats.html'"></section>
		<section class="micropost_form" ng-include="'partials/shared/_micropost_form.html'"></section>
    </aside>
    <div class="col-md-8">
		<h3>Micropost Feed</h3>
		<div ng-include="'partials/shared/_feed.html'"></div>
	</div>
</div>
...

app/controllers/static_pages_controller.js

var sessionHelper = require('../helpers/sessions_helper.js');
var sequelize = CONFIG.database;
function StaticPagesController() {
	this.home = function(req, res, next) {
		var current_user = sessionHelper.current_user(req);
		if (current_user) {
			var following = ModelSync( current_user.getFollowing({attributes: [[sequelize.fn('COUNT', sequelize.col('*')), 'count']]}) )[0];
			var followers = ModelSync( current_user.getFollowers({attributes: [[sequelize.fn('COUNT', sequelize.col('*')), 'count']]}) )[0];
			var offset = (req.query.page - 1) * req.query.limit;
			var feed_items = ModelSync( current_user.feed({offset: offset, limit: req.query.limit}) );
			var microposts_count = ModelSync( Micropost.count({ where: { user_id: current_user.id } }) );
			res.end(JSON.stringify({
				id: current_user.id,
				following: following,
				followers: followers,
				microposts_count: microposts_count,
				feed_items: feed_items
			}));
		} else {
			res.end();
		}
		
	};
}

module.exports = StaticPagesController;

public/controllers/static_pages_controller.js

'use strict';

var staticPagesController = angular.module('staticPagesController', []);

staticPagesController.controller(
	'StaticPagesHomeCtrl',
	['$scope', '$rootScope', '$state', 'Micropost', 'flashHelper', 'home_data', '$stateParams', function ($scope, $rootScope, $state, Micropost, flashHelper, home_data, $stateParams) {
		$scope.user = home_data;
		...
	}]
);
...

To style the stats, we’ll add some CSS

public/assets/stylesheets/custom.css

/* sidebar */
...

.gravatar {
  float: left;
  margin-right: 10px;
}

.gravatar_edit {
  margin-top: 15px;
}

.stats {
  overflow: auto;
  margin-top: 0;
  padding: 0;
}
.stats a {
  float: left;
  padding: 0 10px;
  border-left: 1px solid #eee;
  color: gray;
}
.stats a:first-child {
  padding-left: 0;
  border: 0;
}
.stats a:hover {
  text-decoration: none;
  color: blue;
}
.stats strong {
  display: block;
}

.user_avatars {
  overflow: auto;
  margin-top: 10px;
}
.user_avatars .gravatar {
  margin: 1px 1px;
}
.user_avatars a {
  padding: 0;
}

.users.follow {
  padding: 0;
}

/* forms */
...

We’ll render the stats partial on the profile page in a moment, but first let’s make a partial for the follow/unfollow button

public/partials/users/_follow_form.html

<input ng-if="!user.followed" ng-click="follow(user.id)" name="commit" value="Follow" class="btn btn-primary">
<input ng-if="user.followed" ng-click="unfollow(user.id)" name="commit" value="Unfollow" class="btn">

This does nothing but defer the real work to follow and unfollow partials, which need new routes for the Relationships resource

config/routes.js

module.exports = [
	{ get: '/static_pages/home', action: 'home' },
	{ resources: 'microposts', only: ['create', 'destroy'] },
	{ put: '/password_resets/:id/valid', action: 'valid' },
	{ post: '/sessions' },
	{ delete: '/sessions', action: 'destroy' },
	{ get: '/sessions', action: 'current_user' },
	{
		resources: 'users',
		member: [
			{ get: 'following' },
			{ get: 'followers' }
		]
	},
	{ resources: 'password_resets', only: ['create', 'update'] },
	{ resources: 'account_activations', only: ['update'] },
	{ resources: 'relationships', only: ['create', 'destroy'] }
];

We can now include the follow form and the following statistics on the user profile page simply by rendering the partials

public/partials/users/show.html

<div class="row">
	<aside class="col-md-4">
		<section class="user_info">
			<h1>
				<img gravatar_for="{{ user.email }}" alt="{{ user.name }}" />
				{{ user.name }}
			</h1>
		</section>
		<section class="stats" ng-include="'partials/shared/_stats.html'"></section>
	</aside>
	<div class="col-md-8">
		<div ng-if="current_user.id != user.id && logged_in" id="follow_form" ng-include="'partials/users/_follow_form.html'"></div>
		<div ng-if="user.microposts.count">
			<h3>Microposts ({{ user.microposts.count }})</h3>
			<ol class="microposts">
				<li ng-repeat="micropost in user.microposts.rows" id="micropost-{{ micropost.id }}" ng-include="'partials/microposts/_micropost.html'"></li>
			</ol>
			<uib-pagination total-items="totalItems" ng-model="currentPage" ng-change="pageChanged()" max-size="5" class="pagination-sm" boundary-link-numbers="true" rotate="false" items-per-page="itemsPerPage"></uib-pagination>
		</div>
	</div>
</div>

app/controllers/users_controller.js

var sessionHelper = require('../helpers/sessions_helper.js');

function UsersController() {
	...

	this.show = function(req, res, next) {
		var offset = (req.query.page - 1) * req.query.limit;
		var user = ModelSync( User.findById(req.params.id) );

		var followed = false;
		var current_user = sessionHelper.current_user(req);
		if (current_user) {
			followed = current_user.following(user);
		}

		var following = ModelSync( user.getFollowing({attributes: [[sequelize.fn('COUNT', sequelize.col('*')), 'count']]}) )[0];
		var followers = ModelSync( user.getFollowers({attributes: [[sequelize.fn('COUNT', sequelize.col('*')), 'count']]}) )[0];

		var microposts = ModelSync( Micropost.findAndCountAll({
			where: { user_id: user.id },
			include: [ { model: User } ],
			order: 'micropost.createdAt DESC',
			offset: offset,
			limit: req.query.limit
		}) );
		res.end(JSON.stringify({
			id: user.id,
			email: user.email,
			name: user.name,
			following: following,
			followers: followers,
			microposts: microposts,
			followed: followed
		}));
	};

	...
}

module.exports = UsersController;

Following and followers pages

Our first step is to get the following and followers links to work. We’ll follow Twitter’s lead and have both pages require user login. As with most previous examples of access control, we’ll write the tests first

public/test/e2e_test/controllers/users_controller_test.js

describe('usersControllerTest', function() {
	...
	it('should redirect index when not logged in', function() {
		var current_url = 'http://localhost:1337/#/users';
		browser.get(current_url);
		expect( element.all(by.css('.alert-danger')).count() ).toEqual(1);
		expect( browser.getCurrentUrl() ).toContain('#/login');
	})

	it('should redirect following when not logged in', function() {
		var current_url = 'http://localhost:1337/#/users/1/following';
		browser.get(current_url);
		expect( element.all(by.css('.alert-danger')).count() ).toEqual(1);
		expect( browser.getCurrentUrl() ).toContain('#/login');
	})

	it('should redirect followers when not logged in', function() {
		var current_url = 'http://localhost:1337/#/users/1/followers';
		browser.get(current_url);
		expect( element.all(by.css('.alert-danger')).count() ).toEqual(1);
		expect( browser.getCurrentUrl() ).toContain('#/login');
	})
	...
});

The only tricky part of the implementation is realizing that we need to add two new actions to the Users controller.

app/controllers/users_controller.js

var sessionHelper = require('../helpers/sessions_helper.js');
var sequelize = CONFIG.database;
function UsersController() {
	this.before_action = [
		{ action: 'logged_in_user', only: ['index', 'update', 'destroy', 'following', 'followers'] },
		{ action: 'correct_user', only: ['update'] },
		{ action: 'admin_user', only: ['destroy'] }
	];

	this.getInfo = function(user) {
		var following = ModelSync( user.getFollowing({attributes: [[sequelize.fn('COUNT', sequelize.col('*')), 'count']]}) )[0];
		var followers = ModelSync( user.getFollowers({attributes: [[sequelize.fn('COUNT', sequelize.col('*')), 'count']]}) )[0];
		var microposts_count = ModelSync( Micropost.count({
			where: { user_id: user.id }
		}) );
		return {
			id: user.id,
			email: user.email,
			name: user.name,
			following: following,
			followers: followers,
			microposts_count: microposts_count
		};
	};

	this.following = function(req, res, next) {
		var offset = (req.query.page - 1) * req.query.limit;
		var user = ModelSync( User.findById(req.params.id) );
		var users = ModelSync( user.getFollowing({
			offset: offset,
			limit: req.query.limit,
			attributes: ['id', 'email', 'name']
		}) );
		var info = this.getInfo(user);
		info.users = users;
		res.end(JSON.stringify(info));
	};

	this.followers = function(req, res, next) {
		var offset = (req.query.page - 1) * req.query.limit;
		var user = ModelSync( User.findById(req.params.id) );
		var users = ModelSync( user.getFollowers({
			offset: offset,
			limit: req.query.limit,
			attributes: ['id', 'email', 'name']
		}) );
		var info = this.getInfo(user);
		info.users = users;
		res.end(JSON.stringify(info));
	};
	...
}

module.exports = UsersController;

public/services/user.js

var userService = angular.module('userService', ['ngResource']);

userService.factory('User', ['$resource', function($resource){
	return $resource('users/:id/:member', {id: '@id', member: '@member'}, {
		'get':    {method: 'GET'},
		'create': {method: 'POST'},
		'update': {method: 'PUT'},
		'query':  {method: 'GET', isArray:false},
		'delete': {method: 'DELETE'}
	});
}]);

public/app.js

sampleApp.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider) {
	...
	.state('user_following', {
		url: '/users/:id/following?page&limit',
		templateUrl: 'partials/users/show_follow.html',
		resolve: {
			current_user: current_user,
			user: ['$q', '$stateParams', 'User', function($q, $stateParams, User){
				$stateParams.page = $stateParams.page ? $stateParams.page : 1;
				$stateParams.limit = $stateParams.limit ? $stateParams.limit : 30;
				var deferred = $q.defer();
				User.get({id: $stateParams.id, member: 'following', page: $stateParams.page, limit: $stateParams.limit}, function(following) {
					deferred.resolve(following);
				}, function(){
					deferred.reject();
				});
				return deferred.promise;
			}]
		},
		controller: 'UsersMemberCtrl',
		data: {
			title: 'Following'
		}
	})
	.state('user_followers', {
		url: '/users/:id/followers?page&limit',
		templateUrl: 'partials/users/show_follow.html',
		resolve: {
			current_user: current_user,
			user: ['$q', '$stateParams', 'User', function($q, $stateParams, User){
				$stateParams.page = $stateParams.page ? $stateParams.page : 1;
				$stateParams.limit = $stateParams.limit ? $stateParams.limit : 30;
				var deferred = $q.defer();
				User.get({id: $stateParams.id, member: 'followers', page: $stateParams.page, limit: $stateParams.limit}, function(followers) {
					deferred.resolve(followers);
				}, function(){
					deferred.reject();
				});
				return deferred.promise;
			}]
		},
		controller: 'UsersMemberCtrl',
		data: {
			title: 'Followers'
		}
	})
}]);
...

public/controllers/users_controller.js

...
usersController.controller(
	'UsersMemberCtrl',
	['$scope', '$state', '$stateParams', 'user', function ($scope, $state, $stateParams, user) {
		$scope.user = user;
		$scope.users = user.users;
		$scope.title = $state.current.data.title;
		$scope.totalItems = $state.current.name == 'user_following' ? $scope.user.following.count : $scope.user.followers.count;
	}]
);
...

The show_follow view used to render following and followers.

public/partials/users/show_follow.html

<div class="row">
    <aside class="col-md-4">
        <section class="user_info">
            <img gravatar_for="{{ user.email }}" alt="{{ user.name }}" />
            <h1>{{ user.name }}</h1>
            <span>
                <a href ui-sref="user_detail({id: user.id})" ui-sref-opts="{reload: true}">
                    view my profile
                </a>
            </span>
            <span><b>Microposts:</b> {{ user.microposts_count }}</span>
        </section>
        <section class="stats">
            <div ng-include="'partials/shared/_stats.html'"></div>
            <div ng-if="users && users.length" class="user_avatars">
                <a href ng-repeat="_user in user.users" ui-sref="user_detail({id: _user.id})">
                    <img gravatar_for="{{ _user.email }}" options-size="30" alt="{{ _user.name }}" class="gravatar" />
                </a>
            </div>
        </section>
    </aside>
    <div class="col-md-8">
        <h3>{{ title }}</h3>
        <ul ng-if="users && users.length" class="users follow" ng-include="'partials/users/_user.html'">
        </ul>
        <uib-pagination ng-if="users && users.length" total-items="totalItems" ng-model="currentPage" ng-change="pageChanged()" max-size="5" class="pagination-sm" boundary-link-numbers="true" rotate="false" items-per-page="itemsPerPage"></uib-pagination>
    </div>
</div>

At this point, the tests should be successful

~/sample_app $ protractor protractor.conf.js

Now that we have working following and followers pages, we’ll write a couple of short integration tests to verify their behavior.

public/test/e2e_test/integration/following_test.js

describe('FollowingTest', function() {
	it('following page', function() {
		var current_url = 'http://localhost:1337/#/login';
		browser.get(current_url);
		element(by.css('[name="email"]')).clear('');
		element(by.css('[name="password"]')).clear('');
		element(by.css('[name="email"]')).sendKeys('[email protected]');
		element(by.css('[name="password"]')).sendKeys('123456');
		element(by.css('[name="commit"]')).click();
		expect(browser.getCurrentUrl()).toContain('#/users/');
		browser.get('http://localhost:1337/#/users/1/following');
		expect( element.all(by.css('li[ng-repeat="user in users"]')).count() ).toBeGreaterThan(0);
	})

	it('followers page', function() {
		browser.get('http://localhost:1337/#/users/1/followers');
		expect( element.all(by.css('li[ng-repeat="user in users"]')).count() ).toBeGreaterThan(0);
	})
})

The test suite should now be successful

~/sample_app $ protractor protractor.conf.js

A working follow button

Now that our views are in order, it’s time to get the follow/unfollow buttons working.

~/sample_app $ trainjs generate service Relationships create destroy

We’ll check that attempts to access actions in the Relationships controller require a logged-in user (and thus get redirected to the login page), while also not changing the Relationship count

public/test/e2e_test/controllers/relationships_controller_test.js

describe('relationshipsControllerTest', function() {
	it('create should require logged-in user', function(done) {
		browser.get('http://localhost:1337/#/login');

		var test = function() {
			browser.executeAsyncScript(function(callback) {
				var $injector = angular.injector([ 'relationshipsService' ]);
				var Relationships = $injector.get( 'Relationships' );
				Relationships.create({followed_id: 3}, function(relationships){
					callback(relationships);
				}, function(error){
					callback(error);
				});
			}).then(function (output) {
				expect( output.status ).toEqual(401);
				done();
			});
		};

		element.all(by.css('[ui-sref="login"]')).isDisplayed().then(function(result) {
			if ( result.length > 0 ) {
				test();
			} else {
				element(by.css('.dropdown')).click();
				element(by.css('[ui-sref="logout"]')).click();
				test();
			}
		});
	})

	it('destroy should require logged-in user', function(done) {
		browser.executeAsyncScript(function(callback) {
			var $injector = angular.injector([ 'relationshipsService' ]);
			var Relationships = $injector.get( 'Relationships' );
			Relationships.delete({id: 3}, function(relationships){
				callback(relationships);
			}, function(error){
				callback(error);
			});
		}).then(function (output) {
			expect( output.status ).toEqual(401);
			done();
		});
	})
});

We can get the tests to pass by adding the logged_in_user before filter

app/controllers/relationships_controller.js

function RelationshipsController() {
	this.before_action = [
		{ action: 'logged_in_user' }
	];

	this.create = function(req, res, next) {
	};
	this.destroy = function(req, res, next) {
	};

}

module.exports = RelationshipsController;

To get the follow and unfollow buttons to work, all we need to do is find the user associated with the followed_id in the corresponding form, and then use the appropriate follow or unfollow method

app/controllers/relationships_controller.js

var sessionHelper = require('../helpers/sessions_helper.js');

function RelationshipsController() {
	this.before_action = [
		{ action: 'logged_in_user' }
	];

	this.create = function(req, res, next) {
		var current_user = sessionHelper.current_user(req);
		var user = ModelSync( User.findById(req.body.followed_id) );
		if (user) {
			res.end(JSON.stringify( ModelSync( current_user.follow(user) ) ));
		} else {
			res.end(JSON.stringify( {
				error: 'User not found'
			} ));
		}
	};
	this.destroy = function(req, res, next) {
		var current_user = sessionHelper.current_user(req);
		var user = ModelSync( User.findById(req.params.id) );
		if (user) {
			res.end(JSON.stringify( ModelSync( current_user.unfollow(user) ) ));
		} else {
			res.end(JSON.stringify( {
				error: 'User not found'
			} ));
		}
	};

}

module.exports = RelationshipsController;

public/controllers/users_controller.js

...
usersController.controller(
	'UsersDetailCtrl',
	['$scope', '$rootScope', 'user', '$state', '$stateParams', 'Relationships', 'flashHelper', function ($scope, $rootScope, user, $state, $stateParams, Relationships, flashHelper) {
		$rootScope.provide_title = user.name;
		$scope.user = user;
		$scope.totalItems = user.microposts.count;
		$scope.follow = function(id) {
			Relationships.create({followed_id: id}, function(relationships){
				if ( relationships.error ) {
					flashHelper.set({type: "danger", content: relationships.error});
				}
				$state.transitionTo($state.current, $stateParams, {
					reload: true, inherit: false, notify: true
				});
			});
		};
		$scope.unfollow = function(id) {
			Relationships.delete({id: id}, function(relationships){
				if ( relationships.error ) {
					flashHelper.set({type: "danger", content: relationships.error});
				}
				$state.transitionTo($state.current, $stateParams, {
					reload: true, inherit: false, notify: true
				});
			});
		};
	}]
);
...

Following tests

Now that the follow buttons are working, we’ll write some simple tests to prevent regressions. To follow a user, we post to the relationships path and verify that the number of followed users increases by 1

public/test/e2e_test/integration/following_test.js

describe('FollowingTest', function() {
	...

	it('should unfollow a user', function() {
		browser.get('http://localhost:1337/#/users/2');
		expect( element(by.id('followers')).getText() ).toContain('1');
		element.all(by.css('#follow_form > [name="commit"]')).click();
		expect( element(by.id('followers')).getText() ).toContain('0');
	})

	it('should follow a user', function() {
		browser.get('http://localhost:1337/#/users/2');
		expect( element(by.id('followers')).getText() ).toContain('0');
		element.all(by.css('#follow_form > [name="commit"]')).click();
		expect( element(by.id('followers')).getText() ).toContain('1');
	})
})

At this point, the tests should be successful

~/sample_app $ protractor protractor.conf.js