A project start for practicing using Firebase with AngularJS.
We're going to create a multi-user, real-time forum (RTFM).
- Using Yeoman, create an Angular app. Don't check Sass, use Bootstrap, and don't use any of the Angular-generator defaults.
- Install your NPM dependencies
npm install
. - Install firebase, angularfire and angular-ui-router using bower:
bower install --save firebase angularfire angular-ui-router
. If Bower asks you to 'find a suitable version for angular', pick the latest version of Angular and persist your selection with a!
.
Unable to find a suitable version for angular, please choose one:
1) angular#1.2.6 which resolved to 1.2.6 and is required by angular-mocks#1.2.6, angular-scenario#1.2.6, rtfm
2) angular#>= 1.0.8 which resolved to 1.2.6 and is required by angular-ui-router#0.2.10
3) angular#1.2.21 which resolved to 1.2.21 and is required by angularfire#0.8.0
Prefix the choice with ! to persist it to bower.json
[?] Answer: 3!
- Run
grunt serve
to make sure that your boilerplate install is working. You should see a Yeoman welcome page.
- Open up
app/scripts/app.js
and addfirebase
andui.router
to your module dependencies. - Add a
.config
function and include$stateProvider
and$urlRouterProvider
to your injections. - Stub in routes for
/login
,/threads
and/threads/:threadId
. - Use
$urlRouterProvider.otherwise()
to configure a default URL.
// app.js
'use strict';
angular.module('rtfmApp', ['firebase', 'ui.router']).config(function ($stateProvider, $urlRouterProvider) {
$urlRouterProvider.otherwise('/login');
$stateProvider
.state('login', {
url: '/login'
})
.state('threads', {
url: '/threads'
})
.state('thread', {
url: '/thread/:threadId'
});
});
- Open up
index.html
and switch Yeoman's defaultviews/main.html
view with aui-view
attribute.
OLD
<!-- Add your site or application content here -->
<div class="container" ng-include="'views/main.html'" ng-controller="MainCtrl"></div>
NEW
<!-- Add your site or application content here -->
<div class="container" ui-view></div>
- Create a login view and a login controller using Yeoman.
yo angular:view login
,yo angular:controller login
- Include your new view and controller in your
login
state.
$stateProvider
.state('login', {
url: '/login',
templateUrl: '/views/login.html',
controller: 'LoginCtrl'
})
...
- Make sure
grunt serve
is still running so that you can see your new view. The Yeoman boilerplate view should be replaced with the text 'This is the login view.'
- Create a new file at
/app/env.js
and set it up like so...
window.env = {
"environment": "development",
"firebase": "https://rtfm-demo.firebaseio.com/chris"
};
Feel free to use my rtfm-demo
firebase, or create you own at firebase.com. If you use
my firebase, please change the base to reflect your name rather than 'chris'. For example you could use
https://rtfm-demo.firebaseio.com/supermario
. All this will do is nest your firebase data under supermario
so
that your data doesn't mix with the rest of the group's.
- Import
env.js
in yourindex.html
file so thatwindow.env
is created before the rest of your JS files load.
<!--Environment vars attached to window.env-->
<script src="env.js"></script>
<!-- build:js scripts/vendor.js -->
<!-- bower:js -->
<script src="bower_components/jquery/jquery.js"></script>
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/bootstrap/dist/js/bootstrap.js"></script>
<script src="bower_components/firebase/firebase.js"></script>
<script src="bower_components/firebase-simple-login/firebase-simple-login.js"></script>
<script src="bower_components/angularfire/dist/angularfire.min.js"></script>
<script src="bower_components/angular-ui-router/release/angular-ui-router.js"></script>
<!-- endbower -->
<!-- endbuild -->
- Create an EnvironmentService to make your environment variables injectable into any Angular module using yeoman:
yo angular:service environment-service
'use strict';
angular.module('rtfmApp')
.service('EnvironmentService', function EnvironmentService($window) {
return {
getEnv: function () {
return $window.env;
}
}
});
-
Inject
EnvironmentService
into yourLoginCtrl
and assign your environment to$scope.env
, then read out{{ env }}}
in yourlogin.html
view to confirm that your environment variables are injecting correctly. You should see yourwindow.env
object logged out onto your login view. -
Add
app/env.js
to your
// Login.js
'use strict';
angular.module('rtfmApp')
.controller('LoginCtrl', function ($scope, EnvironmentService) {
$scope.env = EnvironmentService.getEnv();
});
// view/login.html
<p>This is the login view.</p>
<div>
{{ env }}
</div>
- Open up
login.html
and create a text input bound to$scope.username
and a button that callslogMeIn(username)
when clicked.
<p>This is the login view.</p>
<div>
<input type="text" ng-model="username"/>
<button ng-click="logMeIn(username)">Log In</button>
</div>
-
Create the
logMeIn
function in yourLoginCtrl
. Have italert
the username for now. -
Create a function in
EnvironmentService
calledsaveUsername
that accepts a username and saves it to local storage using$window.localStorage.setItem('username', username);
. -
Create another function in
EnvironmentSerice
calledgetUsername
that returns the username with $window.localStorage.getItem('username'); -
Inject
$state
intoLoginCtrl
and use it to forward the user to thethreads
state after login. -
Use Yeoman to create a
views/threads.html
view and aThreadsCtrl
controller. Add the new view and controller to thethreads
state inapp.js
. -
Test your login and make sure that it forwards you to the stubbed threads view.
You're going to want access to the uesrname through the logged-in sections of the app, and you're not going to want a user to access the logged-in portions of the app without having a saved username. An abstract state is a quick and easy way to accomplish this.
- Open
app.js
and create a new state, just after thelogin
state. Call this statesecure
. It will be an abstract state with one resolved injection and one controller. The injection will be calledusername
and the controller will beSecureCtrl
.
// app/scripts/app.js
.state('secure', {
abstract: true,
controller: 'SecureCtrl',
resolve: {
username: function (EnvironmentService) {
return EnvironmentService.getUsername();
}
}
})
...
- Use Yeoman to create
SecureCtrl
. - Open up
app/scripts/controller/secure.js
and injectusername
and$state
into theSecureCtrl
. - If
username
is missing, use$state.go
to redirect to thelogin
state. - Assign
username
to$scope.username
.
// app/scripts/controllers/secure.js
'use strict';
angular.module('rtfmApp')
.controller('SecureCtrl', function ($scope, $state, username) {
if (!username) {
$state.go('login');
}
$scope.username = username;
});
- Open up
app.js
and make thethreads
route and thethread
route child routes tosecure
. Child routes are only instantiated after all parent routes have resolved, and child routes have access to their parent's scope, so we can now secure any route by making it a child tosecure
.
...
.state('secure', {
abstract: true,
controller: 'SecureCtrl',
resolve: {
username: function (EnvironmentService) {
return EnvironmentService.getUsername();
}
}
})
.state('secure.threads', {
url: '/threads',
templateUrl: 'views/threads.html',
controller: 'ThreadsCtrl'
})
.state('secure.thread', {
url: '/thread/:threadId'
})
...
-
Open up
threads.html
and log out{{ username }}
to make sure that the username has resolved correctly. -
Notice that when you log in, you get a warning stating
Error: Could not resolve 'threads' from state 'login'
. This is because your oldthreads
view has been replaced withsecure.threads
, so open uplogin.js
and fix your redirect to look like this:$state.go('secure.threads');
-
When you succeed in hitting the
/threads
state, you'll get a blank screen, because we forgot to add a template with aui-view
to oursecure
abstract state. Parent templates need to know where to render their children, so you'll need to add a template to thesecure
state. It can be as simple as<div ui-view></div>
, or you can create a new template file with aui-view
and include it withtemplateUrl
.
// Add the simplest template to give 'secure' a place to inject it's child templates
.state('secure', {
abstract: true,
template: '<div ui-view>',
controller: 'SecureCtrl',
resolve: {
username: function (EnvironmentService) {
return EnvironmentService.getUsername();
}
}
})
- Use yeoman to create the ThreadService
yo angular:service thread-service
. - Create methods named
getThreads
andgetThread
to generate AngularFire references to all threads and any individual thread. You'll need to injectEnvironmentService
to get your Firebase url and you'll need to inject$firebase
to generate Firebase references (heretofore referred to as "refs").
// app/scripts/controllers/thread-service.js
'use strict';
angular.module('rtfmApp')
.service('ThreadService', function ThreadService(EnvironmentService, $firebase) {
var firebaseUrl = EnvironmentService.getEnv().firebase;
return {
getThreads: function () {
return $firebase(new Firebase(firebaseUrl + '/threads'));
},
getThread: function (threadId) {
return $firebase(new Firebase(firebaseUrl + '/threads/' + threadId));
}
}
});
- Inject the
threadsRef
into theThreadsCtrl
using aresolve
attribute in your router.
.state('secure.threads', {
url: '/threads',
templateUrl: 'views/threads.html',
controller: 'ThreadsCtrl',
resolve: {
threadsRef: function (ThreadService) {
return ThreadService.getThreads();
}
}
})
- Open up your
ThreadsCtrl
located inthreads.js
. AddthreadsRef
to its injections and bindthreads.$asArray()
to scope.
// app/scripts/controllers/threads.js
'use strict';
angular.module('rtfmApp')
.controller('ThreadsCtrl', function ($scope, threadsRef) {
$scope.threads = threadsRef.$asArray();
});
Why $asArray()???
If you read the docs, you'll see
that AngularFire refs generated with $firebase
are meant for certain kinds of low-level Firebase transactions.
You don't want to use raw AngularFire refs very often... you want to use $asArray()
or $asObject()
to
convert the ref into an AngularFire array or an AngularFire object. These "arrays" and objects are designed very
specifically to work with Angular views.
AngularFire "arrays" are not true JavaScript arrays (hence the quotation marks), but they are as close as you'll get to an array with Firebase. Firebase doesn't support JavaScript arrays for some very fundamental reasons related to data integrity... but AngularFire "arrays" provide functionality that is very similar to the JS arrays with which you are familiar.
You'll use $asObject()
when you want to interact with the individual keys of the Firebase ref like you would with
a JS object. For instance, a single thread would be treated as an object so that you could do things like this:
var thread = threadRef.$asObject();
thread.title = "This is a new thread";
thread.$save();
Notice that we you could set the object property thread.title
just as you would any JS object.
- Let's set up
threads.html
with a list of threads, an input and a button to create a new thread, and links to each thread's unique page.
<div>
<p>Threads</p>
<form name="newThreadForm">
<input type="text" ng-model="newThreadTitle" placeholder="New thread title..." required/>
<button ng-disabled="newThreadForm.$invalid" ng-click="createThread(username, newThreadTitle)">Add Thread</button>
</form>
<ul>
<li ng-repeat="thread in threads">
<a ui-sref="secure.thread({threadId: thread.$id})">
<span>{{ thread.title }}</span>
<span>(by {{ thread.username }})</span>
</a>
</li>
</ul>
</div>
- You'll need to create a function in your
ThreadsCtrl
namedcreateThread
. This function must be attached to$scope
and should accept a username and a thread title as arguments. It will then use the AngularFire "array"$add
function to add the new thread to thethreads
array. Once you get this working, you'll be able to add threads in your view and watch them automatically add themselves to the threads list.
'use strict';
angular.module('rtfmApp')
.controller('ThreadsCtrl', function ($scope, threadsRef) {
$scope.threads = threadsRef.$asArray();
$scope.threads.$loaded().then(function (threads) {
console.log(threads);
});
$scope.createThread = function (username, title) {
$scope.threads.$add({
username: username,
title: title
});
}
});
- Create a
ThreadCtrl
andapp/views/thread.html
using Yeoman:yo angular:controller thread
, andyo angular:view thread
. - Add the new controller and view to the
thread
state inapp.js
. Also create a resolve forthread
that uses$stateParams.threadId
andThreadService.getThread()
to inject each thread's AngularFire ref into your newThreadCtrl
.
.state('secure.thread', {
url: '/thread/:threadId',
templateUrl: 'views/thread.html',
controller: 'ThreadCtrl',
resolve: {
threadRef: function (ThreadService, $stateParams) {
return ThreadService.getThread($stateParams.threadId);
}
}
});
- Inject
threadRef
into yourThreadCtrl
and use AngularFire's$asObject
and$bindTo
methods to bind the thread to$scope.thread
.
// app/scripts/controllers/thread.js
'use strict';
angular.module('rtfmApp')
.controller('ThreadCtrl', function ($scope, threadRef) {
var thread = threadRef.$asObject();
thread.$bindTo($scope, 'thread');
});
Why $asObject and $bindTo???
AngularFire refs can get converted into AngularFire "objects". These "objects" can be bound to $scope
using
AngularFire's
$bindTo
function. This sets up 3-way binding from your view, through $scope
and all the way back to your Firebase
data store. You can edit these AngularFire "objects" in place in your view and watch the changes propagate throughout
your entire app.
- Edit
app/views/thread.html
to create a inputs to add comments under the thread as well as read out all existing comments.
<div>
<h1>{{ thread.title }} (by {{ thread.username }})</h1>
<form name="newCommentForm">
<input type="text" ng-model="newCommentText" placeholder="Write a comment..." required/>
<button ng-disabled="newCommentForm.$invalid" ng-click="createComment(username, newCommentText)">Add Comment</button>
</form>
<ul>
<li ng-repeat="comment in comments">{{ comment.username }}: {{ comment.text }}</li>
</ul>
</div>
Notice how we're looping through comment in comments
? We're going to want each thread to have an "array" of
comments in its Firebase data structure. We haven't created the comments
"array" yet, but we can create an
AngularFire ref to it anyway. Firebase will treat that ref as if it already exists, so we can loop through it and add
to it seamlessly. This will require creating a new getComments
method in ThreadService
and injecting this
new commentsRef
into ThreadCtrl
using a resolve
in your secure.thread
state.
This may seem like a lot of steps, but you've already gone through these steps twice with threadsRef
and
threadRef
. The new commentsRef
follows the same pattern.
// app/scripts/services/thread-service.js
'use strict';
angular.module('rtfmApp')
.service('ThreadService', function ThreadService(EnvironmentService, $firebase) {
var firebaseUrl = EnvironmentService.getEnv().firebase;
return {
getThreads: function () {
return $firebase(new Firebase(firebaseUrl + '/threads'));
},
getThread: function (threadId) {
return $firebase(new Firebase(firebaseUrl + '/threads/' + threadId));
},
getComments: function (threadId) {
return $firebase(new Firebase(firebaseUrl + '/threads/' + threadId + '/comments'));
}
}
});
// app/scripts/app.js
.state('secure.thread', {
url: '/thread/:threadId',
templateUrl: 'views/thread.html',
controller: 'ThreadCtrl',
resolve: {
threadRef: function (ThreadService, $stateParams) {
return ThreadService.getThread($stateParams.threadId);
},
commentsRef: function (ThreadService, $stateParams) {
return ThreadService.getComments($stateParams.threadId);
}
}
})
// app/scripts/controllers/thread.js
'use strict';
angular.module('rtfmApp')
.controller('ThreadCtrl', function ($scope, threadRef, commentsRef) {
var thread = threadRef.$asObject();
thread.$bindTo($scope, 'thread');
$scope.comments = commentsRef.$asArray();
$scope.createComment = function (username, text) {
$scope.comments.$add({
username: username,
text: text
});
};
});
Notice that we've added a new $scope.createComment
function. This will get called from the thread.html
view
and add a comment to your AngularFire comments
"array".
This is the seed of a functioning Angular + Firebase application. You could really take it anywhere, but a great first
step would be to use
FirebaseSimpleLogin
to create a real login system rather than the localStorage
hack that we've used here.
You'll want to create users, get the logged in user and offer a "log out" button.
Check out this example user-service if you get stuck. It's got some more advanced code that may look confusing at first, but read through each function and try to understand what it's doing. If you can't understand the function, skip it and circle back later. The important functions in this example are the simple ones.