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

In my previous article I’ve described in detail how to implement Rebecca Murphey’s Srchr application using the ExtJs framework. In this article I’ll use the JavascriptMVC framework for the same purpose. We’ll follow the same code flow as in the previous article and start by breaking down the application into smaller javascriptmvc components and then work on each of the main component interactions, one at a time.

Designing the JavascriptMVC application

Let’s start by breaking down the srchr application into a small set of components we will use to bootstrap our development effort:

The folder structure for our application:

To reproduce the steps described in this tutorial you’ll have to download the JavascriptMVC framework and unzip the archive into the example’s root folder. These dependencies are required in order to run the code presented in this article.

Scaffolding the JavascriptMVC components

Let’s use the JavascritpMVC code generators to create the components that we’ll be fleshing out in the following steps:

cd <ROOT_FOLDER>
./js jquery/generate/app srchr
./js jquery/generate/app searchbar
mv searchbar srchr
./js jquery/generate/app history
mv history srchr
./js jquery/generate/app search_results
mv search_results srchr
./js jquery/generate/app tabs
mv tabs srchr

Next we add a little layout coding so we can see something working as soon as possible:

srchr/srchr.html
<!DOCTYPE HTML>
<html lang="en">
        <head>
                <title>srchr</title>
        </head>
        <body> 
                <div id='srchr_application'></div>
                <script type='text/javascript' src='../steal/steal.js?srchr'></script>
        </body>
</html>
srchr/srchr.js
steal(  
        'jquery/controller',
        'jquery/view/ejs',
        'jquery/controller/view',

        './models/models.js')
.then(  
        '//srchr/views/layout.ejs')
.then(  
        'steal/less')
.then(  

        './srchr.less',

        function(){                                     // configure your application

                $.Controller('Srchr.Application', {
                },
                {
                        init: function() {

                                this.element.html('//srchr/views/layout.ejs', {});
                        }
                });

                $('#srchr_application').srchr_application();
        })
srchr/views/layout.ejs
<div class='searchbar'><h3 class='placeholder'>SEARCHBAR PLACEHOLDER</h3></div>
<div class='container'>
        <div class='history'><h3 class='placeholder'>HISTORY PLACEHOLDER</h3></div>
        <div class='search_results'><h3 class='placeholder'>SEARCH RESULTS PLACEHOLDER</h3></div>
</div>
srchr/srchr/srchr.less
body {
        margin: 0;
        font-family: tahoma, arial, verdana, sans-serif;
        #srchr_application {
                position: absolute;
                top: 0;
                right: 0;
                bottom: 0;
                left: 0;
                .searchbar {
                        position: absolute;
                        top: 0;
                        right: 0;
                        left: 0;
                        height: 60px;
                        border: 1px solid #3ba3d0;
                }
                .container {
                        position: absolute;
                        top: 61px;
                        right: 0;
                        bottom: 0;
                        left: 0;
                        border-left: 1px solid #3ba3d0;
                        border-bottom: 1px solid #3ba3d0;
                        border-right: 1px solid #3ba3d0;
                        .history {
                                position: absolute;
                                top: 0;
                                bottom: 0;
                                left: 0;
                                width: 320px;
                                border-right: 1px solid #3ba3d0;
                        }
                        .search_results {
                                position: absolute;
                                top: 0;
                                right: 0;
                                bottom: 0;
                                left: 321px;
                        }
                }
                h3.placeholder {
                        margin: 20px;
                }
        }
}

And here’s the end result of the above code snippets:

Srchr.SearchBar/Srchr.History component interaction

Following the same coding pattern as in the ExtJs article, we will implement the event driven interaction between Srchr.SearchBar and Srchr.History components: Upon the user hitting the search button, a “search” event will be thrown by the Srchr.SearchBar component and caught by the Srchr.Application component that then tells the Srchr.History component to update itself. The relevant changes to our code follow bellow:

srchr/searchbar.js
steal(  
        '//srchr/searchbar/views/searchbar.ejs',

        function(){

                $.Controller('Srchr.Searchbar', {
                },
                {
                        init: function() {

                                this.element.html('//srchr/searchbar/views/searchbar.ejs', {});
                        },

                        '.search click': function(el, ev) {

                                var twitterChecked = $('#twitter').attr('checked'),
                                    answersChecked = $('#answers').attr('checked'),
                                    query = $('#query').val();

                                if(twitterChecked) {

                                        this.element.trigger('search', {
                                                query: query,
                                                service: 'twitter'
                                        });

                                } else if(answersChecked) {

                                        this.element.trigger('search', {
                                                query: query,
                                                service: 'answers'
                                        });

                                } else {

                                        steal.dev.warn('Invalid service');
                                }
                        }
                });
        })
