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

This is the first of a series of posts about building large client-side single-page javascript applications using javascript MVC frameworks.

We’ll use Rebecca Murphey’s srchr application as the common theme accross the various MVC frameworks we’ll be using to show the development workflow of building a large single-page javascript application.

Designing the ExtJs application

Let’s start by breaking down the srchr application into smaller, more manageable ExtJs components:

And here’s the file organization we’ll use to build the application:

Scaffolding the ExtJs components

The next step is to quickly scaffold our application components so we can have something upon wich we can incrementally build the larger application:

srchr.js
Ext.application({
	name: 'Srchr',
	appFolder: 'srchr',
	launch: function() {
		Ext.create('Srchr.view.Viewport', {});
	}
});

srchr/view/Viewport.js
Ext.define('Srchr.view.Viewport', {
	extend: 'Ext.Viewport',
	requires: [
		'Srchr.view.SearchBar',
		'Srchr.view.History',
		'Srchr.view.SearchResults'
	],
	layout: {
		type: 'fit'
	},
	initComponent: function() {
		this.items = [
			xtype: 'panel',
			cls: 'srchr',
			this.dockedItems = [
				this.createSearchBar(),
				this.createHistory()
			];
			this.items = [{
				id: 'tabs',
				xtype: 'tabpanel',
				layout: 'fit',
				items: [
					this.createTwitterSearchResults(),
					this.createAnswersSearchResults()
				]
			}]
		];
		this.callParent(arguments);
	},
	createSearchBar: function() {
		this.searchBar = Ext.create('widget.searchbar', {
			dock: 'top',
			height: 60	
		});
		return this.searchBar;
	},
	createHistory: function() {
		this.history = Ext.create('widget.history', {
			id: 'history',
			dock: 'left',
			width: 320
		});
		return this.history;
	},
	createTwitterSearchResults: function() {
		this.twitterSearchResults = Ext.create('widget.searchresults', {
			id: 'twittersearchresults',
			title: 'Twitter Search Results Placeholder'
		});
		return this.twitterSearchResults;
	},
	createAnswersSearchResults: function() {
		this.answersSearchResults = Ext.create('widget.searchresults', {
			id: 'answerssearchresults',
			title: 'Answers Search Results Placeholder'
		});
		return this.answersSearchResults;
	}
});
srchr/view/SearchBar.js
Ext.define('Srchr.view.SearchBar', {
        extend: 'Ext.Component',
        alias: 'widget.searchbar',
        cls: 'srchr-searchbar',
        html: '<h1>SearchBar Placeholder</h1>'
});
srchr/view/History.js
Ext.define('Srchr.view.History', {
        extend: 'Ext.Component',
        alias: 'widget.history',
        cls: 'srchr-history',
        html: '<h1>History Placeholder</h1>'
});
srchr/view/SearchResults.js
Ext.define('Srchr.view.SearchResults', {
        extend: 'Ext.Component',
        alias: 'widget.searchresults',
        cls: 'srchr-searchresults',
        html: '<h1>SearchResults Placeholder</h1>'
});

And this is the resulting bare-bones app:

Implementing the SearchBar/History interaction

Instead of trying to implement everything at the same time let’s pick a part of the application we can develop in a standalone manner: the SearchBar/History components interaction.

Basically, the SearchBar component will throw a ‘search’ event each time the user performs a search on a specific service (twitter or answers), the Main controller will catch the event and then tell the History component to update itself.

The model’s code:

srchr/model/Search.js
Ext.define('Srchr.model.Search', {
        extend: 'Ext.data.Model',
        fields: [
                'query',
                'service'
        ]
});

The store’s code:

srchr/store/Searches.js
Ext.define('Srchr.store.Searches', {
        extend: 'Ext.data.Store',
        proxy: {
                type: 'localstorage',
                id: 'searches'
        }
});

Here’s the code to flesh out the Search component:

srchr/view/SearchBar.js
Ext.define('Srchr.view.SearchBar', {
        extend: 'Ext.form.Panel',
        alias: 'widget.searchbar',
        layout: 'hbox',
        cls: 'srchr-searchbar',
        border: false,
        initComponent: function() {
                this.items = [{
                        xtype: 'textfield',
                        name: 'query',
                        fieldLabel: 'Query',
                        labelWidth: 40,
                        padding: '0 5 0 0',
                },{
                        xtype: 'button',
                        text: 'Search',
                        listeners: {
                                scope: this,
                                click: this.search
                        }
                },{
                        xtype: 'radiofield',
                        id: 'twitter',
                        name: 'service',
                        inputValue: 'twitter',
                        fieldLabel: 'Twitter',
                        labelWidth: 40,
                        padding: '0 5 0 25',
                        listeners: {
                                beforeRender: function(field, options) {
                                        field.setValue(true);
                                }
                        }
                },{
                        xtype: 'radiofield',
                        id: 'answers',
                        name: 'service',
                        inputValue: 'answers',
                        fieldLabel: 'Answers',
                        labelWidth: 40,
                        padding: '0 10 0 5'
                }];
                this.callParent(arguments);
        },
        search: function() {
                var form = this.getForm(),
                    input = form && form.findField('query'),
                    twitter = form && form.findField('twitter'),
                    answers = form && form.findField('answers');
                if(input) {
                        if(twitter.getValue()) {
                                this.fireEvent('search', {
                                        query: input.getValue(),
                                        service: 'twitter',
                                        isNew: true
                                });
                        } else if(answers.getValue()) {
                                this.fireEvent('search', {
                                        query: input.getValue(),
                                        service: 'answers',
                                        isNew: true
                                });
                        } else {
                                console.log('Bad search terms');
                        }
                }
        }
});

