How to build a large, single-page javascript application with AngularJs

In this article, we follow-up on my last post and use angularjs to implement Rebecca Murphey’s javascript’s community crowdsourcing exercise - srchr. We will try to follow the same coding workflow as in previous articles where possible keeping in mind that each framework has its own way of doing things that needs to be taken into account in order to follow the framework’s best practices.

The code for this exercise can be donwloaded from here. There’s also an online demo and a github repository with branches for each of the alternative implementations that can be cloned for easier comparison of the different MVC frameworks.

Let’s start by trying to breakdown srchr (see mockup) into smaller, more manageable components so we can have a base from where we can flesh out the rest of the application.

Designing the AngularJS application

AngularJS differs from the previous frameworks in which it doesn’t make it easy to breakdown our exercise application into natural javascript components - instead, angularjs is a markup driven (vs javascript component driven) framework and in order to design srchr “the angularjs way” we need to breakdown the application into angularjs views (html templates) and organize the remaining components (controllers, services, filters and directives) around them.

Instead of splitting srchr into simpler components we’ll diverge from the implementation workflow followed in the previous articles and begin our effort by first writing the markup for the higher level views and then go ahead and flesh out the markup for each of the sub-views. While writing the markup for the top and then for the sub-views we will use wishfull thinking and pretend we already have implemented the controllers, models and directives embedded in the markup. We’ll then use yeoman to scaffold those components so we can quickly have something working we can iterate from in the rest of the article.

Scaffolding the AngularJS application

First, let’s use the yeoman tool to generate the seed files for our exercise application:

	mkdir srchr
	cd srchr
	yeoman init angular

Next, let’s modify the generated html files so we can start fleshing out the overall layout for our application:

srchr/app/index.html
         <![endif]-->
 
         <!-- Add your site or application content here -->
-        <div class="container" ng-view></div>
+        <div class="container" ng-include="'views/main.html'"></div>
 
         <script src="scripts/vendor/angular.js"></script>
 
srchr/app/views/main.html
<div class="main">
	<div class="search-bar" ng-include="'views/search_bar.html'"></div>
	<div class="search-container">
		<div class="history" ng-include="'views/history.html'"></div>
		<tabs cur-tab="twitter">
			<ul>
				<li class="tab" data-tab-id="twitter">Twitter</li>
				<li class="tab" data-tab-id="answers">Answers</li>
			</ul>
			<div class="panel twitter" ng-include="'views/search_results/twitter.html'"></div>
			<div class="panel answers" ng-include="'views/search_results/answers.html'"></div>
		</tabs>
	</div>
</div>

Notice how we broke srchr/app/views/main.html markup down into four sub-views (search_bar.html, history.html, twitter.html and answers.html) and a tabs directive. By using the magic tool of wishful thinking we ended up with an overall design for our application. Now we need to scaffold all these views and directives so we can get something running as quickly as possible:

srchr/app/views/search_bar.html
<h1 class="placeholder">SEARCH BAR PLACEHOLDER</div>
srchr/app/views/history.html
<h1 class="placeholder">HISTORY PLACEHOLDER</div>
srchr/app/views/search_results/twitter.html
<h1 class="placeholder">TWITTER SEARCH RESULTS PLACEHOLDER</div>
srchr/app/views/search_results/answers.html
<h1 class="placeholder">ANSWERS SEARCH RESULTS PLACEHOLDER</div>

In order to see how our layout will look like with the tabs working let’s go ahead and implement the tabs directive:

	yeoman init angular:directive tabs