srchr/searchbar/views/searchbar.ejs
<div class='search_controls'>
        <label for='query'>Query:</label>
        <input id='query' type='text' name='query' value=''>
        <button class='search'>Search</button>
        <label for="twitter">Twitter</label>
        <input id='twitter' type='radio' name='service' value='twitter' checked>
        <label for="answers">Answers</label>
        <input id='answers' type='radio' name='service' value='answers'>
</div>
srchr/srchr.js
steal( 
        'jquery/controller',
        'jquery/view/ejs',
        'jquery/controller/view',

        './models/models.js')
.then( 
        'srchr/searchbar',
        'srchr/history'
)
.then( 
        '//srchr/views/layout.ejs')
.then( 
        'steal/less')
.then( 

        './srchr.less',

        function(){ 

                $.Controller('Srchr.Application', {
                },
                {
                        init: function() {

                                this.element.html('//srchr/views/layout.ejs', {});

                                this.find('.searchbar').srchr_searchbar()

                                this.find('.history').srchr_history({
                                        searches: new Srchr.Models.Search.List()
                                });
                        },

                        '.searchbar search': function(el, ev, search) {

                                this.find('.history')
                                        .controller()
                                        .addSearch(search)
                        },
                });

                $('#srchr_application').srchr_application();
        })
srchr/history.js
steal(
        '//srchr/history/views/history.ejs',

        function(){

                $.Controller('Srchr.History', {
                },
                {
                        init: function() {

                                this.element.html(
                                        '//srchr/history/views/history.ejs', {
                                                searches: this.options.searches.retrieve('searches')
                                });
                        },

                        addSearch: function(search) {

                                search.id = +new Date();

                                steal.dev.log(search.id);

                                this.options.searches.push(
                                        new Srchr.Models.Search(search)
                                );

                                this.options.searches.store('searches');
                        },

                        '{searches} add': function(searches, ev, delta) {

                                this.element.html(
                                        '//srchr/history/views/history.ejs', {
                                                searches: searches
                                });
                        },

                        '.remove click': function(el, ev) {

                                var search = el.closest('.search').model();

                                this.options.searches.remove(search.id);

                                this.options.searches.store('searches');
                        },
                        '{searches} remove': function(searches, ev) {

                                this.element.html(
                                        '//srchr/history/views/history.ejs', {
                                                searches: searches
                                });
                        },

                        '.search mouseenter': function(el, ev) {

                                el.addClass('hover');
                        },

                        '.search mouseleave': function(el, ev) {

                                el.removeClass('hover');
                        }
                });
        })
srchr/history/history.ejs
<% for(var i=0; i<searches.length; i++) {
        var s = searches[i]; %>

        <div class='search' <%= s %>>
                <span class='query'><%= s.query %></span>
                (<span class='service'><%= s.service %></span>)
                <span class='remove'>X</span>
        </div>

<% } %>
srchr/models/models.js
steal( './search.js', 
       './search_list.js')
