Introduction
Angular 1.5 is a new version of Angular that introduces Angular2's core paradigms to existing Angular applications, namely ES6 classes and components. This will allow you to start writing code that will be much easier to port to Angular2 whilst still building features in your existing Angular codebases. This course will teach you everything you need to know about Angular 1.5 and best practices to ensure you're writing clean & modular code that can be upgraded to Angular2 with ease. In an upcoming course we will take this codebase and build it in Angular2, and we also have a corresponding guidebook for migrating Angular 1 apps to Angular2 that is based on our experience with this tutorial and the upcoming Angular2 tutorial.
What this course will cover
The new changes in Angular 1.5 and relevant portions of the Angular Style Guide will be the focus of our learning, including but not limited to:
- ES6
class
support for services and controllers - Angular's new
component
API - Using a task runner to transform and compile your code
- Proper codebase structuring
- Best practices for writing Angular2 compatible code
We feel that learning is best accomplished by "doing", and as such, throughout this course we will be creating a production ready Medium.com clone called "Conduit" to demonstrate & apply these learnings. You can view a live demo of the application here. Conduit is a fully featured social blogging site including:
- Authentication with JWT
- Profiles with images
- Write/edit/read articles
- Comments on articles
- Ability to "favorite" articles
- Ability to follow other users & have their articles show up in your feed
For your convenience, we have a hosted API that you can build your application against. We're also going to release courses for how to create the backend in either Node, Rails, or Django over the next two weeks.
Prerequisites
Setting up a boilerplate project for Angular 1.5 & ES6
Understanding the Gulp build system
Lets take a moment to see what's going on under the hood. We'll be using a Gulp and a handful of Gulp plugins to perform various tasks that will build our application for us. For this course, we chose Gulp instead of other alternatives (like Webpack, etc) because:
- It's commonly used for Angular 1.x apps. This will make it easier to find answers to any questions you may have
- Easily extensible. It's fairly straightforward to write your own build tasks
- The Browserify tasks that gulp invokes (described below) can easily be ported over to Rails, Django, Node, etc projects if needed
- Lightweight
It's worth noting that we considered choosing Webpack but decided against it because it's significantly more complicated and harder to customize. Gulp will likely be easier to integrate into your existing Angular applications as well considering its inherent flexibility.
If you look at our gulpfile, there are a few key tasks being run that are worth noting — we've outlined them below.
Bundling & modularizing all JS files
This allows us to use ES6's import/export functionality. It also dumps out a single JS file for the browser to download instead of having to include every file individually in your HTML file.
What we're using to do it: Browserify. It's very flexible compared to alternatives like Webpack (which is mostly popular with React developers, although Angular2 devs are starting to adopt it as well).
Transpiling ES6
Transpiling allows us to use ES6 features even if browsers don't support it yet.
What we're using to do it: Babel. It's by far the most popular tool for the job and highly configurable.
Gathering templates & injecting them into Angular's $templateCache
The browser will automatically get the entire application's templates on page load, omitting the need to request templates from the server. You can learn more about how $templateCache works here.
Run a local server to show us live changes while we develop our application
We decided to use BrowserSync. LiveReload would've also been a good choice, but BrowserSync has a handful of useful features that make it more attractive.
Exploring the base application
In the video above we explored the initial codebase and familiarized ourselves with its layout. Feel free to play around with the various files and get a feel for how things have been set up. The entry point for the application is app.js, which then includes all of the other javascript files.
Once you've had a chance to poke around a bit, lets go ahead and get started building our first bits of functionality!
Building our first pages with ES6 controllers
When building a new application that involves users registering/logging in, you typically start off by building authentication functionality first. Welding on authentication after you've built out a lot of features can be tedious and lead to confusing code, as you'll have to restructure a lot of your existing code to accomodate it. Instead, lets build all of our authentication related functionality up front.
To do this, we'll need to:
- Create login/register pages
- Send login/register requests to our server & handle the response
- Display errors
- Store the user's auth info (JWT token) to ensure they stay logged in
- Ensure users can/cannot access pages if they're logged in/out
Lets start by building our first two pages: login and register. Since they share a similar template we'll create a single HTML file that will be used for both pages.
auth/auth.html
<div class="auth-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Register</h1>
<p class="text-xs-center">
<a href="">
Have an account?
</a>
</p>
<form>
<fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="text"
placeholder="Username"
ng-model="$ctrl.formData.username" />
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="email"
placeholder="Email"
ng-model="$ctrl.formData.email" />
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="password"
placeholder="Password"
ng-model="$ctrl.formData.password" />
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right"
type="submit">
Register
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
Notice that we have three inputs (username, email, and password) that are bound via ng-model to an object called $ctrl.formData
. If you've ever worked in Angular code before this shouldn't look unfamiliar except for the $ctrl
variable, which normally would be $scope
or some other name you define with controllerAs. We'll get to this in a second when we create the controller for this template!
We want the auth/auth.html template to show up when you go to either /#/login or /#/register, so we'll need to define new UI-Router states to do this.
auth/auth.config.js
function AuthConfig($stateProvider, $httpProvider) {
'ngInject';
// Define the routes
$stateProvider
.state('app.login', {
url: '/login',
templateUrl: 'auth/auth.html',
title: 'Sign in'
})
.state('app.register', {
url: '/register',
templateUrl: 'auth/auth.html',
title: 'Sign up'
});
};
export default AuthConfig;
auth/index.js
import angular from 'angular';
// Create the home module where our functionality can attach to
let authModule = angular.module('app.auth', []);
// Include our UI-Router config settings
import AuthConfig from './auth.config';
authModule.config(AuthConfig);
export default authModule;
app.js
[...]
import './services';
+ import './auth';
const requires = [
'ui.router',
'templates',
'app.layout',
'app.components',
'app.home',
'app.profile',
'app.article',
'app.services',
+ 'app.auth'
];
[...]
Now if you navigate to /#/login and /#/register you should see the HTML contained in the auth/auth.html file!
Lets create a controller for the login and register pages. Since the functionality between them is so similar, lets create a single controller. We'll need to determine if the current state is app.login
or app.register
.
auth/auth.controller.js
class AuthCtrl {
constructor($state) {
'ngInject';
this.title = $state.current.title;
this.authType = $state.current.name.replace('app.', '');
}
}
export default AuthCtrl;
auth/index.js
[...]
+ // Controllers
+ import AuthCtrl from './auth.controller';
+ authModule.controller('AuthCtrl', AuthCtrl);
export default authModule;
Lets take a moment to talk about the $ctrl
variable. With controllerAs, you were allowed to choose the scope's variable name. "vm" (short for "view model") is/was a popular choice. We've chosen $ctrl instead for two reasons:
- It's the default controllerAs name for the new component API (which we'll cover in a minute)
- It looks visually similar to $scope
While initially it may be tempting to use controller specific names (i.e. 'LoginVM', etc), there's no benefit in using different controllerAs variable names across your application. In fact, it will make it harder to upgrade to Angular2 as you'll likely want to do a simple find & remove for all controllerAs variable instances considering Angular2 templates don't require a reference variable.
auth/auth.config.js
function AuthConfig($stateProvider, $httpProvider) {
'ngInject';
// Define the routes
$stateProvider
.state('app.login', {
url: '/login',
+ controller: 'AuthCtrl as $ctrl',
templateUrl: 'auth/auth.html',
title: 'Sign in'
})
.state('app.register', {
url: '/register',
+ controller: 'AuthCtrl as $ctrl',
templateUrl: 'auth/auth.html',
title: 'Sign up'
});
};
export default AuthConfig;
Now that our controller is wired up, we want to update our template to show the proper name of the page ("Sign in" or "Sign up"). We'll also include a link to /#/register on /#/login, and vice versa.
auth/auth.html
[...]
- <h1 class="text-xs-center">Register</h1>
+ <h1 class="text-xs-center" ng-bind="::$ctrl.title"></h1>
- <p class="text-xs-center">
- <a href="">
- Have an account?
- </a>
- </p>
+ <p class="text-xs-center">
+ <a ui-sref="app.login"
+ ng-show="$ctrl.authType === 'register'">
+ Have an account?
+ </a>
+ <a ui-sref="app.register"
+ ng-show="$ctrl.authType === 'login'">
+ Need an account?
+ </a>
+ </p>
[...]
You may have noticed the :: in ng-bind="::$ctrl.title"
— that's the syntax for a one time binding. If you update the title
variable in the controller via user input, a timeout, etc it won't be reflected in the view. It's a lot of work for Angular to keep track of variable changes when they're bound two ways, so whenever you don't need two way data binding, use a one time binding. The title won't change at all after the page loads, so this is a good time to use one.
The other thing you may have noticed is the attribute ui-sref on the login and register <a>
tags. Instead of having to type out /#/register or /#/login, we just pass along the name of the state and it automatically populates the link's href attribute with the correct URL for us. This is especially useful when you change your routes (i.e. changing /#/register to /#/create_account) - instead of having to go through your entire codebase to reflect this change, you simply update the relevant UI-Router configuration and ui-sref will change all links accordingly. Pretty cool!
We need this form to submit data through our controller, so lets modify the form to enable this.
auth/auth.html
[...]
- <form>
+ <form ng-submit="$ctrl.submitForm()">
- <fieldset>
+ <fieldset ng-disabled="$ctrl.isSubmitting">
- <fieldset class="form-group">
+ <fieldset class="form-group" ng-show="$ctrl.authType === 'register'">
<input class="form-control form-control-lg"
type="text"
placeholder="Username"
ng-model="$ctrl.formData.username" />
</fieldset>
[...]
<button class="btn btn-lg btn-primary pull-xs-right"
type="submit"
+ ng-bind="$ctrl.title">
- Register
</button>
</fieldset>
</form>
[...]
The page should now show the correct title & show the link to login or logout, respectively.
The last thing we need to do to wire the template's form submission to the controller. We'll do this by creating the submitForm
method that the template is calling using ng-submit from <form ng-submit="$ctrl.submitForm()">
.
auth/auth.controller.js
class AuthCtrl {
constructor($state) {
'ngInject';
this.title = $state.current.title;
this.authType = $state.current.name.replace('app.', '');
}
+ submitForm () {
+ this.isSubmitting = true;
+
+ console.log(this.formData);
+ }
}
export default AuthCtrl;
Excellent! If you fill in the form and then click submit, your browser console should output an object containing the fields & data you entered.
Interacting with a server using an ES6 service
Now that we're receiving the form's data, we need a way to actually send it up to the server. For this we'll create an Angular service that can perform this functionality and is available throughout our application.
Services in Angular are used to share data & functionality between controllers. If you've used Angular before, you've probably used "factories" more than you've used services. The difference between the two is pretty straightforward:
Factories: you create an object, add properties to it, then return that same object. When you pass this service into your controller, those properties on the object can now be accessed in that controller via your factory.
Services: it's instantiated with the ‘new’ keyword. Because of that, you'll add properties to ‘this’ (like we've been doing in this course) and the service will return ‘this’. When you pass the service into your controller, the properties on ‘this’ can now be accessed in that controller via your service.
Without the nice class
syntax that ES6 provides, most developers opted to just create an object with factories instead of using services. However, with ES6 we can now create classes with ease! Lets create our first service using an ES6 class.
Creating a User service
For functionality that will be reused across the entire application it makes sense to have dedicated folders to hold those relevant files. As such, all of our services are stored in the services/ folder per the Angular styleguide.
services/user.service.js
export default class User {
constructor() {
'ngInject';
}
}
The User service currently needs to do two things: make login/register requests via $http, and store the currently logged in user's info. We will need to access the API URL of our server from config/app.constants.js which are set to a constant named "AppConstants".
export default class User {
- constructor() {
+ constructor(AppConstants, $http) {
'ngInject';
+ // Object to store our user properties
+ this.current = null;
}
}
The next thing we need to do is add a method for attempting authentication. There's just one "gotchya" - we can't access AppConstants
or $http
outside of the constructor function, as they are provided as arguments to the constructor function and are not saved anywhere for later use (which is what we need them for).
This is different from how you typically access injected services in factories, so don't worry if you're a bit confused! The answer is very straightforward - simply create a reference to the service object on this
inside the constructor function.
export default class User {
constructor(AppConstants, $http) {
'ngInject';
+ this._AppConstants = AppConstants;
+ this._$http = $http;
// Object to store our user properties
this.current = null;
}
}
To access an injected service outside of the constructor function, you simply call this._serviceName
(i.e. this._$http()
). With this, lets create a method called attemptAuth
that will either login or register a user.
Note: If you want to use our public API to build against, make sure you update the app constants API url with https://conduit.productionready.io/api
!
export default class User {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
// Object to store our user properties
this.current = null;
}
+ // Try to authenticate by registering or logging in
+ attemptAuth(type, credentials) {
+ let route = (type === 'login') ? '/login' : '';
+ return this._$http({
+ url: this._AppConstants.api + '/users' + route,
+ method: 'POST',
+ data: {
+ user: credentials
+ }
+ }).then(
+ // On success...
+ (res) => {
+ // Store the user's info for easy lookup
+ this.current = res.data.user;
+
+ return res;
+ }
+ );
+ }
}
What is let
?
It's a new ES6 syntax for declaring variables - it's basically the same thing as var
except it will do block scoping, which is super nice because the lack of block scoping with var often causes a lot of unexpected bugs.
What is this (res) => {}
madness?
It's a new feature of ES6 called arrow functions. In the past you would've been forced to write function(res) {}
which would have created a new function scope, which would break this.current = res.data.user
since this
would no longer be tied to the User class. Further, it's a lot shorter & prettier to write out!
services/index.js
import angular from 'angular';
// Create the module where our functionality can attach to
let servicesModule = angular.module('app.services', []);
+ // Services
+ import UserService from './user.service';
+ servicesModule.service('User', UserService);
export default servicesModule;
Check to make sure your console isn't reporting any errors! If it isn't, that means that we should be ready to integrate the User attemptAuth
method into our Auth controller.
Calling the service method in the controller
auth/auth.controller.js
class AuthCtrl {
- constructor($state) {
+ constructor(User, $state) {
'ngInject';
+ this._User = User;
this.title = $state.current.title;
this.authType = $state.current.name.replace('app.', '');
}
submitForm () {
this.isSubmitting = true;
- console.log(this.formData);
+ this._User.attemptAuth(this.authType, this.formData).then(
+ // Callback for success
+ (res) => {
+ this.isSubmitting = false;
+ console.log(res);
+ },
+ // Callback for failure
+ (err) => {
+ this.isSubmitting = false;
+ console.log(err.data.errors);
+ }
+ );
}
}
export default AuthCtrl;
If you try registering and/or logging in, you should see the success data or errors showing up in your console. We'll want to show errors to the user above the form so they know if anything went wrong, so lets go ahead and iterate over any error messages using ng-repeat.
auth/auth.html
[...]
<a ui-sref="app.register"
ng-show="$ctrl.authType === 'login'">
Need an account?
</a>
</p>
+ <ul class="error-messages" ng-show="$ctrl.errors">
+ <div ng-repeat="(field, errors) in $ctrl.errors">
+ <li ng-repeat="error in errors">
+ {{field}} {{error}}
+ </li>
+ </div>
+ </ul>
<form ng-submit="$ctrl.submitForm()">
<fieldset ng-disabled="$ctrl.isSubmitting">
[...]
auth/auth.controller.js
[...]
// Callback for failure
(err) => {
this.isSubmitting = false;
- console.log(err.data.errors);
+ this.errors = err.data.errors;
}
[...]
When the server sends back errors they're now be displayed in a list in our view. Perfect!
Tweet “Boom! Got my Angular ES6 service wired up to the server 😎 ”
Using components & directives for reusable UI functionality
Listing out errors is something that we'll need to do in many different parts of our app, so wouldn't it be great if we could easily include this error listing functionality in our views without having to copy & paste the entire HTML snippet each time? Having a single HTML file would also make it easier to update in the future, as we could update all error handling functionality for the entire site by just changing one file.
This type of reusable UI functionality is exactly what components were made for. Lets create a reusable component that will list errors from an array.
Creating a reusable component for form errors
The simplest component you can make is just a template and a configuration object. When you want to control certain functionality in a component you'll typically need a controller as well, but considering we're just iterating over an array, we can get away with simply binding to a predefined array and expose that to the template.
Much like services, most components are meant to be used across many different parts of the app and thus should receive their own folder (in this case, that folder is /components).
components/list-errors.component.js
let ListErrors= {
bindings: {
errors: '='
},
templateUrl: 'components/list-errors.html'
};
export default ListErrors;
components/list-errors.html
<ul class="error-messages" ng-show="$ctrl.errors">
<div ng-repeat="(field, errors) in $ctrl.errors">
<li ng-repeat="error in errors">
{{field}} {{error}}
</li>
</div>
</ul>
Where is $ctrl being defined?
When a controller isn't defined on a component, the default controller name is $ctrl
. We've chosen to use this name for all other controllers as well because it looks similar to $scope
and isn't easily confused with other variable names you'd typically use.
components/index.js
import angular from 'angular';
let componentsModule = angular.module('app.components', []);
+ // Components (and directives)
+ import ListErrors from './list-errors.component';
+ componentsModule.component('listErrors', ListErrors);
export default componentsModule;
auth/auth.html
[...]
<a ui-sref="app.register"
ng-show="$ctrl.authType === 'login'">
Need an account?
</a>
</p>
- <ul class="error-messages" ng-show="$ctrl.errors">
- <div ng-repeat="(field, errors) in $ctrl.errors">
- <li ng-repeat="error in errors">
- {{field}} {{error}}
- </li>
- </div>
- </ul>
+ <list-errors errors="$ctrl.errors"></list-errors>
<form ng-submit="$ctrl.submitForm()">
<fieldset ng-disabled="$ctrl.isSubmitting">
[...]
Awesome! We now have a reusable component that allows us to easily list out errors. The next thing we'll need to work on is allowing users to actually "log in" to our application.
Creating a directive to show/hide an element depending on auth state
Right now we don't have any way of showing if the request is successful - i.e. the user has no way of knowing if they're logged in or not. We'll need to show different parts of the UI when people are logged in (login/logout buttons, etc), so lets create a directive that allows us to programmatically show or hide HTML elements if the user is/isn't logged in.
Why a directive instead of a component?
Directives are excellent when you want to invoke functionality on an existing HTML element via an attribute (i.e. <div hide-this-element="true">I'm going to be hidden programmatically</div>
, whereas components are meant to be standalone HTML elements (i.e. <list-errors></list-errors>
).
Since User.current
is null when logged out, anything other than that means that they're logged in. Lets have a directive that shows contents when User.current
is not null.
components/show-authed.directive.js
function ShowAuthed(User) {
'ngInject';
return {
restrict: 'A',
link: function(scope, element, attrs) {
scope.User = User;
scope.$watch('User.current', function(val) {
// If user detected
if (val) {
if (attrs.showAuthed === 'true') {
element.css({ display: 'inherit'})
} else {
element.css({ display: 'none'})
}
// no user detected
} else {
if (attrs.showAuthed === 'true') {
element.css({ display: 'none'})
} else {
element.css({ display: 'inherit'})
}
}
});
}
};
}
export default ShowAuthed;
components/index.js
[...]
// Components (and directives)
import ListErrors from './list-errors.component';
componentsModule.component('listErrors', ListErrors);
+ import ShowAuthed from './show-authed.directive';
+ componentsModule.directive('showAuthed', ShowAuthed);
export default componentsModule;
Sweet! The show-authed directive should now be available for use to use in any of our templates. Lets start by changing the header to show different options whether they're logged in or logged out.
<nav class="navbar navbar-light">
<div class="container">
<a class="navbar-brand"
ui-sref="app.home"
ng-bind="::$ctrl.appName | lowercase">
</a>
<!-- Show this for logged out users -->
<ul class="nav navbar-nav pull-xs-right"
+ show-authed="false">
<li class="nav-item">
<a class="nav-link"
ui-sref-active="active"
ui-sref="app.home">
Home
</a>
</li>
- <li class="nav-item">
- <a class="nav-link"
- ui-sref-active="active"
- ui-sref="app.article">
- Article
- </a>
- </li>
-
- <li class="nav-item">
- <a class="nav-link"
- ui-sref-active="active"
- ui-sref="app.profile">
- Profile
- </a>
- </li>
+ <li class="nav-item">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.login">
+ Sign in
+ </a>
+ </li>
+
+ <li class="nav-item">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.register">
+ Sign up
+ </a>
+ </li>
</ul>
+ <!-- Show this for logged in users -->
+ <ul show-authed="true"
+ class="nav navbar-nav pull-xs-right">
+
+ <li class="nav-item">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.home">
+ Home
+ </a>
+ </li>
+
+ <li class="nav-item">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.article">
+ Article
+ </a>
+ </li>
+
+ <li class="nav-item">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.profile">
+ My Profile
+ </a>
+ </li>
+
+ </ul>
</div>
</nav>
If you successfully log in or register, the header now shows "My Profile" in the header instead of "Profile". However, when you refresh the page the application forgets that the user was logged in. This makes sense, as the value of User.current
(and every other variable in our app for that matter) is flushed whenever the page is closed or refreshed. To fix this, we'll need to somehow store the user's authentication credentials in a way that won't be deleted on page close.
Persisting JWT auth across page loads
The server API uses JWT tokens to authenticate users. If you haven't used JWTs before, a JWT token is basically a string that you pass along in requests that verifies the user is actually authorized. If the token is invalid the server will reject the request. It's pretty straightforward and this course about JWT authentication with Angular covers it in great detail.
To keep the user logged in, we'll save their JWT token that's returned from the server in localStorage. localStorage is a key->value store that allows us to persist data even when the page is reloaded.
services/jwt.service.js
export default class JWT {
constructor(AppConstants, $window) {
'ngInject';
this._AppConstants = AppConstants;
this._$window = $window;
}
save(token) {
this._$window.localStorage[this._AppConstants.jwtKey] = token;
}
get() {
return this._$window.localStorage[this._AppConstants.jwtKey];
}
destroy() {
this._$window.localStorage.removeItem(this._AppConstants.jwtKey);
}
}
import angular from 'angular';
// Create the module where our functionality can attach to
let servicesModule = angular.module('app.services', []);
// Services
import UserService from './user.service';
servicesModule.service('User', UserService);
+ import JwtService from './jwt.service';
+ servicesModule.service('JWT', JwtService);
export default servicesModule;
Now we need need to actually save the token when a user registers or logs in successfully.
services/user.service.js
export default class User {
- constructor(AppConstants, $http) {
+ constructor(JWT, AppConstants, $http) {
'ngInject';
+ this._JWT = JWT;
this._AppConstants = AppConstants;
this._$http = $http;
// Object to store our user properties
this.current = null;
}
// Try to authenticate by registering or logging in
attemptAuth(type, credentials) {
let route = (type === 'login') ? '/login' : '';
return this._$http({
url: this._AppConstants.api + '/users' + route,
method: 'POST',
data: {
user: credentials
}
}).then(
// On success...
(res) => {
+ // Set the JWT token
+ this._JWT.save(res.data.user.token);
// Store the user's info for easy lookup
this.current = res.data.user;
return res;
}
);
}
}
Now try logging in or registering. After the request returns successfully, type localStorage.getItem('jwtToken')
in your console and you should see the token being stored. Huzzah!
Logging out
Lets add a logout method to the User service. To log out, we'll need to do three things:
- Set
User.current
to null - Delete the JWT token in localStorage
- Do a hard refresh of the page to flush out old data
services/user.service.js
export default class User {
- constructor(JWT, AppConstants, $http) {
+ constructor(JWT, AppConstants, $http, $state) {
'ngInject';
this._JWT = JWT;
this._AppConstants = AppConstants;
this._$http = $http;
+ this._$state = $state;
// Object to store our user properties
this.current = null;
}
// Try to authenticate by registering or logging in
attemptAuth(type, credentials) {
[...]
}
+ logout() {
+ this.current = null;
+ this._JWT.destroy();
+ // Do a hard reload of current state to ensure all data is flushed
+ this._$state.go(this._$state.$current, null, { reload: true });
+ }
}
Lets test it out in the console! Make sure that you're logged in and seeing the "My Profile" link in the header. Then, get & hold a reference to the service object by running var User = angular.element(document).injector().get('User')
. Then run User.logout()
- the UI should change! If you check localStorage.getItem('jwtToken')
it should yield no token.
But we still don't have the user's info when we refresh the page. Now that we're storing the JWT token, we just need to exchange that JWT token for the user's info on page load.
Getting the user's info on page load
To get the current user's info from the server we simply need to make a GET request to /api/users with an authentication header containing their JWT token. Before we can make that request we'll need to check if a JWT token is present. Therefore if there isn't a JWT token present, or if the server returns an error, we know that there isn't a user logged in.
services/user.service.js
export default class User {
- constructor(JWT, AppConstants, $http, $state) {
+ constructor(JWT, AppConstants, $http, $state, $q) {
'ngInject';
this._JWT = JWT;
this._AppConstants = AppConstants;
this._$http = $http;
this._$state = $state;
+ this._$q = $q;
// Object to store our user properties
this.current = null;
}
// Try to authenticate by registering or logging in
attemptAuth(type, credentials) {
[...]
}
logout() {
[...]
}
+ verifyAuth() {
+ let deferred = this._$q.defer();
+
+ // Check for JWT token first
+ if (!this._JWT.get()) {
+ deferred.resolve(false);
+ return deferred.promise;
+ }
+
+ // If there's a JWT & user is already set
+ if (this.current) {
+ deferred.resolve(true);
+
+ // If current user isn't set, get it from the server.
+ // If server doesn't 401, set current user & resolve promise.
+ } else {
+ this._$http({
+ url: this._AppConstants.api + '/user',
+ method: 'GET',
+ headers: {
+ Authorization: 'Token ' + this._JWT.get()
+ }
+ }).then(
+ (res) => {
+ this.current = res.data.user;
+ deferred.resolve(true);
+ },
+ // If an error happens, that means the user's token was invalid.
+ (err) => {
+ this._JWT.destroy();
+ deferred.resolve(false);
+ }
+ // Reject automatically handled by auth interceptor
+ // Will boot them to homepage
+ );
+ }
+
+ return deferred.promise;
+ }
}
We need to check this on every page load otherwise we won't know if the user is logged in or not. This should be the first code that runs before our application fully boots up. We have an abstract UI-Router state called app
that all of our other views inhereit from -- and by adding a resolve to it that invokes User.verifyAuth
, it will ensure that the application won't render the child states until we know if the user is logged in or not.
Lets add a UI-Router resolve to our app state that will invoke User.verifyAuth
and then wait for the promise to resolve before allowing the rest of the page to execute.
config/app.config.js
function AppConfig($httpProvider, $stateProvider, $locationProvider, $urlRouterProvider) {
'ngInject';
/*
If you don't want hashbang routing, uncomment this line.
Our tutorial will be using hashbang routing though :)
*/
// $locationProvider.html5Mode(true);
$stateProvider
.state('app', {
abstract: true,
templateUrl: 'layout/app-view.html',
+ resolve:{
+ auth: function(User) {
+ return User.verifyAuth();
+ }
+ }
});
$urlRouterProvider.otherwise('/');
}
export default AppConfig;
Now when you reload the page the header should have the "My Profile" link in it which means we were automatically authenticated! There's one nice optimization we can do that will make sending authenticated requests much easier, which is to automatically add the JWT authentication header to all requests we make. Otherwise, we will have to include it with all $http requests we make which will be tedious and likely introduce accidental bugs.
Utilizing $http interceptors for JWT
We can easily add logic around all $http requests in our application by creating an $http interceptor. For our use case specifically, we want to automatically add the authorization header with our JWT token only if the $http request is being made to our own API. Additionally, if the server ever returns a 401 error (unauthorized), that means that the current user isn't logged in and we should delete the JWT token from localStorage.
config/auth.interceptor.js
function authInterceptor(JWT, AppConstants, $window, $q) {
'ngInject';
return {
// automatically attach Authorization header
request: function(config) {
if(config.url.indexOf(AppConstants.api) === 0 && JWT.get()) {
config.headers.Authorization = 'Token ' + JWT.get();
}
return config;
},
// Handle 401
responseError: function(rejection) {
if (rejection.status === 401) {
// clear any JWT token being stored
JWT.destroy();
// do a hard page refresh
$window.location.reload();
}
return $q.reject(rejection);
}
}
}
export default authInterceptor;
config/app.config.js
+ import authInterceptor from './auth.interceptor';
function AppConfig($httpProvider, $stateProvider, $locationProvider, $urlRouterProvider) {
'ngInject';
+ // Push our interceptor for auth
+ $httpProvider.interceptors.push(authInterceptor);
/*
If you don't want hashbang routing, uncomment this line.
Our tutorial will be using hashbang routing though :)
*/
// $locationProvider.html5Mode(true);
$stateProvider
.state('app', {
abstract: true,
templateUrl: 'layout/app-view.html',
resolve:{
auth: function(User) {
return User.verifyAuth();
}
}
});
$urlRouterProvider.otherwise('/');
}
export default AppConfig;
[...]
url: this._AppConstants.api + '/user',
method: 'GET'
- headers: {
- Authorization: 'Token ' + this._JWT.get()
- }
[...]
Awesome, it still works! The final thing we'll do to finish authentication is creating a simple way to restrict access to pages whether you're logged in or logged out.
Restricting access to pages for logged in/out users
The most straightforward way to restrict access to a page is by adding a resolve to it's UI-Router state. A good example of this are our login & register pages -- we don't want logged in users to be able to access those. In the next chapter we'll be making a settings page, and we won't want logged out users to be able to access that page either.
services/user.service.js
[...]
// This method will be used by UI-Router resolves
ensureAuthIs(bool) {
let deferred = this._$q.defer();
this.verifyAuth().then((authValid) => {
// if it's the opposite, redirect home
if (authValid !== bool) {
this._$state.go('app.home');
deferred.resolve(false);
} else {
deferred.resolve(true);
}
})
return deferred.promise;
}
[...]
auth/auth.config.js
function AuthConfig($stateProvider, $httpProvider) {
'ngInject';
// Define the routes
$stateProvider
.state('app.login', {
url: '/login',
controller: 'AuthCtrl as $ctrl',
templateUrl: 'auth/auth.html',
title: 'Sign in',
+ resolve:{
+ auth: function(User) {
+ return User.ensureAuthIs(false);
+ }
+ }
})
.state('app.register', {
url: '/register',
controller: 'AuthCtrl as $ctrl',
templateUrl: 'auth/auth.html',
title: 'Sign up',
+ resolve:{
+ auth: function(User) {
+ return User.ensureAuthIs(false);
+ }
+ }
});
};
export default AuthConfig;
auth/auth.controller.js
class AuthCtrl {
constructor(User, $state) {
'ngInject';
this._User = User;
+ this._$state = $state;
this.title = $state.current.title;
this.authType = $state.current.name.replace('app.', '');
}
submitForm () {
this.isSubmitting = true;
this._User.attemptAuth(this.authType, this.formData).then(
// Callback for success
(res) => {
- this.isSubmitting = false;
- console.log(res);
+ this._$state.go('app.home');
},
// Callback for failure
(err) => {
this.isSubmitting = false;
this.errors = err.data.errors;
}
);
}
}
export default AuthCtrl;
We're all done implementing authentication! Now lets give the user a way to edit their settings (username, picture, bio, etc) as well as view their own profile.
Updating user settings
Now that users can sign up & create an account, we should really let them edit/update their account information. Besides the username, email, and password they've already provided us with, they can also provide a profile image and a bio about themselves that will be displayed on their profile (which we'll create in the next chapter). Lets create a settings page where our users can update all of these fields!
settings/settings.html
<div class="settings-page">
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Your Settings</h1>
<list-errors errors="$ctrl.errors"></list-errors>
<form ng-submit="$ctrl.submitForm()">
<fieldset ng-disabled="$ctrl.isSubmitting">
<fieldset class="form-group">
<input class="form-control"
type="text"
placeholder="URL of profile picture"
ng-model="$ctrl.formData.image" />
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="text"
placeholder="Username"
ng-model="$ctrl.formData.username" />
</fieldset>
<fieldset class="form-group">
<textarea class="form-control form-control-lg"
rows="8"
placeholder="Short bio about you"
ng-model="$ctrl.formData.bio">
</textarea>
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="email"
placeholder="Email"
ng-model="$ctrl.formData.email" />
</fieldset>
<fieldset class="form-group">
<input class="form-control form-control-lg"
type="password"
placeholder="New Password"
ng-model="$ctrl.formData.password" />
</fieldset>
<button class="btn btn-lg btn-primary pull-xs-right"
type="submit">
Update Settings
</button>
</fieldset>
</form>
<!-- Line break for logout button -->
<hr />
<button class="btn btn-outline-danger">
Or click here to logout.
</button>
</div>
</div>
</div>
</div>
settings/settings.controller.js
class SettingsCtrl {
constructor() {
'ngInject';
}
}
export default SettingsCtrl;
settings/settings.config.js
function SettingsConfig($stateProvider) {
'ngInject';
$stateProvider
.state('app.settings', {
url: '/settings',
controller: 'SettingsCtrl',
controllerAs: '$ctrl',
templateUrl: 'settings/settings.html',
title: 'Settings',
resolve:{
auth: function(User) {
return User.ensureAuthIs(true);
}
}
});
};
export default SettingsConfig;
settings/index.js
import angular from 'angular';
// Create the settings module where our functionality can attach to
let settingsModule = angular.module('app.settings', []);
// Include our UI-Router config settings
import SettingsConfig from './settings.config';
settingsModule.config(SettingsConfig);
// Controllers
import SettingsCtrl from './settings.controller';
settingsModule.controller('SettingsCtrl', SettingsCtrl);
export default settingsModule;
[...]
import './auth';
+ import './settings';
// Create and bootstrap application
const requires = [
'ui.router',
'templates',
'app.layout',
'app.components',
'app.home',
'app.profile',
'app.article',
'app.services',
'app.auth',
+ 'app.settings'
];
[...]
Now that the settings page is set up, lets include a handy link to the page for our users (and ourselves :)
layout/header.html
[...]
<!-- Show this for logged in users -->
<ul show-authed="true"
class="nav navbar-nav pull-xs-right">
<li class="nav-item">
<a class="nav-link"
ui-sref-active="active"
ui-sref="app.home">
Home
</a>
</li>
<li class="nav-item">
<a class="nav-link"
ui-sref-active="active"
ui-sref="app.article">
Article
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.settings">
+ <i class="ion-gear-a"></i> Settings
+ </a>
+ </li>
<li class="nav-item">
<a class="nav-link"
ui-sref-active="active"
ui-sref="app.profile">
My Profile
</a>
</li>
</ul>
[...]
What is <i class="ion-gear-a"></i>
?
It's an icon from Ionicons, a really dead-simple CSS icon set made by the folks over at Ionic. If you look at the index.html file in the src/ folder you'll see that we're including it in the header tag.
The settings page is now viewable and navigable from our header bar. However, the page doesn't show any of our user's current data in the form fields.
settings/settings.controller.js
class SettingsCtrl {
+ constructor() {
+ constructor(User) {
'ngInject';
+ this.formData = {
+ email: User.current.email,
+ bio: User.current.bio,
+ image: User.current.image,
+ username: User.current.username
+ };
}
}
export default SettingsCtrl;
Excellent, our user's data is being prepopulated. Before we make the form work, lets make the logout button work.
settings/settings.controller.js
class SettingsCtrl {
constructor(User) {
'ngInject';
this.formData = {
email: User.current.email,
bio: User.current.bio,
image: User.current.image,
username: User.current.username
};
+ // Bind is req'd because the logout method assumes
+ // the execution context is within the User object.
+ this.logout = User.logout.bind(User);
}
}
export default SettingsCtrl;
One thing worth mentioning is that we could've had a class method called logout() { this._User.logout() }
instead of this.logout = User.logout.bind(User);
. This would've allowed us to not have to use .bind(User)
. These both yield the same desired result, but we find the former to be less verbose & we intentionally placed it in this course to ensure you have a solid understanding of scopes/contexts in Javascript.
settings/settings.html
[...]
<button class="btn btn-outline-danger"
+ ng-click="$ctrl.logout()">
Or click here to logout.
</button>
[...]
The logout button should now work! Now lets finish up the form's functionality.
To make our form editable, we'll need to first create a method in our User service that allows us to update the user's information on the server.
services/user.service.js
[...]
+ // Update the current user's name, email, password, etc
+ update(fields) {
+ return this._$http({
+ url: this._AppConstants.api + '/user',
+ method: 'PUT',
+ data: { user: fields }
+ }).then(
+ (res) => {
+ this.current = res.data.user;
+ return res.data.user;
+ }
+ );
+ }
[...]
settings/settings.controller.js
class SettingsCtrl {
- constructor(User) {
+ constructor(User, $state) {
'ngInject';
+ this._User = User;
+ this._$state = $state;
this.formData = {
email: User.current.email,
bio: User.current.bio,
image: User.current.image,
username: User.current.username
};
// Bind is req'd because the logout method assumes
// the execution context is within the User object.
this.logout = User.logout.bind(User);
}
+ submitForm() {
+ this.isSubmitting = true;
+ this._User.update(this.formData).then(
+ (user) => {
+ console.log('success!');
+ this.isSubmitting = false;
+ },
+ (err) => {
+ this.isSubmitting = false;
+ this.errors = err.data.errors;
+ }
+ )
+ }
}
export default SettingsCtrl;
Go ahead and test it out by updating some of the fields, submitting the form, and then refreshing the page. The data stays! When the user successfully updates their information, we want to redirect them to their profile (where their name, picture, and bio's changed would be reflected). Lets go ahead and build out the functionality on our profile pages.
Displaying a profile page from server data
The profile page will be used for all user profiles and not just our own. As such, the API has a different endpoint for retrieving public profile information than the /api/user endpoint we've previously used to retrieve the currently logged in user's info.
In general, all functionality in our User service is meant to only deal with the currently logged in User. As such, it makes sense for us to create a new service that will exclusively deal with retrieving & interacting with user data that is not (necessarily) the currently logged in user's.
services/profile.service.js
export default class Profile {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
}
// Retrieve a user's profile
get(username) {
return this._$http({
url: this._AppConstants.api + '/profiles/' + username,
method: 'GET'
}).then((res) => res.data.profile);
}
}
[...]
import JwtService from './jwt.service';
servicesModule.service('JWT', JwtService);
+ import ProfileService from './profile.service';
+ servicesModule.service('Profile', ProfileService);
export default servicesModule;
We now want to wire this up to the profile page. Specifically, we want to:
- Get the username from the URL of the page. We'll do this using UI-Router parameters.
- Pass that username to the
Profile.get
method and then display the resulting data on the profile page. There are a few ways we could go about doing this. We could use the $stateParams service in our profile controller and then invokeProfile.get
- the only downside is that the profile page will partially render and look weird whileProfile.get
is waiting for the server response. The other option is to use a resolve on UI-Router to callProfile.get
, which will ensure the profile is loaded before the state changes.
Using a resolve typically makes sense when the data you're requesting only needs to be loaded once for that page. When we load a profile page we don't need to request the profile information again, so this is a great place for us to use a UI-Router resolve.
profile/profile.config.js
[...]
$stateProvider
.state('app.profile', {
- url: '/profile',
+ url: '/@:username',
controller: 'ProfileCtrl',
controllerAs: '$ctrl',
templateUrl: 'profile/profile.html'
+ resolve: {
+ profile: function(Profile, $state, $stateParams) {
+ return Profile.get($stateParams.username).then(
+ (profile) => profile,
+ (err) => $state.go('app.home')
+ );
+ }
+ }
});
[...]
Why didn't you add a title property to this state? We're going to update the title with the user's name inside the controller instead of having a static name like "Profile". Don't worry, we'll get to this a little bit later!
profile/profile.controller.js
class ProfileCtrl {
- constructor() {
+ constructor(profile, User) {
'ngInject';
+ // The profile for this page, resolved by UI Router
+ this.profile = profile;
+ // Show edit profile is this profile is the current user's
+ if (User.current) {
+ this.isUser = (User.current.username === this.profile.username);
+ } else {
+ this.isUser = false;
+ }
}
}
export default ProfileCtrl;
profile/profile.html
[...]
<div class="col-xs-12 col-md-10 offset-md-1">
- <img class="user-img" />
+ <img ng-src="{{::$ctrl.profile.image}}" class="user-img" />
- <h4>BradGreen</h4>
+ <h4 ng-bind="::$ctrl.profile.username"></h4>
- <p>I'm the dude running the Angular team at Google, yo.</p>
+ <p ng-bind="::$ctrl.profile.bio"></p>
+ <a ui-sref="app.settings"
+ class="btn btn-sm btn-outline-secondary action-btn"
+ ng-show="$ctrl.isUser">
+ <i class="ion-gear-a"></i> Edit Profile Settings
+ </a>
<button class="btn btn-sm btn-outline-secondary action-btn"
+ ng-hide="$ctrl.isUser">
<i class="ion-plus-round"></i>
- Follow BradGreen
+ Follow
</button>
</div>
[...]
If you go to /#/@[your username] in your web browser you should now see your profile! You'll also be able to click through to the settings page. Lets make it easier to get to our profile page - first we need to update our settings controller to redirect back the the profile on success, and then we'll update our header's profile link to show our name & picture and link to our profile.
First, lets update our settings controller to redirect to our profile page when we successfully update our information.
settings/settings.controller.js
[...]
this._User.update(this.formData).then(
(user) => {
- console.log('success!');
- this.isSubmitting = false;
+ this._$state.go('app.profile', {username:user.username});
},
(err) => {
this.isSubmitting = false;
this.errors = err.data.errors;
}
)
[...]
Now we need to change the header bar link. Lets show the current user's prof pic and username.
layout/header.component.js
class AppHeaderCtrl {
- constructor(AppConstants) {
+ constructor(AppConstants, User, $scope) {
'ngInject';
this.appName = AppConstants.appName;
+ this.currentUser = User.current;
+ $scope.$watch('User.current', (newUser) => {
+ this.currentUser = newUser;
+ });
}
}
[...]
layout/header.html
[...]
<li class="nav-item">
<a class="nav-link"
ui-sref-active="active"
- ui-sref="app.profile">
+ ui-sref="app.profile({ username: $ctrl.currentUser.username })">
- My Profile
+ <img ng-src="{{$ctrl.currentUser.image}}" class="user-pic"/>
+ {{ $ctrl.currentUser.username }}
</a>
</li>
[...]
Nice! Our new header looks sexy.
Creating a follow button component with encapsulated functionality
The last thing we'll do on the profile (for now) is making the follow button actually work. The follow button will also be used on our articles page which we'll build next chapter, so we should create a reusable component that will encapsulate the button & it's functionality. It also makes the implementation very elegant as our profile controller doesn't need to be aware of the follow button's logic. The only thing that we need to give the follow button is a profile object, as it contains the two things we need: the user's username and whether or not we're currently following them.
profile/profile.html
<div class="profile-page">
<!-- User's basic info & action buttons -->
<div class="user-info">
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-10 offset-md-1">
<img ng-src="{{::$ctrl.profile.image}}" class="user-img" />
<h4 ng-bind="::$ctrl.profile.username"></h4>
<p ng-bind="::$ctrl.profile.bio"></p>
+ <follow-btn user="$ctrl.profile" ng-hide="$ctrl.isUser"></follow-btn>
<a ui-sref="app.settings"
class="btn btn-sm btn-outline-secondary action-btn"
ng-show="$ctrl.isUser">
<i class="ion-gear-a"></i> Edit Profile Settings
</a>
- <button class="btn btn-sm btn-outline-secondary action-btn"
- ng-hide="$ctrl.isUser">
- <i class="ion-plus-round"></i>
-
- Follow
- </button>
</div>
</div>
</div>
</div>
If you view the profile of another user besides the one you're currently logged in as, nothing will show up because Angular can't find the <follow-btn>
component. Lets go ahead and create that.
components/buttons/follow-btn.html
<button
class="btn btn-sm btn-outline-secondary action-btn">
<i class="ion-plus-round"></i>
{{ $ctrl.user.following ? 'Unfollow' : 'Follow' }} {{ $ctrl.user.username }}
</button>
components/buttons/follow-btn.component.js
class FollowBtnCtrl {
constructor() {
'ngInject';
}
}
let FollowBtn= {
bindings: {
user: '='
},
controller: FollowBtnCtrl,
templateUrl: 'components/buttons/follow-btn.html'
};
export default FollowBtn;
[...]
+ import FollowBtn from './buttons/follow-btn.component';
+ componentsModule.component('followBtn', FollowBtn);
export default componentsModule;
Cool, the follow button is showing now. We need to make the button actually make a request to the server when it's clicked though. It's inapproapriate to make $http requests in controllers, so lets extend our Profile service with follow & unfollow methods that wrap the $http requests.
services/profile.service.js
export default class Profile {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
}
// Retrieve a user's profile
get(username) {
return this._$http({
url: this._AppConstants.api + '/profiles/' + username,
method: 'GET'
}).then((res) => res.data.profile);
}
+ // Follow a user
+ follow(username) {
+ return this._$http({
+ url: this._AppConstants.api + '/profiles/' + username + '/follow',
+ method: 'POST'
+ }).then((res) => res.data);
+ }
+ // Unfollow a user
+ unfollow(username) {
+ return this._$http({
+ url: this._AppConstants.api + '/profiles/' + username + '/follow',
+ method: 'DELETE'
+ }).then((res) => res.data);
+ }
}
components/buttons/follow-btn.component.js
class FollowBtnCtrl {
- constructor() {
+ constructor(Profile, User, $state) {
'ngInject';
+ this._Profile = Profile;
+ this._User = User;
+ this._$state = $state;
}
+ submit() {
+ this.isSubmitting = true;
+
+ if (!this._User.current) {
+ this._$state.go('app.register');
+ return;
+ }
+
+ // If following already, unfollow
+ if (this.user.following) {
+ this._Profile.unfollow(this.user.username).then(
+ () => {
+ this.isSubmitting = false;
+ this.user.following = false;
+ }
+ )
+
+ // Otherwise, follow them
+ } else {
+ this._Profile.follow(this.user.username).then(
+ () => {
+ this.isSubmitting = false;
+ this.user.following = true;
+ }
+ )
+ }
+ }
}
let FollowBtn= {
bindings: {
user: '='
},
controller: FollowBtnCtrl,
templateUrl: 'components/buttons/follow-btn.html'
};
export default FollowBtn;
components/buttons/follow-btn.html
<button
- class="btn btn-sm btn-outline-secondary action-btn">
+ class="btn btn-sm action-btn"
+ ng-class="{ 'disabled': $ctrl.isSubmitting,
+ 'btn-outline-secondary': !$ctrl.user.following,
+ 'btn-secondary': $ctrl.user.following }"
+ ng-click="$ctrl.submit()">
<i class="ion-plus-round"></i>
{{ $ctrl.user.following ? 'Unfollow' : 'Follow' }} {{ $ctrl.user.username }}
</button>
The follow button now works -- great job! Next, lets start working on the functionality for creating, updating, showing and deleting articles (this is a social blogging site after all :)
Creating the article editor
Lets create a page where we can create and update articles. For the most part it will just be a simple form, but we'll also need to create a UI for adding "tags". Further, we'll need to prepopulate the form when we're editing an article. Thankfully Angular and UI-Router makes both of these super easy to accomplish!
To start off, lets create the template and base controller.
editor/editor.html
<div class="editor-page">
<div class="container page">
<div class="row">
<div class="col-md-10 offset-md-1 col-xs-12">
<list-errors errors="$ctrl.errors"></list-errors>
<form>
<fieldset ng-disabled="$ctrl.isSubmitting">
<fieldset class="form-group">
<input class="form-control form-control-lg"
ng-model="$ctrl.article.title"
type="text"
placeholder="Article Title" />
</fieldset>
<fieldset class="form-group">
<input class="form-control"
ng-model="$ctrl.article.description"
type="text"
placeholder="What's this article about?" />
</fieldset>
<fieldset class="form-group">
<textarea class="form-control"
rows="8"
ng-model="$ctrl.article.body"
placeholder="Write your article (in markdown)">
</textarea>
</fieldset>
<fieldset class="form-group">
<input class="form-control"
type="text"
placeholder="Enter tags"
ng-model="$ctrl.tagField" />
<div class="tag-list">
<span class="tag-default tag-pill">
<i class="ion-close-round"></i>
Example Tag
</span>
<span class="tag-default tag-pill">
<i class="ion-close-round"></i>
Another Tag
</span>
</div>
</fieldset>
<button class="btn btn-lg pull-xs-right btn-primary" type="button">
Publish Article
</button>
</fieldset>
</form>
</div>
</div>
</div>
</div>
editor/editor.controller.js
class EditorCtrl {
constructor() {
'ngInject';
}
}
export default EditorCtrl;
editor/editor.config.js
function EditorConfig($stateProvider) {
'ngInject';
$stateProvider
.state('app.editor', {
url: '/editor',
controller: 'EditorCtrl',
controllerAs: '$ctrl',
templateUrl: 'editor/editor.html',
title: 'Editor',
resolve:{
auth: function(User) {
return User.ensureAuthIs(true);
}
}
});
};
export default EditorConfig;
import angular from 'angular';
// Create the module where our functionality can attach to
let editorModule = angular.module('app.editor', []);
// Include our UI-Router config settings
import EditorConfig from './editor.config';
editorModule.config(EditorConfig);
// Controllers
import EditorCtrl from './editor.controller';
editorModule.controller('EditorCtrl', EditorCtrl);
export default editorModule;
app.js
[...]
import './auth';
import './settings';
+ import './editor';
// Create and bootstrap application
const requires = [
'ui.router',
'templates',
'app.layout',
'app.components',
'app.home',
'app.profile',
'app.article',
'app.services',
'app.auth',
'app.settings'
+ 'app.editor'
];
[...]
If you navigate to /#/editor you should now see the HTML contained in editor.html. Lets make this a bit easier to navigate to for ourselves & our users!
layout/header.html
[...]
- <li class="nav-item">
- <a class="nav-link"
- ui-sref-active="active"
- ui-sref="app.article">
- Article
- </a>
- </li>
+ <li class="nav-item">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.editor">
+ <i class="ion-compose"></i> New Article
+ </a>
+ </li>
[...]
Awesome! Now lets work on getting the editor to actually behave the way we want it to. On all the other pages we've worked on we simply used ng-bind on the input fields. We'll be doing this here as well on the title, description and body, but we need to create a custom UI for creating/removing tags.
editor/editor.controller.js
class EditorCtrl {
constructor() {
'ngInject';
this.article = {
title: '',
description: '',
body: '',
tagList: []
}
}
addTag() {
// Make sure this tag isn't already in the array
if (!this.article.tagList.includes(this.tagField)) {
this.article.tagList.push(this.tagField);
this.tagField = '';
}
}
removeTag(tagName) {
this.article.tagList = this.article.tagList.filter((slug) => slug != tagName);
}
}
export default EditorCtrl;
editor/editor.html
- <fieldset class="form-group">
- <input class="form-control"
- type="text"
- placeholder="Enter tags"
- ng-model="$ctrl.tagField" />
-
- <div class="tag-list">
- <span class="tag-default tag-pill">
- <i class="ion-close-round"></i>
- Example Tag
- </span>
- <span class="tag-default tag-pill">
- <i class="ion-close-round"></i>
- Another Tag
- </span>
- </div>
- </fieldset>
+ <fieldset class="form-group">
+ <input class="form-control"
+ type="text"
+ placeholder="Enter tags"
+ ng-model="$ctrl.tagField"
+ ng-keyup="$event.keyCode == 13 && $ctrl.addTag()" />
+
+ <div class="tag-list">
+ <span ng-repeat="tag in $ctrl.article.tagList"
+ class="tag-default tag-pill">
+ <i class="ion-close-round" ng-click="$ctrl.removeTag(tag)"></i>
+ {{ tag }}
+ </span>
+ </div>
+ </fieldset>
Adding & removing tags work!
Now lets create a service to actually save the article in our editor to the server.
services/articles.service.js
export default class Articles {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
}
// Creates an article
save(article) {
let request = {
url: `${this._AppConstants.api}/articles`,
method: 'POST',
data: { article: article }
};
return this._$http(request).then((res) => res.data.article);
}
}
What's the deal with ${this._AppConstants.api}/articles
?
It's the new template literals in ES6! Instead of having to split strings like you normally would with the plus sign 'hi' + userName
, you can simply write a string and place variables in it with the ${} syntax (hi ${userName}
). Note that you must have the string wrapped in backticks and not single or double quotes.
services/index.js
[...]
import ProfileService from './profile.service';
servicesModule.service('Profile', ProfileService);
+ import ArticlesService from './articles.service';
+ servicesModule.service('Articles', ArticlesService);
export default servicesModule;
Now lets hook into the save method on the Articles service.
editor/editor.html
<button class="btn btn-lg pull-xs-right btn-primary" type="button" ng-click="$ctrl.submit()">
Publish Article
</button>
editor/editor.controller.js
class EditorCtrl {
- constructor() {
+ constructor(Articles, $state) {
'ngInject';
+ this._Articles = Articles;
+ this._$state = $state;
[...]
}
addTag() {
[...]
}
removeTag(tagName) {
[...]
}
+ submit() {
+ this.isSubmitting = true;
+
+ this._Articles.save(this.article).then(
+ (newArticle) => {
+ this._$state.go('app.article', { slug: newArticle.slug });
+ },
+ (err) => {
+ this.isSubmitting = false;
+ this.errors = err.data.errors;
+ }
+ );
+ }
}
export default EditorCtrl;
It should work! Before we actually show the article on the page we redirect to on success, lets add the ability to update existing articles in the editor.
Extending the editor to update articles
We want to have the form prefilled with an article's data when there is slug in the URL (i.e. /#/editor/article-slug-here). We currently don't have a way of retrieving articles from the server by slug, so lets extend the article service to allow this.
services/articles.service.js
// Retrieve a single article
get(slug) {
return this._$http({
url: this._AppConstants.api + '/articles/' + slug,
method: 'GET'
}).then((res) => res.data.article);
}
editor/editor.config.js
$stateProvider
.state('app.editor', {
url: '/editor/:slug',
controller: 'EditorCtrl',
controllerAs: '$ctrl',
templateUrl: 'editor/editor.html',
title: 'Editor',
resolve:{
auth: function(User) {
return User.ensureAuthIs(true);
},
article: function(Articles, User, $state, $stateParams) {
// If we're trying to edit an article
if ($stateParams.slug) {
return Articles.get($stateParams.slug).then(
(article) => {
// If the current user is the author, resolve the article data
if (User.current.username === article.author.username) {
return article;
// Otherwise, redirect them to home page
} else {
$state.go('app.home');
}
},
// If there's an error (article doesn't exist, etc), redirect to home page
(err) => $state.go('app.home')
);
// If this is a new article, then just return null
} else {
return null;
}
}
}
});
editor/editor.controller.js
[...]
+ constructor(Articles, article, $state) {
'ngInject';
this._Articles = Articles;
this._$state = $state;
+ if (!article) {
this.article = {
title: '',
description: '',
body: '',
tagList: []
}
+ } else {
+ this.article = article;
+ }
}
[...]
Cool, the article data is now being prepopulated! But we need to have it actually update the article when we save, not try to create a new one. Lets update the save method to check for an existing article slug and update the article if one is found.
services/articles.service.js
// Creates or updates an article
save(article) {
let request = {};
// If there's a slug, perform an update via PUT w/ article's slug
if (article.slug) {
request.url = `${this._AppConstants.api}/articles/${article.slug}`;
request.method = 'PUT';
// Delete the slug from the article to ensure the server updates the slug,
// which happens if the title of the article changed.
delete article.slug;
// Otherwise, this is a new article POST request
} else {
request.url = `${this._AppConstants.api}/articles`;
request.method = 'POST';
}
// Set the article data in the data attribute of our request
request.data = { article: article };
return this._$http(request).then((res) => res.data.article);
}
If you update an article and then go back to the editor page, you should now see the new article data being persisted in the form. Nice work!
Rendering articles from the server
Lets create the page where we can actually see articles! We want users to be able to view articles by visiting /#/article/slug-of-article. To do this, we need to retrieve the given article by its slug (similar to what we did with the article editor).
article/article.config.js
function ArticleConfig($stateProvider) {
'ngInject';
$stateProvider
.state('app.article', {
url: '/article/:slug',
controller: 'ArticleCtrl',
controllerAs: '$ctrl',
templateUrl: 'article/article.html',
+ // When the controller loads, the title of the web page
+ // will be changed to the article's title
+ title: 'Article',
+ resolve: {
+ article: function(Articles, $state, $stateParams) {
+ return Articles.get($stateParams.slug).then(
+ (article) => article,
+ (err) => $state.go('app.home')
+ );
+ }
+ }
});
};
export default ArticleConfig;
article/article.controller.js
class ArticleCtrl {
- constructor() {
+ constructor(article) {
'ngInject';
+ this.article = article;
}
}
export default ArticleCtrl;
article/article.html
<div class="article-page">
<!-- Banner for article title, action buttons -->
<div class="banner">
<div class="container">
- <h1>Example article title</h1>
+ <h1 ng-bind="::$ctrl.article.title"></h1>
<div class="article-meta">
<!-- Show author info + favorite & follow buttons -->
<div class="article-meta">
<a href=""><img /></a>
<div class="info">
<a href="" class="author">Brad Green</a>
<span class="date">January 20th</span>
</div>
<button class="btn btn-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Brad Green
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Article <span class="counter">(29)</span>
</button>
</div>
</div>
</div>
</div>
<!-- Main view. Contains article html and comments -->
<div class="container page">
<!-- Article's HTML & tags rendered here -->
<div class="row article-content">
<div class="col-xs-12">
<div>
- <p>This is the content of our article.</p>
+ <div ng-bind="::$ctrl.article.body"></div>
</div>
<ul class="tag-list">
- <li class="tag-default tag-pill tag-outline">
- Tag One
- </li>
- <li class="tag-default tag-pill tag-outline">
- Tag Two
- </li>
+ <li class="tag-default tag-pill tag-outline"
+ ng-repeat="tag in ::$ctrl.article.tagList">
+ {{ tag }}
+ </li>
</ul>
</div>
</div>
[...]
You should now be able to see the title, body and tags show up on the article page! We need to turn the body into HTML from markdown though. We'll use an external package called marked to do this.
article/article.controller.js
+ import marked from 'marked';
class ArticleCtrl {
- constructor(article) {
+ constructor(article, $sce, $rootScope) {
'ngInject';
this.article = article;
+ // Update the title of this page
+ $rootScope.setPageTitle(this.article.title);
+ // Transform the markdown into HTML
+ this.article.body = $sce.trustAsHtml(marked(this.article.body, { sanitize: true }));
}
}
export default ArticleCtrl;
article/article.html
[...]
<div class="row article-content">
<div class="col-xs-12">
<div>
- <div ng-bind="::$ctrl.article.body"></div>
+ <div ng-bind-html="::$ctrl.article.body"></div>
</div>
<ul class="tag-list">
<li class="tag-default tag-pill tag-outline"
ng-repeat="tag in ::$ctrl.article.tagList">
{{ tag }}
</li>
</ul>
</div>
</div>
[...]
The markdown should now render properly! There is one bug though - if there's no slug in the URL, the server endpoint returns an array because it thinks we're querying for a list of the latest articles. The fix for this is simple — just update the service to reject the request if there is no slug present.
services/articles.service.js
export default class Articles {
- constructor(AppConstants, $http) {
+ constructor(AppConstants, $http, $q) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
+ this._$q = $q;
}
// Creates or updates an article
save(article) {
[...]
}
// Retrieve a single article
get(slug) {
+ let deferred = this._$q.defer();
+
+ // Check for blank title
+ if (!slug.replace(" ", "")) {
+ deferred.reject("Article slug is empty");
+ return deferred.promise;
+ }
- return this._$http({
+ this._$http({
+ url: this._AppConstants.api + '/articles/' + slug,
+ method: 'GET'
- }).then((res) => res.data.article);
+ }).then(
+ (res) => deferred.resolve(res.data.article),
+ (err) => deferred.reject(err)
+ );
+
+ return deferred.promise;
}
With that bug fixed, our application can now display articles! The next thing we'll do is work on the functionality contained within the article page.
Compartmentalizing page functionality into components
Components are excellent for reusing functionality across your application, but they're also excellent for ensuring you have a solid separation of concerns. In other words, it allows you to break the functionality of your application down into small self contained pieces.
The article page is a great example of where we can take advantage of this aspect of components. At the top and bottom of the page we literally have the same thing showing up where we could reuse a component -- the author's info with follow & favorite buttons. We also have a comments section at the bottom of the page where we could compartmentalize functionality. Lets build out the functionality for these.
A component for showing article meta information
We'll need to show the author's name, image, and the date the article was posted in different places on our site besides the article page. As such, lets make a single component to handle this. Since it will be used elsewhere on the site, we'll create it within the components folder. However, we should create a new subfolder where we'll put all article related components in order to keep the components folder organized.
components/article-helpers/article-meta.html
<div class="article-meta">
<a ui-sref="app.profile({ username:$ctrl.article.author.username })">
<img ng-src="{{$ctrl.article.author.image}}" />
</a>
<div class="info">
<a class="author"
ui-sref="app.profile({ username:$ctrl.article.author.username })"
ng-bind="$ctrl.article.author.username">
</a>
<span class="date"
ng-bind="$ctrl.article.createdAt | date: 'longDate' ">
</span>
</div>
<ng-transclude></ng-transclude>
</div>
components/article-helpers/article-meta.component.js
let ArticleMeta= {
bindings: {
article: '='
},
transclude: true,
templateUrl: 'components/article-helpers/article-meta.html'
};
export default ArticleMeta;
import FollowBtn from './buttons/follow-btn.component';
componentsModule.component('followBtn', FollowBtn);
+ import ArticleMeta from './article-helpers/article-meta.component';
+ componentsModule.component('articleMeta', ArticleMeta);
export default componentsModule;
article/article.html
<div class="article-page">
<!-- Banner for article title, action buttons -->
<div class="banner">
<div class="container">
<h1 ng-bind="::$ctrl.article.title"></h1>
- <div class="article-meta">
- <!-- Show author info + favorite & follow buttons -->
- <div class="article-meta">
- <a href=""><img /></a>
- <div class="info">
- <a href="" class="author">Brad Green</a>
- <span class="date">January 20th</span>
- </div>
+ <article-meta article="$ctrl.article">
<button class="btn btn-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Brad Green
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Article <span class="counter">(29)</span>
</button>
+ </article-meta>
- </div>
- </div>
</div>
</div>
<!-- Main view. Contains article html and comments -->
<div class="container page">
<!-- Article's HTML & tags rendered here -->
<div class="row article-content">
[...]
</div>
<hr />
<div class="article-actions">
<!-- Show author info + favorite & follow buttons -->
- <div class="article-meta">
- <a href=""><img /></a>
- <div class="info">
- <a href="" class="author">Brad Green</a>
- <span class="date">January 20th</span>
- </div>
+ <article-meta article="$ctrl.article">
<button class="btn btn-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Brad Green
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Article <span class="counter">(29)</span>
</button>
+ </article-meta>
- </div>
</div>
[...]
Excellent, we now have a single file that's powering the article's meta information!
Creating page specific components
You know, having the follow and favorite buttons hard coded in there seems redundant and they need their own logic anyways (actually following/favoriting the user/post on the server). Since this is functionality that is specific to the article page (and thus won't be reused elsewhere on the site), lets create a local component in the article/ folder to do this.
article/article-actions.html
<article-meta article="$ctrl.article">
<button class="btn btn-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Brad Green
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Article <span class="counter">(29)</span>
</button>
</article-meta>
article/article-actions.component.js
class ArticleActionsCtrl {
constructor() {
'ngInject';
}
}
let ArticleActions = {
bindings: {
article: '='
},
controller: ArticleActionsCtrl,
templateUrl: 'article/article-actions.html'
};
export default ArticleActions;
[...]
// Controllers
import ArticleCtrl from './article.controller';
articleModule.controller('ArticleCtrl', ArticleCtrl);
+
+import ArticleActions from './article-actions.component';
+articleModule.component('articleActions', ArticleActions);
export default articleModule;
<div class="article-page">
<!-- Banner for article title, action buttons -->
<div class="banner">
<div class="container">
<h1 ng-bind="::$ctrl.article.title"></h1>
- <article-meta article="$ctrl.article">
- <button class="btn btn-sm btn-outline-secondary">
- <i class="ion-plus-round"></i>
-
- Follow Brad Green
- </button>
-
- <button class="btn btn-sm btn-outline-primary">
- <i class="ion-heart"></i>
-
- Favorite Article <span class="counter">(29)</span>
- </button>
- </article-meta>
+ <article-actions article="$ctrl.article"></article-actions>
</div>
</div>
<!-- Main view. Contains article html and comments -->
<div class="container page">
<!-- Article's HTML & tags rendered here -->
<div class="row article-content">
[...]
</div>
<hr />
<div class="article-actions">
<!-- Show author info + favorite & follow buttons -->
- <article-meta article="$ctrl.article">
- <button class="btn btn-sm btn-outline-secondary">
- <i class="ion-plus-round"></i>
-
- Follow Brad Green
- </button>
-
- <button class="btn btn-sm btn-outline-primary">
- <i class="ion-heart"></i>
-
- Favorite Article <span class="counter">(29)</span>
- </button>
- </article-meta>
+ <article-actions article="$ctrl.article"></article-actions>
</div>
[...]
Nice -- that looks super clean! We're not done yet with the article-actions component yet though; we want to show edit/delete buttons when logged in, and a favorite/follow button when we're not. Lets build the edit/delete buttons first.
<article-meta article="$ctrl.article">
+ <!-- If current user is the author, show edit/delete buttons -->
+ <span ng-show="$ctrl.canModify">
+
+ <a class="btn btn-outline-secondary btn-sm"
+ ui-sref="app.editor({ slug: $ctrl.article.slug })">
+ <i class="ion-edit"></i> Edit Article
+ </a>
+
+ <button class="btn btn-outline-danger btn-sm">
+ <i class="ion-trash-a"></i> Delete Article
+ </button>
+
+ </span>
+ <!-- Otherwise, show favorite & follow buttons -->
+ <span ng-hide="$ctrl.canModify">
<button class="btn btn-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Brad Green
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Article <span class="counter">(29)</span>
</button>
+ </span>
</article-meta>
article/article-actions.component.js
class ArticleActionsCtrl {
- constructor() {
+ constructor(User) {
'ngInject';
+ // The user can only edit/delete this comment if they are the author
+ if (User.current) {
+ this.canModify = (User.current.username === this.article.author.username);
+ } else {
+ this.canModify = false;
+ }
}
}
[...]
The edit button works! Now lets make that delete button work.
// Retrieve a single article
get(slug) {
[...]
}
+ // Delete an article
+ destroy(slug) {
+ return this._$http({
+ url: this._AppConstants.api + '/articles/' + slug,
+ method: 'DELETE'
+ });
+ }
article/article-actions.component.js
class ArticleActionsCtrl {
- constructor(User) {
+ constructor(Articles, User, $state) {
'ngInject';
+ this._Articles = Articles;
+ this._$state = $state;
// The user can only edit/delete this comment if they are the author
if (User.current) {
this.canModify = (User.current.username === this.article.author.username);
} else {
this.canModify = false;
}
}
+ deleteArticle() {
+ this.isDeleting = true;
+ this._Articles.destroy(this.article.slug).then(
+ (success) => this._$state.go('app.home'),
+ (err) => this._$state.go('app.home')
+ );
+ }
}
[...]
article/article-actions.component.html
<article-meta article="$ctrl.article">
<!-- If current user is the author, show edit/delete buttons -->
<span ng-show="$ctrl.canModify">
<a class="btn btn-outline-secondary btn-sm"
ui-sref="app.editor({ slug: $ctrl.article.slug })">
<i class="ion-edit"></i> Edit Article
</a>
- <button class="btn btn-outline-danger btn-sm">
+ <button class="btn btn-outline-danger btn-sm"
+ ng-class="{disabled: $ctrl.isDeleting}"
+ ng-click="$ctrl.deleteArticle()">
<i class="ion-trash-a"></i> Delete Article
</button>
</span>
<!-- Otherwise, show favorite & follow buttons -->
<span ng-hide="$ctrl.canModify">
<button class="btn btn-sm btn-outline-secondary">
<i class="ion-plus-round"></i>
Follow Brad Green
</button>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Article <span class="counter">(29)</span>
</button>
</span>
</article-meta>
Delete works! Finally, lets add the following and favorite buttons.
article/article-actions.html
[...]
<!-- Otherwise, show favorite & follow buttons -->
- <span ng-hide="$ctrl.canModify">
- <button class="btn btn-sm btn-outline-secondary">
- <i class="ion-plus-round"></i>
-
- Follow Brad Green
- </button>
+ <follow-btn user="$ctrl.article.author"></follow-btn>
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i>
Favorite Article <span class="counter">(29)</span>
</button>
</span>
</article-meta>
Boom! That was super easy, right? Now lets make the favorite button. It will be used on other parts of the site (like the follow button), so lets put it in components/buttons/ as well.
<button class="btn btn-sm btn-outline-primary">
<i class="ion-heart"></i> <ng-transclude></ng-transclude>
</button>
class FavoriteBtnCtrl {
constructor() {
'ngInject';
}
}
let FavoriteBtn= {
bindings: {
article: '='
},
transclude: true,
controller: FavoriteBtnCtrl,
templateUrl: 'components/buttons/favorite-btn.html'
};
export default FavoriteBtn;
[...]
import ArticleMeta from './article-helpers/article-meta.component';
componentsModule.component('articleMeta', ArticleMeta);
+ import FavoriteBtn from './buttons/favorite-btn.component';
+ componentsModule.component('favoriteBtn', FavoriteBtn);
export default componentsModule;
[...]
<!-- Otherwise, show favorite & follow buttons -->
<span ng-hide="$ctrl.canModify">
<follow-btn user="$ctrl.article.author"></follow-btn>
-
- <button class="btn btn-sm btn-outline-primary">
- <i class="ion-heart"></i>
-
- Favorite Article <span class="counter">(29)</span>
- </button>
+ <favorite-btn article="$ctrl.article">
+ {{ $ctrl.article.favorited ? 'Unfavorite' : 'Favorite' }} Article <span class="counter">({{$ctrl.article.favoritesCount}})</span>
+ </favorite-btn>
</span>
</article-meta>
And the favorite button now shows up! To wire the favoriting functionality up to the server, we'll need favorite/unfavorite methods on the article service for the favorite button controller to invoke.
[...]
// Delete an article
destroy(slug) {
return this._$http({
url: this._AppConstants.api + '/articles/' + slug,
method: 'DELETE'
});
}
+ // Favorite an article
+ favorite(slug) {
+ return this._$http({
+ url: this._AppConstants.api + '/articles/' + slug + '/favorite',
+ method: 'POST'
+ });
+ }
+ // Unfavorite an article
+ unfavorite(slug) {
+ return this._$http({
+ url: this._AppConstants.api + '/articles/' + slug + '/favorite',
+ method: 'DELETE'
+ });
+ }
}
components/buttons/favorite-btn.component.js
class FavoriteBtnCtrl {
- constructor() {
+ constructor(User, Articles, $state) {
'ngInject';
+ this._Articles = Articles;
+ this._User = User;
+ this._$state = $state;
}
+ submit() {
+ this.isSubmitting = true;
+
+ if (!this._User.current) {
+ this._$state.go('app.register');
+ return;
+ }
+
+ // If fav'd already, unfavorite it
+ if (this.article.favorited) {
+ this._Articles.unfavorite(this.article.slug).then(
+ () => {
+ this.isSubmitting = false;
+ this.article.favorited = false;
+ this.article.favoritesCount--;
+ }
+ )
+
+ // Otherwise, favorite it
+ } else {
+ this._Articles.favorite(this.article.slug).then(
+ () => {
+ this.isSubmitting = false;
+ this.article.favorited = true;
+ this.article.favoritesCount++;
+ }
+ )
+ }
+ }
}
components/buttons/favorite-btn.component.js
- <button class="btn btn-sm btn-outline-primary">
+ <button class="btn btn-sm"
+ ng-class="{ 'disabled': $ctrl.isSubmitting,
+ 'btn-outline-primary': !$ctrl.article.favorited,
+ 'btn-primary': $ctrl.article.favorited }"
+ ng-click="$ctrl.submit()">
<i class="ion-heart"></i> <ng-transclude></ng-transclude>
</button>
Woot! The favorite button is complete :) Notice the data binding here -- when you favorite at the top of the page, it also is reflected at the bottom of the page automatically. Having two way data binding can make the UX of your applications amazing.
Creating commenting functionality
Lets start off by wiring up the comment form at the bottom of the page to the article controller.
[...]
<!-- Comments section -->
<div class="row">
<div class="col-xs-12 col-md-8 offset-md-2">
- <div>
+ <div show-authed="true">
+ <list-errors from="$ctrl.commentForm.errors"></list-errors>
- <form class="card comment-form">
+ <form class="card comment-form" ng-submit="$ctrl.addComment()">
+ <fieldset ng-disabled="$ctrl.commentForm.isSubmitting">
<div class="card-block">
<textarea class="form-control"
placeholder="Write a comment..."
rows="3"
+ ng-model="$ctrl.commentForm.body">
</textarea>
</div>
<div class="card-footer">
- <img class="comment-author-img" />
+ <img ng-src="{{::$ctrl.currentUser.image}}" class="comment-author-img" />
<button class="btn btn-sm btn-primary" type="submit">
Post Comment
</button>
</div>
+ </fieldset>
</form>
</div>
+ <p show-authed="false">
+ <a ui-sref="app.login">Sign in</a> or <a ui-sref="app.register">sign up</a> to add comments on this article.
+ </p>
<div class="card">
<div class="card-block">
<p class="card-text">This is an example comment.</p>
</div>
<div class="card-footer">
<a class="comment-author" href="">
<img class="comment-author-img" />
</a>
<a class="comment-author" href="">
BradGreen
</a>
<span class="date-posted">
Jan 20, 2016
</span>
</div>
</div>
</div>
</div>
[...]
article/article.controller.js
import marked from 'marked';
class ArticleCtrl {
- constructor(article, $sce, $rootScope) {
+ constructor(article, User, $sce, $rootScope) {
'ngInject';
this.article = article;
+ this.currentUser = User.current;
// Update the title of this page
$rootScope.setPageTitle(this.article.title);
// Transform the markdown into HTML
this.article.body = $sce.trustAsHtml(marked(this.article.body));
+ // Initialize blank comment form
+ this.resetCommentForm();
}
+ resetCommentForm() {
+ this.commentForm = {
+ isSubmitting: false,
+ body: '',
+ errors: []
+ }
+ }
+ addComment() {
+ this.commentForm.isSubmitting = true;
+
+ // Need to make request to server here
+ }
}
export default ArticleCtrl;
When you submit the form it should be disabled, which means it's wired up properly! We need a way to create, get, and delete comments on the server. Since commenting is a separate piece of functionality from articles, lets create a comments service that will handle all of the server interactions.
services/comments.service.js
export default class Comments {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
}
// Add a comment to an article
add(slug, payload) {
return this._$http({
url: `${this._AppConstants.api}/articles/${slug}/comments`,
method: 'POST',
data: { comment: { body: payload } }
}).then((res) => res.data.comment);
}
}
[...]
import ArticlesService from './articles.service';
servicesModule.service('Articles', ArticlesService);
+ import CommentsService from './comments.service';
+ servicesModule.service('Comments', CommentsService);
export default servicesModule;
article/article.controller.js
import marked from 'marked';
class ArticleCtrl {
- constructor(article, User, $sce, $rootScope) {
+ constructor(article, User, Comments, $sce, $rootScope) {
'ngInject';
this.article = article;
+ this._Comments = Comments;
this.currentUser = User.current;
[...]
}
resetCommentForm() {
[...]
}
addComment() {
this.commentForm.isSubmitting = true;
- // Need to make request to server here
+ this._Comments.add(this.article.slug, this.commentForm.body).then(
+ (comment) => {
+ console.log(comment);
+ this.resetCommentForm();
+ },
+ (err) => {
+ this.commentForm.isSubmitting = false;
+ this.commentForm.errors = err.data.errors;
+ }
+ );
}
[...]
It works! Now lets create a method to get all the comments for a given article and then display them underneath the form.
services/comments.service.js
export default class Comments {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
}
// Add a comment to an article
add(slug, payload) {
return this._$http({
url: `${this._AppConstants.api}/articles/${slug}/comments`,
method: 'POST',
data: { comment: { body: payload } }
}).then((res) => res.data.comment);
}
+ // Retrieve the comments for an article
+ getAll(slug) {
+ return this._$http({
+ url: `${this._AppConstants.api}/articles/${slug}/comments`,
+ method: 'GET'
+ }).then((res) => res.data.comments);
+ }
}
article/article.controller.js
import marked from 'marked';
class ArticleCtrl {
constructor(article, User, Comments, $sce, $rootScope) {
'ngInject';
this.article = article;
this._Comments = Comments;
this.currentUser = User.current;
// Update the title of this page
$rootScope.setPageTitle(this.article.title);
// Transform the markdown into HTML
this.article.body = $sce.trustAsHtml(marked(this.article.body));
+ // Get comments for this article
+ Comments.getAll(this.article.slug).then(
+ (comments) => this.comments = comments
+ );
// Initialize blank comment form
this.resetCommentForm();
}
resetCommentForm() {
[...]
}
addComment() {
this.commentForm.isSubmitting = true;
// Need to make request to server here
this._Comments.add(this.article.slug, this.commentForm.body).then(
(comment) => {
- console.log(comment);
+ this.comments.unshift(comment);
this.resetCommentForm();
},
(err) => {
this.commentForm.isSubmitting = false;
this.commentForm.errors = err.data.errors;
}
);
}
}
export default ArticleCtrl;
- <div class="card">
+ <div class="card" ng-repeat="data in $ctrl.comments">
<div class="card-block">
- <p class="card-text">This is an example comment.</p>
+ <p class="card-text" ng-bind="::data.body"></p>
</div>
<div class="card-footer">
- <a class="comment-author" href="">
+ <a class="comment-author"
+ ui-sref="app.profile({ username: data.author.username })">
- <img class="comment-author-img" />
+ <img ng-src="{{::data.author.image}}" class="comment-author-img" />
</a>
- <a class="comment-author" href="">
- BradGreen
- </a>
+ <a class="comment-author"
+ ui-sref="app.profile({ username: data.author.username })"
+ ng-bind="::data.author.username">
+ </a>
- <span class="date-posted">
- Jan 20, 2016
- </span>
+ <span class="date-posted"
+ ng-bind="::data.createdAt | date: 'longDate' ">
+ </span>
</div>
</div>
It works! We need to add a delete button if this is the author though. We've added a lot of stuff to the article controller and template, so we should really modularize this a bit. Lets create a component for comments that will handle this logic.
article/article.html
[...]
<!-- Comments section -->
<div class="row">
<div class="col-xs-12 col-md-8 offset-md-2">
[...]
<p show-authed="false">
<a ui-sref="app.login">Sign in</a> or <a ui-sref="app.register">sign up</a> to add comments on this article.
</p>
- <div class="card" ng-repeat="data in $ctrl.comments">
- <div class="card-block">
- <p class="card-text" ng-bind="::data.body"></p>
- </div>
- <div class="card-footer">
- <a class="comment-author"
- ui-sref="app.profile({ username: data.author.username })">
- <img ng-src="{{::data.author.image}}" class="comment-author-img" />
- </a>
-
- <a class="comment-author"
- ui-sref="app.profile({ username: data.author.username })"
- ng-bind="::data.author.username">
- </a>
- <span class="date-posted"
- ng-bind="::data.createdAt | date: 'longDate' ">
- </span>
- </div>
- </div>
+ <comment ng-repeat="cmt in $ctrl.comments"
+ data="cmt">
+ </comment>
</div>
</div>
[...]
<div class="card" ng-repeat="data in $ctrl.comments">
<div class="card-block">
<p class="card-text" ng-bind="::data.body"></p>
</div>
<div class="card-footer">
<a class="comment-author"
ui-sref="app.profile({ username: data.author.username })">
<img ng-src="{{::data.author.image}}" class="comment-author-img" />
</a>
<a class="comment-author"
ui-sref="app.profile({ username: data.author.username })"
ng-bind="::data.author.username">
</a>
<span class="date-posted"
ng-bind="::data.createdAt | date: 'longDate' ">
</span>
</div>
</div>
class CommentCtrl {
constructor() {
'ngInject';
}
}
let Comment = {
bindings: {
data: '='
},
controller: CommentCtrl,
templateUrl: 'article/comment.html'
};
export default Comment;
[...]
import ArticleActions from './article-actions.component';
articleModule.component('articleActions', ArticleActions);
+ import Comment from './comment.component';
+ articleModule.component('comment', Comment);
export default articleModule;
Hmm, it doesn't seem to work... Oh! We need to update the comment template to simply use the $ctrl variable and remove its ng-repeat, since ng-repeat is being called upon it in article.html
article/comment.html
- <div class="card" ng-repeat="data in $ctrl.comments">
+ <div class="card">
<div class="card-block">
- <p class="card-text" ng-bind="::data.body"></p>
+ <p class="card-text" ng-bind="::$ctrl.data.body"></p>
</div>
<div class="card-footer">
<a class="comment-author"
- ui-sref="app.profile({ username: data.author.username })">
+ ui-sref="app.profile({ username: $ctrl.data.author.username })">
- <img ng-src="{{::data.author.image}}" class="comment-author-img" />
+ <img ng-src="{{::$ctrl.data.author.image}}" class="comment-author-img" />
</a>
<a class="comment-author"
- ui-sref="app.profile({ username: data.author.username })"
+ ui-sref="app.profile({ username: $ctrl.data.author.username })"
- ng-bind="::data.author.username">
+ ng-bind="::$ctrl.data.author.username">
</a>
<span class="date-posted"
- ng-bind="::data.createdAt | date: 'longDate' ">
+ ng-bind="::$ctrl.data.createdAt | date: 'longDate' ">
</span>
</div>
</div>
Now it works! Lets add a delete button that only shows up when the current user is the comment owner.
article/comment.component.js
class CommentCtrl {
- constructor() {
+ constructor(User) {
'ngInject';
+ // The user can only delete this comment if they are the author
+ if (User.current) {
+ this.canModify = (User.current.username === this.data.author.username);
+ } else {
+ this.canModify = false;
+ }
}
}
[...]
<div class="card">
<div class="card-block">
<p class="card-text" ng-bind="::$ctrl.data.body"></p>
</div>
<div class="card-footer">
<a class="comment-author"
ui-sref="app.profile({ username: $ctrl.data.author.username })">
<img ng-src="{{::$ctrl.data.author.image}}" class="comment-author-img" />
</a>
<a class="comment-author"
ui-sref="app.profile({ username: $ctrl.data.author.username })"
ng-bind="::$ctrl.data.author.username">
</a>
<span class="date-posted"
ng-bind="::$ctrl.data.createdAt | date: 'longDate' ">
</span>
+ <span class="mod-options" ng-show="$ctrl.canModify">
+ <i class="ion-trash-a"></i>
+ </span>
</div>
</div>
services/comments.service.js
export default class Comments {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
}
// Add a comment to an article
add(slug, payload) {
[...]
}
+ // Delete a comment from an article
+ destroy(commentId, articleSlug) {
+ return this._$http({
+ url: `${this._AppConstants.api}/articles/${articleSlug}/comments/${commentId}`,
+ method: 'DELETE'
+ });
+ }
// Retrieve the comments for an article
getAll(slug) {
[...]
}
}
In the future, we might want to be able to delete comments from the article controller itself. So lets create the method there and then pass it down for the component to use.
[...]
addComment() {
[...]
}
+ deleteComment(commentId, index) {
+ this._Comments.destroy(commentId, this.article.slug).then(
+ (success) => {
+ this.comments.splice(index, 1);
+ }
+ );
+ }
}
export default ArticleCtrl;
article/article.html
[...]
<comment ng-repeat="cmt in $ctrl.comments"
- data="cmt">
+ data="cmt"
+ delete-cb="$ctrl.deleteComment(cmt.id, $index)">
</comment>
[...]
article/comment.component.js
[...]
let Comment = {
bindings: {
- data: '='
+ data: '=',
+ deleteCb: '&'
},
controller: CommentCtrl,
templateUrl: 'article/comment.html'
};
export default Comment;
article/comment.html
<div class="card">
<div class="card-block">
<p class="card-text" ng-bind="::$ctrl.data.body"></p>
</div>
<div class="card-footer">
<a class="comment-author"
ui-sref="app.profile({ username: $ctrl.data.author.username })">
<img ng-src="{{::$ctrl.data.author.image}}" class="comment-author-img" />
</a>
<a class="comment-author"
ui-sref="app.profile({ username: $ctrl.data.author.username })"
ng-bind="::$ctrl.data.author.username">
</a>
<span class="date-posted"
ng-bind="::$ctrl.data.createdAt | date: 'longDate' ">
</span>
<span class="mod-options" ng-show="$ctrl.canModify">
- <i class="ion-trash-a"></i>
+ <i class="ion-trash-a" ng-click="$ctrl.deleteCb()"></i>
</span>
</div>
</div>
And it works! Bad ass. Our article page is totally complete now. However, we still need to add lists of articles to profiles and our home page. Lets start with profiles first.
Creating abstract states and child states in UI-Router
We want our profile page to show articles that the user created, and articles that the user favorited. Ideally these two pages would have different routes (/#/@username and /#/@username/favorites), so lets make that happen.
profile/profile.config.js
function ProfileConfig($stateProvider) {
'ngInject';
$stateProvider
.state('app.profile', {
+ abstract: true,
url: '/@:username',
controller: 'ProfileCtrl',
controllerAs: '$ctrl',
templateUrl: 'profile/profile.html',
// When the controller loads, the title of the web page
// will be changed to the username of this profile
resolve: {
profile: function(Profile, $state, $stateParams) {
return Profile.get($stateParams.username).then(
(profile) => profile,
(err) => $state.go('app.home')
);
}
}
})
+ .state('app.profile.main', {
+ url:'',
+ controller: 'ProfileArticlesCtrl',
+ controllerAs: '$ctrl',
+ templateUrl: 'profile/profile-articles.html',
+ title: 'Profile'
+ })
+ .state('app.profile.favorites', {
+ url:'/favorites',
+ controller: 'ProfileArticlesCtrl',
+ controllerAs: '$ctrl',
+ templateUrl: 'profile/profile-articles.html',
+ title: 'Favorites'
+ });
};
export default ProfileConfig;
profile/profile.html
[...]
- <!-- List of articles -->
- <div class="article-preview">
- <div class="article-meta">
- <a href=""><img /></a>
- <div class="info">
- <a href="" class="author">BradGreen</a>
- <span class="date">January 20th</span>
- </div>
- <button class="btn btn-outline-primary btn-sm pull-xs-right">
- <i class="ion-heart"></i> 29
- </button>
- </div>
- <a href="" class="preview-link">
- <h1>How to build Angular apps that scale</h1>
- <p>Building web applications is not an easy task. It's even hard to make ones that scale.</p>
- <span>Read more...</span>
- <ul class="tag-list">
- <li class="tag-default tag-pill tag-outline">programming</li>
- <li class="tag-default tag-pill tag-outline">web</li>
- </ul>
- </a>
- </div>
+ <!-- View where profile-articles renders to -->
+ <div ui-view></div>
[...]
profile/profile-articles.controller.js
class ProfileArticlesCtrl {
constructor(profile, $state) {
'ngInject';
// The profile for this page, resolved by UI Router
this.profile = profile;
this.profileState = $state.current.name.replace('app.profile.', '');
}
}
export default ProfileArticlesCtrl;
profile/profile-articles.html
<p>Current state is {{ $ctrl.profileState }}</p>
profile/index.js
[...]
import ProfileCtrl from './profile.controller';
profileModule.controller('ProfileCtrl', ProfileCtrl);
+ import ProfileArticlesCtrl from './profile-articles.controller';
+ profileModule.controller('ProfileArticlesCtrl', ProfileArticlesCtrl);
export default profileModule;
Nice! You can now navigate to /#/@username/favorites in the URL bar. How about we fix the tabs to allow us to click between the main and favorites states?
profile/profile.html
<!-- Tabs for switching between author articles & favorites -->
<div class="articles-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item">
- <a class="nav-link active">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.profile.main({username: $ctrl.profile.username})">
My Articles
</a>
</li>
<li class="nav-item">
- <a class="nav-link">
+ <a class="nav-link"
+ ui-sref-active="active"
+ ui-sref="app.profile.favorites({username: $ctrl.profile.username})">
Favorited Articles
</a>
</li>
</ul>
</div>
Cool, you can toggle between tabs now! However, all of the links we made to profiles on the site are busted now because they have to be profile.main
and not just profile
.
layout/header.html
<li class="nav-item">
<a class="nav-link"
ui-sref-active="active"
- ui-sref="app.profile({ username: $ctrl.currentUser.username })">
+ ui-sref="app.profile.main({ username: $ctrl.currentUser.username })">
<img ng-src="{{$ctrl.currentUser.image}}" class="user-pic"/>
{{ $ctrl.currentUser.username }}
</a>
</li>
components/article-helpers/article-meta.html
<div class="article-meta">
- <a ui-sref="app.profile({ username:$ctrl.article.author.username })">
+ <a ui-sref="app.profile.main({ username:$ctrl.article.author.username })">
<img ng-src="{{$ctrl.article.author.image}}" />
</a>
<div class="info">
<a class="author"
- ui-sref="app.profile({ username:$ctrl.article.author.username })"
+ ui-sref="app.profile.main({ username:$ctrl.article.author.username })"
ng-bind="$ctrl.article.author.username">
</a>
<span class="date"
ng-bind="$ctrl.article.createdAt | date: 'longDate' ">
</span>
</div>
<ng-transclude></ng-transclude>
</div>
article/comment.html
<div class="card">
<div class="card-block">
<p class="card-text" ng-bind="::$ctrl.data.body"></p>
</div>
<div class="card-footer">
<a class="comment-author"
- ui-sref="app.profile({ username: $ctrl.data.author.username })">
+ ui-sref="app.profile.main({ username: $ctrl.data.author.username })">
<img ng-src="{{::$ctrl.data.author.image}}" class="comment-author-img" />
</a>
<a class="comment-author"
- ui-sref="app.profile({ username: $ctrl.data.author.username })"
+ ui-sref="app.profile.main({ username: $ctrl.data.author.username })"
ng-bind="::$ctrl.data.author.username">
</a>
<span class="date-posted"
ng-bind="::$ctrl.data.createdAt | date: 'longDate' ">
</span>
<span class="mod-options" ng-show="$ctrl.canModify">
<i class="ion-trash-a" ng-click="$ctrl.deleteCb()"></i>
</span>
</div>
</div>
settings/settings.controller.js
[...]
submitForm() {
this.isSubmitting = true;
this._User.update(this.formData).then(
(user) => {
- this._$state.go('app.profile', {username:user.username});
+ this._$state.go('app.profile.main', {username:user.username});
},
(err) => {
this.isSubmitting = false;
this.errors = err.data.errors;
}
)
}
[...]
Sweet! We're now ready to start showing lists of articles :)
Displaying lists of articles
We'll want to display lists of articles on both the home page and the profile page. To get started, lets build this out on the profile page. The first thing we'll need to do is create a wrapper for querying the server's API for lists of articles.
services/articles.service.js
[...]
// Unfavorite an article
unfavorite(slug) {
return this._$http({
url: this._AppConstants.api + '/articles/' + slug + '/favorite',
method: 'DELETE'
});
}
+ /*
+ Config object spec:
+
+ {
+ type: String [REQUIRED] - Accepts "all", "feed"
+ filters: Object that serves as a key => value of URL params (i.e. {author:"ericsimons"} )
+ }
+ */
+ query(config) {
+
+ // Create the $http object for this request
+ let request = {
+ url: this._AppConstants.api + '/articles' + ((config.type === 'feed') ? '/feed' : ''),
+ method: 'GET',
+ params: config.filters ? config.filters : null
+ };
+ return this._$http(request).then((res) => res.data);
+ }
}
We'll be calling this method from our ArticleListCtrl
later for retrieving the appropriate list of articles. Next up, let's create a template for previewing a list of articles. We'll make this a component so we can use it in multiple places.
components/article-helpers/article-preview.html
<div class="article-preview">
<article-meta article="$ctrl.article">
<favorite-btn
article="$ctrl.article"
class="pull-xs-right">
{{$ctrl.article.favoritesCount}}
</favorite-btn>
</article-meta>
<a ui-sref="app.article({ slug: $ctrl.article.slug })" class="preview-link">
<h1 ng-bind="$ctrl.article.title"></h1>
<p ng-bind="$ctrl.article.description"></p>
<span>Read more...</span>
<ul class="tag-list">
<li class="tag-default tag-pill tag-outline"
ng-repeat="tag in $ctrl.article.tagList">
{{tag}}
</li>
</ul>
</a>
</div>
components/article-helpers/article-preview.component.js
let ArticlePreview = {
bindings: {
article: '='
},
templateUrl: 'components/article-helpers/article-preview.html'
};
export default ArticlePreview;
components/index.js
[...]
+ import ArticlePreview from './article-helpers/article-preview.component';
+ componentsModule.component('articlePreview', ArticlePreview);
export default componentsModule;
Like we mentioned earlier, we also need to show lists of articles on the home page. So lets create a component that can be dropped in and "just works" for retrieving, iterating, and paginating lists of articles.
components/article-helpers/article-list.html
<article-preview
article="article"
ng-repeat="article in $ctrl.list">
</article-preview>
<div class="article-preview"
ng-hide="!$ctrl.loading">
Loading articles...
</div>
<div class="article-preview"
ng-show="!$ctrl.loading && !$ctrl.list.length">
No articles are here... yet.
</div>
profile/profile-articles.html
- <p>Current state is {{ $ctrl.profileState }}</p>
+ <article-list limit="5" list-config="$ctrl.listConfig"></article-list>
profile/profile-articles.component.js
class ProfileArticlesCtrl {
+ constructor(profile, $state, $rootScope) {
'ngInject';
// The profile for this page, resolved by UI Router
this.profile = profile;
this.profileState = $state.current.name.replace('app.profile.', '');
+ // Both favorites and author articles require the 'all' type
+ this.listConfig = { type: 'all' };
+
+ // `main` state's filter should be by author
+ if (this.profileState === 'main') {
+ this.listConfig.filters = {author: this.profile.username};
+ // Set page title
+ $rootScope.setPageTitle('@' + this.profile.username);
+
+ } else if (this.profileState === 'favorites') {
+ this.listConfig.filters = {favorited: this.profile.username};
+ // Set page title
+ $rootScope.setPageTitle(`Articles favorited by ${this.profile.username}`);
+ }
}
}
export default ProfileArticlesCtrl;
components/article-helpers/article-list.component.js
class ArticleListCtrl {
constructor(Articles) {
'ngInject';
this._Articles = Articles;
this.setListTo(this.listConfig);
}
setListTo(newList) {
// Set the current list to an empty array
this.list = [];
// Set listConfig to the new list's config
this.listConfig = newList;
this.runQuery();
}
runQuery() {
// Show the loading indicator
this.loading = true;
// Create an object for this query
let queryConfig = {
type: this.listConfig.type,
filters: this.listConfig.filters || {}
};
// Set the limit filter from the component's attribute
queryConfig.filters.limit = this.limit;
// Run the query
this._Articles
.query(queryConfig)
.then(
(res) => {
this.loading = false;
// Update list and total pages
this.list = res.articles;
}
);
}
}
let ArticleList = {
bindings: {
limit: '=',
listConfig: '='
},
controller: ArticleListCtrl,
templateUrl: 'components/article-helpers/article-list.html'
};
export default ArticleList;
components/index.js
[...]
import ArticlePreview from './article-helpers/article-preview.component';
componentsModule.component('articlePreview', ArticlePreview);
+ import ArticleList from './article-helpers/article-list.component';
+ componentsModule.component('articleList', ArticleList);
export default componentsModule;
Now we should be able to see our ArticleList component on our profile pages!
Adding pagination to lists
If you think about it, pagination functionality just needs to know 2 things: the current number of pages, and which page we're currently on. When a different page is selected, the pagination needs to relay that information to the article-list component.
components/article-helpers/article-list.component.js
[...]
runQuery() {
// Show the loading indicator
this.loading = true;
// Create an object for this query
let queryConfig = {
type: this.listConfig.type,
filters: this.listConfig.filters || {}
};
// Set the limit filter from the component's attribute
queryConfig.filters.limit = this.limit;
+ // If there is no page set, set page as 1
+ if (!this.listConfig.currentPage) {
+ this.listConfig.currentPage = 1;
+ }
+ // Add the offset filter
+ queryConfig.filters.offset = (this.limit * (this.listConfig.currentPage - 1));
// Run the query
this._Articles
.query(queryConfig)
.then(
(res) => {
this.loading = false;
// Update list and total pages
this.list = res.articles;
+ this.listConfig.totalPages = Math.ceil(res.articlesCount / this.limit);
}
);
}
[...]
components/article-helpers/article-list.html
<article-preview
article="article"
ng-repeat="article in $ctrl.list">
</article-preview>
<div class="article-preview"
ng-hide="!$ctrl.loading">
Loading articles...
</div>
<div class="article-preview"
ng-show="!$ctrl.loading && !$ctrl.list.length">
No articles are here... yet.
</div>
+ <list-pagination
+ total-pages="$ctrl.listConfig.totalPages"
+ current-page="$ctrl.listConfig.currentPage"
+ ng-hide="$ctrl.listConfig.totalPages <= 1">
+ </list-pagination>
components/article-helpers/list-pagination.component.js
class ListPaginationCtrl {
constructor() {
'ngInject';
}
pageRange(total) {
let pages = [];
for (var i = 0; i < total; i++) {
pages.push(i + 1)
}
return pages;
}
}
let ListPagination= {
bindings: {
totalPages: '=',
currentPage: '='
},
controller: ListPaginationCtrl,
templateUrl: 'components/article-helpers/list-pagination.html'
};
export default ListPagination;
components/article-helpers/list-pagination.html
<nav>
<ul class="pagination">
<li class="page-item"
ng-class="{active: pageNumber === $ctrl.currentPage }"
ng-repeat="pageNumber in $ctrl.pageRange($ctrl.totalPages)">
<a class="page-link" href="">{{ pageNumber }}</a>
</li>
</ul>
</nav>
components/index.js
[...]
import ArticleList from './article-helpers/article-list.component';
componentsModule.component('articleList', ArticleList);
+ import ListPagination from './article-helpers/list-pagination.component';
+ componentsModule.component('listPagination', ListPagination);
export default componentsModule;
Pagination should now work! However, we need to enable changing pages from the pagination component. We'll do this by using $scope.$emit and $scope.broadcast to send information between controllers.
components/article-helpers/article-list.component.js
class ArticleListCtrl {
- constructor(Articles) {
+ constructor(Articles, $scope) {
'ngInject';
this._Articles = Articles;
this.setListTo(this.listConfig);
+ $scope.$on('setPageTo', (ev, pageNumber) => {
+ this.setPageTo(pageNumber);
+ });
}
setListTo(newList) {
// Set the current list to an empty array
this.list = [];
// Set listConfig to the new list's config
this.listConfig = newList;
this.runQuery();
}
+ setPageTo(pageNumber) {
+ this.listConfig.currentPage = pageNumber;
+
+ this.runQuery();
+ }
[...]
ArticleListCtrl
will listen to the setPageTo
event, which the pagination component will fire when the user wants to view a different page.
components/article-helpers/list-pagination.component.js
class ListPaginationCtrl {
- constructor() {
+ constructor($scope) {
'ngInject';
+ this._$scope = $scope;
}
pageRange(total) {
[...]
}
+ changePage(number) {
+ this._$scope.$emit('setPageTo', number);
+ }
}
[...]
components/article-helpers/list-pagination.html
<nav>
<ul class="pagination">
<li class="page-item"
ng-class="{active: pageNumber === $ctrl.currentPage }"
ng-repeat="pageNumber in $ctrl.pageRange($ctrl.totalPages)"
+ ng-click="$ctrl.changePage(pageNumber)">
<a class="page-link" href="">{{ pageNumber }}</a>
</li>
</ul>
</nav>
Cool - it all works! Last but not least, lets drop the article-list component into our home page :)
Reusing the article-list component on the home page
There are three article-list configurations that we want to implement on the home page: feed articles (articles that were authored by users you follow), global articles (all articles that have been posted to the site), and articles that contain a certain tag. We already have the functionality for feed articles and global articles, so lets work on showing articles with specific tags.
The first thing we need to do is populate the tag list on the homepage from the server.
services/tags.service.js
export default class Tags {
constructor(AppConstants, $http) {
'ngInject';
this._AppConstants = AppConstants;
this._$http = $http;
}
getAll() {
return this._$http({
url: this._AppConstants.api + '/tags',
method: 'GET',
}).then((res) => res.data.tags);
}
}
services/index.js
[...]
import CommentsService from './comments.service';
servicesModule.service('Comments', CommentsService);
+ import TagsService from './tags.service';
+ servicesModule.service('Tags', TagsService);
export default servicesModule;
home/home.controller.js
class HomeCtrl {
constructor(AppConstants) {
'ngInject';
this.appName = AppConstants.appName;
}
}
export default HomeCtrl;
home/home.html
<!-- Sidebar where popular tags are listed -->
<div class="col-md-3">
<div class="sidebar">
<p>Popular Tags</p>
- <div class="tag-list">
- <a href="" class="tag-default tag-pill">
- Tag One
- </a>
- <a href="" class="tag-default tag-pill">
- Tag Two
- </a>
- </div>
+ <div class="tag-list" ng-show="$ctrl.tags">
+ <a href="" class="tag-default tag-pill"
+ ng-repeat="tagName in $ctrl.tags"
+ ng-bind="tagName">
+ </a>
+ </div>
+ <div ng-show="!$ctrl.tagsLoaded">
+ Loading tags...
+ </div>
+ <div class="post-preview"
+ ng-show="$ctrl.tagsLoaded && !$ctrl.tags.length">
+ No tags are here... yet.
+ </div>
</div>
</div>
home/home.controller.js
class HomeCtrl {
- constructor(Tags, AppConstants) {
+ constructor(User, Tags, AppConstants) {
'ngInject';
this.appName = AppConstants.appName;
+ // Get list of all tags
+ Tags
+ .getAll()
+ .then(
+ (tags) => {
+ this.tagsLoaded = true;
+ this.tags = tags
+ }
+ );
+ // Set current list to either feed or all, depending on auth status.
+ this.listConfig = {
+ type: User.current ? 'feed' : 'all'
+ };
}
}
export default HomeCtrl;
home/home.html
[...]
<!-- List the current articles -->
- <div class="article-preview">
- <div class="article-meta">
- <a href=""><img /></a>
- <div class="info">
- <a href="" class="author">BradGreen</a>
- <span class="date">January 20th</span>
- </div>
- <button class="btn btn-outline-primary btn-sm pull-xs-right">
- <i class="ion-heart"></i> 29
- </button>
- </div>
- <a href="" class="preview-link">
- <h1>How to build Angular apps that scale</h1>
- <p>Building web applications is not an easy task. It's even hard to make ones that scale.</p>
- <span>Read more...</span>
- <ul class="tag-list">
- <li class="tag-default tag-pill tag-outline">programming</li>
- <li class="tag-default tag-pill tag-outline">web</li>
- </ul>
- </a>
- </div>
+ <article-list limit="10" list-config="$ctrl.listConfig"></article-list>
[...]
We can see the feed now, but we need to have a way to toggle between the different feeds. We'll need to add a listener in the ArticleList component for this.
components/article-helpers/article-list.component.js
class ArticleListCtrl {
constructor(Articles, $scope) {
'ngInject';
this._Articles = Articles;
this.setListTo(this.listConfig);
+ $scope.$on('setListTo', (ev, newList) => {
+ this.setListTo(newList);
+ });
$scope.$on('setPageTo', (ev, pageNumber) => {
this.setPageTo(pageNumber);
});
}
home/home.controller.js
class HomeCtrl {
- constructor(User, Tags, AppConstants) {
+ constructor(User, Tags, AppConstants, $scope) {
'ngInject';
this.appName = AppConstants.appName;
+ this._$scope = $scope;
// Get list of all tags
Tags
.getAll()
.then(
(tags) => {
this.tagsLoaded = true;
this.tags = tags
}
);
// Set current list to either feed or all, depending on auth status.
this.listConfig = {
type: User.current ? 'feed' : 'all'
};
}
+ changeList(newList) {
+ this._$scope.$broadcast('setListTo', newList);
+ }
}
export default HomeCtrl;
[...]
<!-- Main view - contains tabs & article list -->
<div class="col-md-9">
<!-- Tabs for toggling between feed, article lists -->
<div class="feed-toggle">
<ul class="nav nav-pills outline-active">
<li class="nav-item" show-authed="true">
- <a href="" class="nav-link active">
+ <a href="" class="nav-link"
+ ng-class="{ active: $ctrl.listConfig.type === 'feed' }"
+ ng-click="$ctrl.changeList({ type: 'feed' })">
Your Feed
</a>
</li>
<li class="nav-item">
- <a href="" class="nav-link">
+ <a href="" class="nav-link"
+ ng-class="{ active: $ctrl.listConfig.type === 'all' && !$ctrl.listConfig.filters }"
+ ng-click="$ctrl.changeList({ type: 'all' })">
Global Feed
</a>
</li>
+ <li class="nav-item" ng-show="$ctrl.listConfig.filters.tag">
+ <a href="" class="nav-link active">
+ <i class="ion-pound"></i> {{$ctrl.listConfig.filters.tag}}
+ </a>
+ </li>
</ul>
</div>
<!-- List the current articles -->
<article-list limit="10" list-config="$ctrl.listConfig"></article-list>
</div>
<!-- Sidebar where popular tags are listed -->
<div class="col-md-3">
<div class="sidebar">
<p>Popular Tags</p>
<div class="tag-list" ng-show="$ctrl.tags">
<a href="" class="tag-default tag-pill"
+ ng-click="$ctrl.changeList({ type: 'all', filters: { tag: tagName } })"
ng-repeat="tagName in $ctrl.tags"
ng-bind="tagName">
</a>
</div>
<div ng-show="!$ctrl.tagsLoaded">
Loading tags...
</div>
<div class="post-preview"
ng-show="$ctrl.tagsLoaded && !$ctrl.tags.length">
No tags are here... yet.
</div>
</div>
</div>
[...]
And with that, our application is complete! Nice work!!
Building it in Angular2
Stay tuned! We have an upcoming course that will show you how to recreate Conduit in Angular2 by reusing the code you wrote in Angular 1.5. To stay in the loop, make sure you're subscribed to our newsletter (right below this section) and follow us on Twitter @GoThinkster and/or myself @EricSimons40!