srchr/app/scripts/directives/tabs.js
srchrApp.directive('tabs', function() {
        return {
                restrict: 'E',
                scope: {
                        curTab: '@'
                },
                compile: function(cElem, cAttrs) {

                        var ulChildren = cElem.find('ul').children(),
                            children = cElem.children(),
                            // HELPER FUNCTION TO FILTER ELEMENTS BY CLASSNAME 
                            // (BECAUSE jqLITE DOES NOT IMPLEMENT FILTER BY CLASSNAME)
                            filterByClassName = function(els, className) {
                                var result = [];
                                angular.forEach(els, function(el, i) {
                                        if(angular.element(el).hasClass(className)) {
                                                result.push(el);
                                        }
                                });
                                return result.length == 1 ? result[0] : result;
                            },
                            // GET THE TAB (LI) ELEMENTS
                            tabEls = filterByClassName(ulChildren, 'tab'),
                            // GET THE PANEL ELEMENTS
                            panelEls = filterByClassName(children, 'panel');
                        // FOR EACH TAB (LI ELEMENT) DO ...
                        angular.forEach(tabEls, function(tabEl, i) {

                                var $tabEl = angular.element(tabEl),
                                    tabId = $tabEl.attr('data-tab-id'),
                                    $panelEl = angular.element(filterByClassName(panelEls, tabId));

                                    // ... INSTRUMENT IT WITH CLICK HANDLERS THAT SET curTab TO ITS tabId ...
                                    $tabEl.attr('ng-click', 'curTab=\'' + tabId + '\'');
                                    // ... INSTRUMENT IT SO IT HAS current CLASSNAME WHEN SELECTED ...
                                    $tabEl.attr('ng-class', '{current: curTab==\'' + tabId + '\'}');

                                    // ... INSTRUMENT ITS PANEL SO THAT IT SHOWS WHEN curTab EQUALS ITS tabId ...
                                    $panelEl.attr('ng-show', 'curTab==\'' + tabId + '\'');
                                    // ... INSTRUMENT ITS PANEL SO THAT IT HIDES WHEN curTab IS NOT ITS tabId
                                    $panelEl.attr('ng-hide', 'curTab!=\'' + tabId + '\'');
                        });

                        angular.element(cElem).addClass('tabs-wrap');

                        return function link(scope, lElem, lAttrs) {

                                // USE THE DIRECTIVE's cur-tab ATTRIBUTE TO DECIDE 
                                // WHICH TAB TO ACTIVATE WHEN THE APPLICATION LOADS
                                scope.curTab = lAttrs.curTab;
                        };
                }
        };
});

The sass styles needed to give structure and skin to the scaffolded application:

srchr/app/styles/_scss.scss

h1.placeholder {
	font-size: 1.4em;
	padding: 10px;
}

.container {
	position: absolute;
	top: 0;
	right: 0;
	bottom: 0;
	left: 0;
	padding: 0;
	margin: 0;  
	.search-bar {
		height: 60px;
		border-bottom: 1px solid #999;
	}
	.search-container {
		position: absolute;
		top: 61px;
		right: 0;
		bottom: 0;
		left: 0; 
		.history {
			width: 320px;
			height: 100%;
			border-right: 1px solid #999;
		}
		tabs {
			position: absolute;
			top: 0;
			right: 0;
			bottom: 0;
			left: 321px;
			ul {
				list-style: none;
				height: 20px;
				border-bottom: 1px solid #999;
				li {
					float: left;
					height: 100%;
					margin:0 0 0 2px;
					padding: 0 5px;
					border-top: 1px solid #999;
					border-right: 1px solid #999;
					border-left: 1px solid #999;
					font-weight: bold;
					color: #fff;
					background-color: #ccc;
					cursor: pointer;
					&.current {
						font-weight: normal;
						color: #000;
						background-color: #fff;
					}
				}
			}
			.panel {
				position: absolute;
				top: 21px;
				right: 0;
				bottom: 0;
				left: 0;
			}
		}
	}
}

You can see how the scaffolded application looks like in the following image:

Now let’s split the application overall behaviour into three interdependent use cases:

  • Use Case #1: As an user, when I click the search button, the history panel is updated with a new search entry
  • Use Case #2: As an user, when I click the search button, the relevant tab opens and search results show up on the search results panel
  • Use Case #3: As an user, when I select a previous search from the history panel, the relevant tab opens and the search results show up on the results panel

and let’s work on each of these, one at a time.

Use Case #1: As an user, when I click the search button, the history panel is updated with a new search entry

Following the markup driven approach we’ve been developing so far let’s begin by fleshing out the views for srchr/app/views/search_bar.html and srchr/app/views/history.html, using the wishful thinking technique by embedding all the necessary directives in the markup as we go along. After writing down all the necessary markup we’ll use yeoman to scaffold all the controllers and services we need to make Use Case #1 run.

srchr/app/views/search_bar.html

<div ng-controller="SearchBarCtrl">
	<div class="search-box">
		<label for="query">Query</label>
		<input id="query" ng-model="search.query">
		<button ng-click="performSearch()">Search</button>
		<label for="twitter">Twitter</label>
		<input id="twitter" type="radio" name="service" ng-model="search.service" value="twitter" checked>
		<label for="answers">Answers</label>
		<input id="answers" type="radio" name="service" ng-model="search.service" value="answers">
	</div>