srchr/models/search.js
steal('jquery/model', function(){

        $.Model('Srchr.Models.Search',
        {
        },
        {
        });
})
srchr/models/search_list.js
steal('jquery/model/list/cookie', function($){
        $.Model.List.Cookie('Srchr.Models.Search.List', {
        },
        {
        });
});
srchr/srchr.less
// ...
#srchr_application {
	// ...
	.searchbar {
		position: absolute;
		top: 0;
		right: 0;
		left: 0;
		height: 60px;
		border: 1px solid #3ba3d0;
		.searchbar_background_gradient;
		.search_controls {
			padding: 20px;
			label {
				margin-left: 20px;
				color: #fff;
			}
			#query {
				width: 200px;
				padding: 2px;
				border: 1px solid #333;
			}
			button {
				cursor: pointer;
			}
			#twitter, #answers {
				margin-left: 10px;
			}
		}
	}
	.container {
		// ...
		.history {
			position: absolute;
			top: 0;
			bottom: 0;
			left: 0;
			width: 320px;
			border-right: 1px solid #3ba3d0;
			.search {
				height: 25px;
				padding: 10px 0 0 20px;
				border-bottom: 1px solid #333;
				cursor: pointer;
				.query {
					font-weight: bold;
				}
				.remove {
					cursor: pointer;
				}
				&.hover {
					background-color: #ffc;
				}
			}
		}
		// ...

The working app so far:

Srchr.SearchBar/Srchr.SearchResults component interaction

We now move on to the Srchr.SearchBar/Srchr.SearchResults component interaction: when the user clicks the search button and Srchr.SearchBar throws the “search” event, Srchr.Application is listening, activates the relevant tab panel and tells the appropriate Srchr.SearchResults component to update itself by searching the choosen service for matching results. The changes to the code follow bellow:

srchr/srchr.js
// ...

.then(  
        'srchr/searchbar',
        'srchr/history',
        'srchr/tabs',
        'srchr/search_results'
)
.then(  
        '//srchr/views/layout.ejs',
        '//srchr/search_results/views/twitter.ejs',
        '//srchr/search_results/views/answers.ejs'
)
.then(  

	// ...

        function(){                                     // configure your application

                $.Controller('Srchr.Application', {
                },
                {
                        init: function() {

				// ...

                                this.find('.tabs_wrapper').srchr_tabs();

                                $('#twitter_panel').srchr_search_results({
                                        model: Srchr.Models.Twitter,
                                        view: '//srchr/search_results/views/twitter.ejs'
                                });
                                $('#answers_panel').srchr_search_results({
                                        model: Srchr.Models.Answers,
                                        view: '//srchr/search_results/views/answers.ejs'
                                });
                        },

                        '.searchbar search': function(el, ev, search) {
				
				// ...

                                this.performSearch(search);
                        },

                        performSearch: function(search) {

                                var service = search && search.service,
                                    tabId = '#'+service+'_panel';

                                this.find('.srchr_tabs')
                                        .controller()
                                        .select(tabId);

                                $(tabId).controller().search(search);
                        }

			// ...
        })
srchr/tabs/tabs.js
steal( 
        function(){
                
                $.Controller('Srchr.Tabs', {
                },
                {
                        init: function() {

                                this.find('.panel').hide();

                                this.select('#twitter_panel');
                        },

                        '.tab a click': function(el, ev) {

                                var tabId = el.attr('href');

                                this.select(tabId);

                                ev.preventDefault();


                        },

                        select: function(tabId) {

                                var curTabEl = this.find('.current'),
                                    curTabId = curTabEl.find('a').attr('href'),
                                    curPanelEl = $(curTabId),
                                    newTabEl = this.getTabEl(tabId),
                                    newPanelEl = $(tabId);

                                curTabEl.removeClass('current');
                                curPanelEl.hide();

                                if(newTabEl) {

                                        newTabEl.addClass('current');
                                        newPanelEl.show();
                                }
                        },

                        getTabEl: function(id) {

                                var tabEls = this.find('.tab');

                                for(var i=0; i<tabEls.length; i++) {

                                        var tabEl = $(tabEls[i]),
                                            tabId = tabEl.find('a').attr('href');

                                        if(tabId === id) return tabEl;
                                }
                        }
                });
        })
srchr/search_results/search_results.js
steal( 
        function(){

                $.Controller('Srchr.SearchResults', {
                },
                {
                        search: function(search) {

                                this.options.model.search(
                                        search,
                                        this.proxy('searchComplete'),
                                        this.proxy('searchError')
                                );
                        },

                        searchComplete: function(results) {

                                this.element.html(this.options.view, {
                                        results: results
                                });
                        },

                        searchError: function(error) {

                                steal.dev.log(error);
                        }
                });
        })
srchr/models/twitter.js
steal('jquery/model', function(){

        $.Model('Srchr.Models.Twitter',
        {
                search: function(params, success, error) {

                        $.ajax({
                                url: 'http://search.twitter.com/search.json',
                                type: 'get',
                                dataType: 'jsonp',
                                data: { q: params.query },
                                success: this.proxy(['searchSuccessful', success]),
                                error: error
                        });
                },

                searchSuccessful: function(data) {

                        var instances = [],
                            results = data && data.results,
                            len = results && results.length;

                        if(results && len) {

                                for(var i=0; i<len; i++) {

                                        var r = results[i];
                                        instances.push( new this(r));
                                }
                        }

                        return [instances];
                }
        },
        {
        });
})
srchr/models/answers.js
steal('jquery/model', function(){

        $.Model('Srchr.Models.Answers',
        {
                search: function(params, success, error) {

                        var yql = 'select * from answers.search where query="' + params.query + '"';
                        yql = yql + ' and type="resolved"';

                        $.ajax({
                                url: 'http://query.yahooapis.com/v1/public/yql?q=' + encodeURIComponent(yql) + '&format=json',
                                type: 'get',
                                dataType: 'jsonp',
                                data: {},
                                success: this.proxy(['searchSuccessful', success]),
                                error: error
                        });
                },

                searchSuccessful: function(data) {

                        var instances = [],
                            query = data && data.query,
                            results = query && query.results,
                            question = results && results.Question,
                            len = question && question.length;

                        if(question && len) {

                                for(var i=0; i<len; i++) {

                                        var q = question[i];
                                        instances.push( new this(q));
                                }
                        }

                        return [instances];
                }
        },
        {
        });
})
srchr/models/models.js
steal( './search.js',
       './search_list.js',
       './twitter.js',
       './answers.js')
srchr/search_results/views/twitter.ejs
<% for(var i=0; i<results.length; i++) {
        var r = results[i]; %>

<div class='search_result clearfix'>
        <div class='avatar'>
                <img src='<%= r.profile_image_url %>'/>
        </div>
        <div class='info'>
                <div class='from_user'><%= r.from_user %></div>
                <div class='text'><%= r.text %></div>
        </div>
</div>

<% } %>
srchr/search_results/views/answers.ejs
<% for(var i=0; i<results.length; i++) {
        var r = results[i]; %>

<div class="search_result answer">
        <div class="info">
                <div class="text"><span class="question">Question:</span><%= r.Content %></div>
                <div class="text"><span class="answer">Answer:</span><%= r.ChosenAnswer %></div>
        </div>
</div>

<% } %>
srchr/srchr.less
// GLOBALS
.searchbar_background_gradient(@startColor: #024a68, @endColor: #0772a1) {
  background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, @startColor), color-stop(100%, @endColor));
  background-image: -webkit-linear-gradient(@startColor, @endColor);
  background-image: -moz-linear-gradient(@startColor, @endColor);
  background-image: -o-linear-gradient(@startColor, @endColor);
  background-image: linear-gradient(@startColor, @endColor);
}
.rounded_corners(@radius: 5px) {
        -moz-border-radius-topright: @radius;
        -moz-border-radius-topleft: @radius;
        -webkit-border-top-right-radius: @radius;
        -webkit-border-top-left-radius: @radius;
        border-top-right-radius: @radius;
        border-top-left-radius: @radius;
}
.clearfix:after{
  clear: both;
  content: ".";
  display: block;
  height: 0;
  visibility: hidden;
  font-size: 0;
}
// STYLES
body {

	// ...

	.search_results {
		position: absolute;
		top: 0;
		right: 0;
		bottom: 0;
		left: 321px;
		.tabs_wrapper {
			position: absolute;
			top: 0;
			right: 0;
			bottom: 0;
			left: 0;
			.tabs {
				clear: both;
				height: 30px;
				margin: 0;
				padding: 0;
				border-bottom: 1px solid #333;
				.tab {
					float: left;
					list-style-type: none;
					margin: 5px 1px 0 0;
					padding: 5px 10px 0 10px;
					border-top: 1px solid #333;
					border-right: 1px solid #333;
					border-left: 1px solid #333;
					.searchbar_background_gradient;
					.rounded_corners;
					&.current {
						a {
							color: #333;
						}
						background-image: none;
						border-bottom: 1px solid #fff;
					}
					a {
						color: #fff;
						text-decoration: none;
					}
				}
			}
			.panel {
				position: absolute;
				top: 30px;
				right: 0;
				bottom: 0;
				left: 0;
				overflow: auto;
				.search_result {
					padding: 15px 0 0 5px;
					border-bottom: 1px solid #333;
					.avatar {
						float: left;
						margin: 0 5px 0 0;
					}
					.info {
						float: left;
						.text {
							max-width: 960px;
							padding: 2px;
						}
					}
					&.answer {
						.info {
							float: none;
							.text {
								.question,
								.answer {
									font-weight: bold;
								}
							}
						}
					}
				}
			}
		}
	}

And this is how the resulting code looks like when we run it:

History/SearchResults ineraction

Finally, whenever the user clicks an History entry, a search event is thrown to be caught by the Main controller that then tells SearchResults view to update itself.

The relevant code changes:

srchr/history/history.js
// ...

init: function() {
	// ...
},

'.search click': function(el, ev) {

	var search = el.model();
	
	this.element.trigger('search', search); 
},

addSearch: function(search) {
	// ...
srchr/srchr.js
// ...

init: function() {
	// ...
},

'.search_bar search': function(el, ev, search) {

	this.find('.history')
		.controller()
		.addSearch(search)

	this.performSearch(search);
},

'.history search': function(el, ev, search) {
	// ...

Final thoughts

The full codebase can be downloaded from here and a live demo can be seen here. In the next article we’ll implement the srchr application using backbonejs framework and requirejs dependency manager. If you liked this article please subscribe to the blog’s feeds to receive other articles on building large client-side javascript applications and javascript development in general.

Thank you for reading this article.

comments powered by Disqus