The Main controller:

srchr/controller/Main.js
Ext.define('Srchr.controller.Main', {
        extend: 'Ext.app.Controller',
        stores: ['Searches'],
        models: ['Search'],
        views: [
                'SearchBar',
                'History'
        ],
        refs: [ 
                {ref: 'searchBar', selector: '#searchbar'},
                {ref: 'history', selector: '#history'}
        ],
        init: function() {
                this.control({
                        'searchbar': {
                                search: this.search
                        }
                });
        },
        search: function(search) {
                if(search.isNew) {
                        this.getHistory().addSearch(search);
                }
        }
});

And the code for the History component:

srchr/view/History.js
Ext.define('Srchr.view.History', {
        extend: 'Ext.view.View',
        alias: 'widget.history',
        cls: 'srchr-history',
        overItemCls: 'hover',
        initComponent:  function(){
                this.store = Ext.create('Srchr.store.Searches', {
                        id: 'searchesStore',
                        model: 'Srchr.model.Search',
                        autoLoad: true
                });
                this.tpl = new Ext.XTemplate( '<tpl for=".">',
                                '<div class="search">',
                                        '<div class="params"><span class="query">{query}</span>&nbsp;({service})</div>',
                                        '<div class="close"><i class="icon-remove"></i></div>',
                                '</div>',
                        '</tpl>'
                );
                this.itemSelector = 'div.search';
		this.listeners = {
			itemclicked: this.itemClicked
		},
                this.callParent(arguments);
        },
        itemClicked: function(view, search, el, idx, ev) {
                var target = ev.getTarget(),
                    className = target && target.className;
                if( className && /close/.test(className)) {
                        return this.close(search);
                }
        },
        close: function(search) {
                this.store.remove(search);
                this.store.sync();
        },
        addSearch: function(searchParams) {
                this.store.add(new Srchr.model.Search(searchParams));
                this.store.sync();
        }
});

And this is the end result of the previous coding steps:

Implementing the SearchBar/SearchResults interaction

Next we’ll take on the SearchBar/SearchResults components interaction. Every time a ‘search’ event is triggered by the SearchBar component the Main controller is listening, checks for which service we’re going to perform the search, tells the relevant SearchResults component to execute the search and activates the associated tab panel.

Models, stores incremental changes:

srchr/model/TwitterSearchResult.js
Ext.define('Srchr.model.TwitterSearchResult', {
        extend: 'Ext.data.Model',
        fields: ['from_user', 'text', 'profile_image_url']
});
srchr/model/AnswersSearchResult.js
Ext.define('Srchr.model.AnswersSearchResult', {
        extend: 'Ext.data.Model',
        fields: ['UserNick', 'Content', 'ChosenAnswer']
});
srchr/store/TwitterSearchResults.js
Ext.define('Srchr.store.TwitterSearchResults', {
        extend: 'Ext.data.Store',
        model: 'Srchr.model.TwitterSearchResult',
        proxy: {
                type: 'jsonp',
                url: 'http://search.twitter.com/search.json',
                reader: {
                        type: 'json',
                        root: 'results'
                }
        }
});
srchr/store/AnswersSearchResults.js
Ext.define('Ext.data.reader.Answers', {
        extend: 'Ext.data.reader.Json',
        read: function(response) {
                return this.callParent([{root: response.query.count > 0 ? response.query.results.Question : []}]);      
        },
        root: 'root'
});
Ext.define('Srchr.store.AnswersSearchResults', {
        extend: 'Ext.data.Store',
        model: 'Srchr.model.AnswersSearchResult',
        proxy: {
                type: 'jsonp',
                url: 'http://query.yahooapis.com/v1/public/yql',
                reader: Ext.create('Ext.data.reader.Answers', {})
        }
});

The changes to Main controller code:

srchr/controller/Main.js
Ext.define('Srchr.controller.Main', {
        extend: 'Ext.app.Controller',
        requires: ['Ext.util.Format'],
        stores: ['Searches', 'TwitterSearchResults', 'AnswersSearchResults'],
        models: ['Search', 'TwitterSearchResult', 'AnswersSearchResult'],
        views: [
                'SearchBar',
                'History'
        ],
        refs: [
                {ref: 'searchBar', selector: '#searchbar'},
                {ref: 'history', selector: '#history'},
                {ref: 'tabs', selector: '#tabs'},
                {ref: 'twitterSearchResults', selector: '#twittersearchresults'},
                {ref: 'answersSearchResults', selector: '#answerssearchresults'}
        ],
        init: function() {
                this.control({
                        'searchbar': {
                                search: this.search
                        }
                });
        },
        search: function(search) {
                var tabs = this.getTabs(),
                    tabName = search.service + 'SearchResults',
                    tab = this['get' + Ext.util.Format.capitalize(tabName)]();
                tab.search(search);
                tabs.setActiveTab(tab);
                if(search.isNew) {
                        this.getHistory().addSearch(search);
                }
        }
});

Changes to the Viewport component:

srchr/view/Viewport.js
Ext.define('Srchr.view.Viewport', {
        extend: 'Ext.Viewport',
        requires: [
                'Srchr.view.SearchBar',
                'Srchr.view.History',
                'Srchr.view.SearchResults'
        ],
        initComponent: function() {
                this.layout = {
                       type: 'fit'
                };
                this.items = [{
                        xtype: 'panel',
                        cls: 'srchr',
                        layout: {
                                type: 'fit'
                        },
                        dockedItems: [
                                this.createSearchBar(),
                                this.createHistory()
                        ],
                        items: [{
                                id: 'tabs',
                                cls: 'srchr-tabs',
                                xtype: 'tabpanel',
                                layout: {
                                        type: 'fit'
                                },
                                items: [
                                        this.createTwitterSearchResults(),
                                        this.createAnswersSearchResults()
                                ]
                        }]
                }];
                this.callParent(arguments);
        },
        createSearchBar: function() {
                this.searchBar = Ext.create('widget.searchbar', {
                        id: 'searchbar',
                        dock: 'top',
                        height: 60
                });
                return this.searchBar;
        },
        createHistory: function() {
                this.history = Ext.create('widget.history', {
                        id: 'history',
                        dock: 'left',
                        width: 320
                });
                return this.history;
        },
        createTwitterSearchResults: function() {
                this.twitterSearchResults = Ext.create('widget.searchresults', {
                        id: 'twittersearchresults',
                        store: Ext.create('Srchr.store.TwitterSearchResults', {}),
                        tpl: new Ext.XTemplate(
                                '<tpl for=".">',
                                        '<div class="search-result">',
                                                '<div class="avatar">',
                                                        '<img src="{ profile_image_url }">',
                                                '</div>',
                                                '<div class="info">',
                                                        '<div class="from_user">{ from_user }</div>',
                                                        '<div class="text">{ text }</div>',
                                                '</div>',
                                        '</div>',
                                '</tpl>'
                        ),
                        title: 'Twitter Search Results',
                        search: function(search) {
                                this.store.getProxy().extraParams.q = search.query;
                                this.store.load();
                        }
                });
                return this.twitterSearchResults;
        },
        createAnswersSearchResults: function() {
                this.answersSearchResults = Ext.create('widget.searchresults', {
                        id: 'answerssearchresults',
                        store: Ext.create('Srchr.store.AnswersSearchResults', {}),
                        tpl: new Ext.XTemplate(
                                '<tpl for=".">',
                                        '<div class="search-result answer">',
                                                '<div class="info">',
                                                        '<div class="text"><span class="question">Question:</span>{ Content }</div>',
                                                        '<div class="text"><span class="answer">Answer:</span>{ ChosenAnswer }</div>',
                                                '</div>',
                                        '</div>',
                                '</tpl>'
                        ),
                        title: 'Answers Search Results',
                        search: function(search) {
                                var yql = 'select * from answers.search where query="' + search.query + '"';
                                yql = yql + ' and type="resolved"';
                                this.store.getProxy().extraParams.q = yql;
                                this.store.getProxy().extraParams.format = 'json';
                                this.store.load();
                        }
                });
                return this.answersSearchResults;
        }

And finally, changes to SearchResults:

srchr/view/SearchResults.js
Ext.define('Srchr.view.SearchResults', {
        extend: 'Ext.view.View',
        alias: 'widget.searchresults',
        cls: 'srchr-searchresults',
        tpl: '<h1>Search Results</h1>',
        itemSelector: 'search',
        autoScroll: true
});

The end result:

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 changes to the History view component:

srchr/view/History.js
...
itemClicked: function(view, search, el, idx, ev) {

	var target = ev.getTarget(),
	    className = target && target.className;

	if( className && /close/.test(className)) {

		return this.close(search);

	} 

	this.fireEvent('search', search.data);
},
...

And the changes made to the Main controller:

srchr/view/Main.js
...
init: function() {

	this.control({
		'searchbar': {
			search: this.search
		},
		'history': {
			search: this.search
		}
	});     
},
...

Final thoughts

The full codebase can be downloaded from here and a a live demo can be seen here. In the next article we’ll implement the srchr application using the javascriptmvc framework. 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