</div>

srchr/app/views/main.html

<div class="main" ng-controller="MainCtrl">
        <div class="search-bar" ng-include="'views/search_bar.html'"></div>
        <div class="search-container">
                <div class="history" ng-include="'views/history.html'"></div>
                <tabs cur-tab="twitter">
                        <ul>   
                                <li class="tab" data-tab-id="twitter">Twitter</li>
                                <li class="tab" data-tab-id="answers">Answers</li>
                        </ul>
                        <div class="panel twitter" ng-include="'views/search_results/twitter.html'"></div>
                        <div class="panel answers" ng-include="'views/search_results/answers.html'"></div>
                </tabs>
        </div>
</div>

srchr/app/views/history.html

<div ng-controller="HistoryCtrl">
        <ul class="searches">
                <li class="search" ng-repeat="search in searches"
                                   ng-mouseenter="hover=true" 
                                   ng-mouseleave="hover=false" 
                                   ng-class="{hover: hover}">
                        <span class="query"></span>
                        (<span class="service"></span>)
                        <span class="remove" ng-click="remove(search)">X</span>
                </li>
        </ul>
</div>

Next we use yeoman to scaffold the controllers embedded in the markup:

	yeoman init angular:controller search_bar
	yeoman init angular:controller history

The code for the controllers:

srchr/app/controllers/search_bar.js

srchrApp.controller('SearchBarCtrl', function($scope) {

	// DEFAULT SEARCH SERVICE TO TWITTER
	$scope.search = {
		query: '',
		service: 'twitter'
	};

	// HANDLE THE USER DOING A SEARCH
	$scope.performSearch = function() {

		// LET THE MAIN CONTROLLER KNOW THERE'S A NEW SEARCH TO BE DISPATCHED
		$scope.$emit('search', {
			query: $scope.search.query,
			service: $scope.search.service,
			// THIS SEARCH IS NOT COMING FROM HISTORY
			isNew: true
		});
	}
});

srchr/app/controllers/main.js

srchrApp.controller('MainCtrl', function($scope) {

        // TABS DEFAULT PANEL IS TWITTER SEARCH RESULTS
        $scope.curTab = 'twitter';

        // LISTEN FOR SEARCH EVENTS
        $scope.$on('search', function(ev, search) {

                // PREVENT INFINITE LOOP ON BROADCAST
                if(ev.targetScope == $scope) return;

                // LET HISTORY AND SEARCH RESULTS CONTROLLERS
                // KNOW THERE'S A NEW SEARCH TO BE DISPATCHED
                $scope.$broadcast('search', search);

                // SHOW THE TABS PANEL THAT MATCHES THE SEARCH SERVICE
                $scope.curTab = search.service;
        });
});

srchr/app/controllers/history.js

srchrApp.controller('HistoryCtrl', function($scope, searchStore) {

        // GET THE SEARCH LOCAL STORAGE SERVICE
        var searches = $scope.searches = searchStore.get();

        // LISTEN FOR SEARCH EVENTS
        $scope.$on('search', function(ev, search) {

                // IS THIS COMING FROM SEARCH BAR?
                if(search.isNew) {

                        // IF SO THEN UPDATE HISTORY'S SEARCH LIST ...
                        searches.push(search);

                        // ... AND UPDATE LOCAL STORAGE
                        searchStore.put(searches);
                }
        });

        // HANDLE THE USER REMOVING A SEARCH
        $scope.remove = function(search) {

                // REMOVE THE SEARCH FROM HISTORY'S SEARCH LIST ...
                searches.splice(searches.indexOf(search), 1);

                // ... AND UPDATE LOCAL STORAGE
                searchStore.put(searches);
        }
});

Bellow is a diagram showing how the above controllers will cooperate in order to dispatch a search initiated by the user. The idea is that SearchBarCtrl will handle the user interaction with the search button by triggering a semantic search event. This event will then be caught by MainCtrl that then forwards it to HistoryCtrl which does its jobs by updating the search entry list.

Notice we a reference to the searchStore service, a localStorage wrapper we use to persist search history across page refreshes. AngularJS uses dependency injection to provide this service to HistoryCtrl. Let’s go ahead and scaffold this service using yeoman:

	yeoman init angular:service search_store

srchr/app/scripts/services/search_store.js

srchrApp.factory('searchStore', function() {
    
  var STORAGE_ID = 'srchr-angularjs';
    
  return {

    get: function() {

      return JSON.parse(localStorage.getItem(STORAGE_ID) || '[]');
    },
    put: function(searches) {

        localStorage.setItem(STORAGE_ID, JSON.stringify(searches));
    }
  };
});

The changes to the sass files (changes highlighted):

srchr/app/styles/_srchr.scss

...
.search-bar {
	height: 60px;
	background-color: #ccc;
	border-bottom: 1px solid #aaa;
	.search-box { 
		padding: 18px;
		input {
			border: 1px solid #aaa;
		}
	}
}
.search-container {
	position: absolute;
	top: 61px;
	right: 0;
	bottom: 0;
	left: 0;
	.history {
		width: 320px;
		height: 100%;
		border-right: 1px solid #aaa;
		.searches {
			.search {
				padding: 10px;
				border-bottom: 1px solid #999;
				cursor: pointer;
				.service {
					font-weight: bold;
				}
				.remove {
					display: inline-block;
					margin: 0 0 0 5px;
					font-weight: bold;
					cursor: pointer;
				}
				&.hover {
					background-color: #ffc;
				}
			}
		}
	}
	tabs {
		position: absolute;
		...

You can see how the application looks so far in the image bellow:

Use Case #2: As an user, when I click the search button, the relevant tab opens and search results show up on the search results panel

Now we move on to Use Case #2 and implement the views for srchr/app/views/search_results/twitter.html and srchr/app/views/search_results/answers.html adding the necessary controller and (other) directives as we flesh out the markup.

srchr/app/views/search_results/twitter.html

<div ng-controller="TwitterSearchResultsCtrl">
	<ul class="results">
		<li class="result" ng-repeat="result in results">
			<img class="avatar" ng-src="">
			<div class="info">
				<div class="user-info">
					<span class="user-name"></span>
					<span class="user">@</span>
				</div>
				<div class="text"></div>
			</div>
		</li>
	</ul>
</div>

srchr/app/views/search_results/answers.html

<div ng-controller="AnswersSearchResultsCtrl">
	<ul class="results">
		<li class="result" ng-repeat="result in results">
			<div class="info">
				<div class="question">
					<label>Question:</label>
					<span class="text"></span>
				</div>
				<div class="answer">
					<label class="answer">Answer:</label>
					<span class="text"></span>
				</div>
			</div>
		</li>
	</ul>
</div>

Scaffolding the controllers:

	yeoman init angular:controller twitter_search_results
	yeoman init angular:controller answers_search_results

srchr/app/controllers/twitter_search_results.js

srchrApp.controller('TwitterSearchResultsCtrl', function($scope, twitter) {

        // SEARCH RESULTS EMPTY BY DEFAULT
        $scope.results = [];

        // LISTEN FOR USER INITIATED SEARCH EVENTS
        $scope.$on('search', function(ev, search) {

                // ONLY ACCEPT TWITTER SEARCH EVENTS
                if(search.service != 'twitter') return;

                // QUERY TWITTER SEARCH API
                twitter.search({
                        q: search.query
                    },
                    $scope.searchComplete,
                    $scope.errorSearching
                );
        });

        // IS THE SEARCH SUCCESSFUL?
        $scope.searchComplete = function(results) {

                // IF SO THEN UPDATE THE VIEW
                $scope.results = results;
        };

        // IF THERE WAS AN ERROR SEARCHING ...
        $scope.errorSearching = function(data, status, headers, config) {

                // ... THEN OUTPUT THE ERROR MESSAGE TO THE CONSOLE
                console.log(data);
        };
});

srchr/app/controllers/answers_search_results.js

srchrApp.controller('AnswersSearchResultsCtrl', function($scope, answers) {

        // SEARCH RESULTS EMPTY BY DEFAULT
        $scope.results = [];

        // LISTEN FOR USER INITIATED SEARCH EVENTS
        $scope.$on('search', function(ev, search) {

                // ONLY ACCEPT YAHOO ANSWERS SEARCH EVENTS
                if(search.service != 'answers') return;

                // QUERY YAHOO'S YQL ANSWERS API
                answers.search({
                        q: search.query
                    },
                    $scope.searchComplete,
                    $scope.errorSearching
                );
        });

        // IS THE SEARCH SUCCESSFUL?
        $scope.searchComplete = function(results) {

                // IF SO THEN UPDATE VIEW
                $scope.results = results;
        };

        // IF THERE WAS AN ERROR SEARCHING ...
        $scope.errorSearching = function(data, status, headers, config) {

                // ... THEN OUTPUT THE ERROR MESSAGE TO THE CONSOLE
                console.log(data);
        };
});

We also need to update the tabs directive so that the appropriate tab opens when a search is performed (changes to the file highlighted):

srchr/app/views/main.html

...
<div class="search-container">
	<div class="history" ng-include="'views/history.html'"></div>
	<tabs cur-tab="{{curTab}}">
		<ul>   
			<li class="tab" data-tab-id="twitter">Twitter</li>
			<li class="tab" data-tab-id="answers">Answers</li>
		</ul>
		<div class="panel twitter" ng-include="'views/search_results/twitter.html'"></div>
		<div class="panel answers" ng-include="'views/search_results/answers.html'"></div>
	</tabs>
</div>
...

The following diagram describes the sequence of interactions that takes place between the controllers:

Now all we have to do is to update the scss styles:

srchr/apps/styles/_scrhr.scss

...
tabs {
	...
	.twitter,
	.answers {
		position: absolute;
		top: 21px;
		right: 0;
		bottom: 0;
		left: 0;
		overflow: auto;
		.results {
			border-bottom: none;
		}
	}
	.twitter {
		.results {
			.result {
				position: relative;
				height: 80px;
				border-bottom: 1px solid #999;
				.avatar {
					position: absolute;
					top: 5px;
					width: 64px;
					bottom: 5px;
					left: 5px;
					border: 1px solid #999;
				}
				.info {
					position: absolute;
					top: 5px;
					left: 80px;
					bottom: 5px;
					right: 5px;
					.user-info {
						margin: 0 0 5px 0;
						.user-name {
							display: inline-block;
							margin: 0 0 0 1px;
							font-weight: bold;
						}
						.user {
							display: inline-block;
							margin: 0 0 0 1px;
							color: #999;
						}
					}
					.text {
						margin: 2px 0 0 0;
					}
				}
			}
		}
	}
	.answers {
		.results {
			.result {
				padding: 5px;
				border-bottom: 1px solid #999;
				.info {
					.question, .answer {
						padding: 2px 0 2px 0;
						label {
							font-weight: bold;
						}
						.text {
						}
					}
				}
			}
		}
	}
}
...

And this is how the application looks with all the changes so far:

Use Case #3: As an user, when I select a previous search from the history panel, the relevant tab opens and the search results show up on the results panel

In order to finalize the application we make the following simple changes (changes to the code highlighted):

srchr/app/views/history.html

<div ng-controller="HistoryCtrl">
        <ul class="searches">
                <li class="search" ng-repeat="search in searches"
                                   ng-click="select(search)" 
                                   ng-mouseenter="hover=true" 
                                   ng-mouseleave="hover=false" 
                                   ng-class="{hover: hover}">
                        <span class="query"></span>
                        (<span class="service"></span>)
                        <span class="remove" ng-click="remove(search)">X</span>
                </li>
        </ul>
</div>

srchr/app/scripts/controllers/history.js

srchrApp.controller('HistoryCtrl', function($scope, searchStore) {
	...
        // LISTEN FOR SEARCH EVENTS
        $scope.$on('search', function(ev, search) {
		...
        });

        $scope.select = function(search) {

                search.isNew = false;

                $scope.$emit('search', search);
        }

        // HANDLE THE USER REMOVING A SEARCH
        $scope.remove = function(search) {
		...
        }
});

This simple diagram explains the changes to the code described above:

Final thoughts

In this article we’ve used the AngularJS framework to implement Rebecca Murphey’s javascript community challenge, srchr.

Being a markup driven framework it was not possible to follow with precision the same implementation workflow as we did for the previous frameworks (ExtJS, JavascriptMVC and BackboneJs), due to the fact that markup driven frameworks make it difficult to breakdown the application into a natural set of javascript components.

The solution to this problem was to use the magic tool of wishful thinking and to start designing the views for the main layout first, embedding the directives I wish were available in the markup and postponing their implementation to a later iteration of the application.

The code can be downloaded from here and an online demo can be tried out by clicking this link. In addition, a github repository with a branch for each of the different implementations can be cloned from this github page so you can easely compare the different versions.

In the next article I’ll use the knockoutjs MVC framework to implement the reference application, srchr.

Thank you for reading this article.

comments powered by Disqus