Backbone/Marionette App Using Models, Collections and LocalStorage

This is an example of a simple library application which uses Backbone.js Models and Collections, and Marionette.js ItemViews, CollectionViews, and Layouts. It also uses the Backbone.js LocalStorage plugin, for saving data to the browser’s local storage system.

Here is the Javascript file, called “script.js”:

/* define the application */
var app = new Backbone.Marionette.Application();

/* define the region into which the app will be rendered */
app.addRegions({
	libraryRegion: '#libraryApp' // element in the HTML file
});

/* define the module we will use for this app */
app.module('Library',function(module, App, Backbone, Marionette, $, _){

	/* define the model used for a Genre */
	module.GenreModel = Backbone.Model.extend({
		defaults: {
			title: ''
		}
	});

	/* define the collection used for Genres */
	module.GenreCollection = Backbone.Collection.extend({

		/* set the model used in this collection */
		model: module.GenreModel,

		/* define localStorage container for this collection */
		localStorage: new Backbone.LocalStorage("LibraryGenreCollection")
	});

	/* define the model used for an Author */
	module.AuthorModel = Backbone.Model.extend({
		defaults: {
			firstName: '',
			lastName: ''
		}
	});

	/* define the collection used for Authors */
	module.AuthorCollection = Backbone.Collection.extend({
		/* set the model used in this collection */
		model: module.AuthorModel,

		/* define the localStorage container for his collection */
		localStorage: new Backbone.LocalStorage("LibraryAuthorCollection")
	});

	/* define the model used for a Book */
	module.BookModel = Backbone.Model.extend({
		defaults: {
			id: null,
			title: '',
			authorFirst: '',
			authorLast: '',
			genre: ''
		}
	});

	/* define the collection used for Books */
	module.BookCollection = Backbone.Collection.extend({

		/* set the model used in this collection */
		model: module.BookModel,
		localStorage: new Backbone.LocalStorage("LibraryCollection")
	});

	/* the main layout used for this app */
	module.LibraryView = Marionette.LayoutView.extend({

		/* local variables used to store/access collections */
		bookCollection: null,
		authorCollection: null,
		genreCollection: null,

		/* HTML template used for this view */
		template: '#layout-template',

		/* define mouse events */
		events: {
			'click #btnSave' : 'onSaveClicked',
			'click #btnClear' : 'onClearClicked',
			'click #btnClearCache' : 'onClearCacheClicked',
			'change #selectGenreRegion select' : 'onGenreSelected',
			'change #selectAuthorRegion select' : 'onAuthorSelected'
		},

		/* UI shortcuts */
		ui: {
			inputTitle: '#txtTitle', // title input field
			inputAuthorFirst: '#txtAuthorFirst', // author first name input field
			inputAuthorLast: '#txtAuthorLast', // author last name input field
			inputGenre: '#txtGenre', // genre input field
			selectGenre: '#selectGenre', // genre select list
			selectAuthor: '#selectAuthor' // author select list
		},

		/* regions into which other views will be rendered */
		regions: {
			authorRegion: '#selectAuthorRegion', // dropdown list of authors
			genreRegion: '#selectGenreRegion', // dropdown list of genres
			outputRegion: '#outputRegion' // list of books
		},

		/* called on click of SAVE button */
		onSaveClicked: function() {
			this.addBook();
		},

		/* called on click of CLEAR FORM button */
		onClearClicked: function() {
			this.clearForm();
		},

		/* called on click of CLEAR CACHE button.
			this will empty the localstorage cache for this app */
		onClearCacheClicked: function() {
			window.localStorage.clear();
			this.render();
		},

		/* called when a genre is selected from the dropdown menu */
		onGenreSelected: function() {
			var selectedGenre = $('#selectGenre').val();
			
			/* if the selected genre value is blank, do nothing */
			if(selectedGenre === '') return;

			/* update the genre input element with the value pulled from the select element */
			this.ui.inputGenre.val(selectedGenre);
		},

		/* called when an author is selected from the dropdown menu */
		onAuthorSelected: function() {
			
			/* set the values of the first and last name */
			var authorFirst = $('#selectAuthor').val().split(', ')[1];
			var authorLast = $('#selectAuthor').val().split(', ')[0];

			/* if the value is blank, do nothing */
			if(authorFirst === '' && authorLast === '') return;

			/* update the appropriate input elements with the values pulled from the select element */
			this.ui.inputAuthorFirst.val(authorFirst);
			this.ui.inputAuthorLast.val(authorLast);
		},

		/* add a book to the collection */
		addBook: function() {

			/* get values from the form elements */
			var newTitle = this.ui.inputTitle.val(),
				newAuthorFirst = this.ui.inputAuthorFirst.val(),
				newAuthorLast = this.ui.inputAuthorLast.val(),
				newGenre = this.ui.inputGenre.val();

			/* add a new model to the book collection.
				Automatically adds and instance of the 
				module.BookModel type because that is the 
				model associated with the BookCollection */
			this.bookCollection.add({
				id: new Date().getTime(),
				title: newTitle,
				authorFirst: newAuthorFirst,
				authorLast: newAuthorLast,
				genre: newGenre
			});

			/* iterate through the collection and save each model. 
				necessary to do it this way because of how
				Backbone.localStorage works. */
			this.bookCollection.each(function(oBook) {
				oBook.save();
			});

			/* clear the form elements */
			this.clearForm();

			/* add the author to the list of authors */
			this.addAuthor(newAuthorFirst,newAuthorLast);

			/* add the genre to the list of genres */
			this.addGenre(newGenre);
		},

		/* add a genre to the list of genres */
		addGenre: function(sGenre) {

			/* set a state variable */
			var exists = false;

			/* iterate through the collection to see if the genre
				we are attempting to add already exists */
			this.genreCollection.each(function(oGenre) {
				if(oGenre.get('title') === sGenre) exists = true;
			});

			/* if the genre already exists, exit and do nothing */
			if(exists) return;

			/* add the new genre to the collection. Defaults to an
				instance of module.GenreModel, because that is what
				is associated with the module.GenreCollection */
			this.genreCollection.add({
				title: sGenre
			});

			/* iterate through the genres in the collection and save each one */
			this.genreCollection.each(function(oGenre) {
				oGenre.save();
			});
		},

		/* add an author to the list of authors */
		addAuthor: function(sAuthFirst, sAuthLast) {
			/* set a state variable */
			var exists = false;

			/* iterate through the collection to see if the author
				we are attempting to add already exists */
			this.authorCollection.each(function(oAuthor) {
				if(oAuthor.get('firstName') === sAuthFirst && oAuthor.get('lastName') === sAuthLast) exists = true;
			});

			/* if the author already exists, exit and do nothing */
			if(exists) return;

			/* add the new author to the collection. Defaults to an
				instance of module.AuthorModel, because that is what
				is associated with the module.AuthorCollection */
			this.authorCollection.add({
				firstName: sAuthFirst,
				lastName: sAuthLast
			});

			/* iterate through the authors in the collection and save each one */
			this.authorCollection.each(function(oAuthor) {
				oAuthor.save();
			});
		},

		/* clear all of the imput elements in the form */
		clearForm: function() {
			this.ui.inputTitle.val('');
			this.ui.inputAuthorFirst.val('');
			this.ui.inputAuthorLast.val('');
			this.ui.inputGenre.val('');
		},

		/* called when the DOM for this view is ready for manipulation */
		onRender: function(){
			/* save a local reference to the view, for use in
				model and collection callbacks */
			var capturedThis = this;

			/* create a new instance of the book collection */
			this.bookCollection = new module.BookCollection();

			/* load the book collection from local storage */
			this.bookCollection.fetch()
				.fail(function(){ /* something went wrong */
					console.log('failed to load book collection');
				})
				.done(function(){
					/* when loaded, create a new collectionview using it */
					var bookCollectionView = new module.BookCollectionView({collection: capturedThis.bookCollection});

					/* render the collectionview in the appropriate region */
					capturedThis.outputRegion.show(bookCollectionView);
				});
			
			/* create a new instance of the genre collection */
			this.genreCollection = new module.GenreCollection();

			/* load the collection of genres from local storage */
			this.genreCollection.fetch()
				.fail(function() { /* something went wrong */
					console.log('failed to load genre collection');
				})
				.done(function() { /* loaded successfully */

					/* if the genre collection is empty, add an initial element to use as a label */
					if(capturedThis.genreCollection.length === 0) {
						capturedThis.genreCollection.add({
							title: ''
						});
					}
					/* Create a new collectionview using the genreCollection */
					var genreCollectionView = new module.GenreCollectionView({collection: capturedThis.genreCollection});

					/* render the collectionview in the appropriate region */
					capturedThis.genreRegion.show(genreCollectionView);
				});

			/* create a new instance of the author collection */
			this.authorCollection = new module.AuthorCollection();

			/* load the collection of authors from local storage */
			this.authorCollection.fetch()
				.fail(function() { /* something went wrong */
					console.log('failed to load author collection');
				})
				.done(function() { /* loaded succesfully */

					/* if the genre collection is empty, add an initial element to use as a label */
					if(capturedThis.authorCollection.length === 0) {
						capturedThis.authorCollection.add({
							firstName: '',
							lastName: ''
						});
					}
					/* when the collection is loaded, create a new collectionview using it */
					var authorCollectionView = new module.AuthorCollectionView({collection: capturedThis.authorCollection});

					/* render the collectionview in the appropriate region */
					capturedThis.authorRegion.show(authorCollectionView);
				});
		}
	});
	
	/* item view for individual book */
	module.BookItemView = Marionette.ItemView.extend({
		tagName: 'li',
		template: '#book-template',
		events: {
			'click .delete' : 'onDeleteClicked'
		},

		/* when the delete button is clicked, destroy this model
			this removes the model from the parent collection */
		onDeleteClicked: function(){
			this.model.destroy();
		}
	});

	/* collection view for the list of books */
	module.BookCollectionView = Marionette.CollectionView.extend({
		tagName: 'ul',
		childView: module.BookItemView,
		id: 'bookList',

		/* listen for changes in the collection or the models therein */
		modelEvents: {
			'change' : 'onModelChanged'
		},

		/* when the collection or one of the models therein broadcasts a change event,
			re-render this view. In this example, this is called when a book is added
			or removed from the collection. */
		onModelChanged: function() {
			this.render();
		}
	});

	/* item view for an individual genre */
	module.GenreItemView = Marionette.ItemView.extend({
		template: '#genre-template',
		tagName: 'option',

		/* add attributes to this element. Setting a return value as is done
			here allows for attributes to be set 'on the fly'; in this instance
			each element created will have a unique value derived from
			the model associated with this view. */
		attributes: function(){
			return {
				value: this.model.get('title')
			}
		}
	});

	/* define the genre collection view */
	module.GenreCollectionView = Marionette.CollectionView.extend({
		tagName: 'select',
		childView: module.GenreItemView,
		id: 'selectGenre'
	});

	module.AuthorItemView = Marionette.ItemView.extend({
		template: '#author-template',
		tagName: 'option',

		/* add attributes to this element. Setting a return value as is done
			here allows for attributes to be set 'on the fly'; in this instance
			each element created will have a unique value derived from
			the lastName and firstName attributes of the model associated with this view. */
		attributes: function(){
			var authName = this.model.get('lastName') + ', ' + this.model.get('firstName');
			return {
				value: authName
			}
		}
	});

	/* define the author collection view */
	module.AuthorCollectionView = Marionette.CollectionView.extend({
		tagName: 'select',
		childView: module.AuthorItemView,
		id: 'selectAuthor'
	});

	/* add a method which will fire on the initialization
		of this application. In this case, render
		the main view in the region which was defined
		up at the top of this file. */
	module.addInitializer(function(){
		app.libraryRegion.show(new module.LibraryView());
	});

});

/* once the DOM is ready, start the application */
$(document).ready(function() {app.start();});

Here is the HTML file:

<!doctype html>
<html>
	<head>
		<title>Library App Created in Backbone/Marionette Using LocalStorage</title>
		<link rel="stylesheet" href="style.css"/>
	</head>
	<body>
		<!-- Base element for app -->
		<!--
			Don't use the BODY element as the base element, because when the app renders in the BODY
			it will wipe out the template files before the views can pick them up.
		-->
		
		<div id="libraryApp"></div>

		<!-- TEMPLATES -->
		<!-- main layout template -->
		<script type="text/template" id="layout-template">
			<h1>Library App Using LocalStorage</h1>
			<div id="inputRegion">
				<div><label>Title</label><input id="txtTitle" type="text"/></div>
				<div class="author-row">
					<label>Author (last, first)</label><input id="txtAuthorLast" type="text"/><input id="txtAuthorFirst" type="text"/>
					<div id="selectAuthorRegion" class="selectContainer"></div>
				</div>
				<div>
					<label>Genre</label><input id="txtGenre" type="text"/>
					<div id="selectGenreRegion" class="selectContainer"></div>
				</div>
				<p class="buttons">
					<button id="btnSave">Save</button>
					<button id="btnClear">Clear</button>
					<button id="btnClearCache">Clear LocalStorage</button>
				</p>
			</div>
			<div id="outputRegion"></div>
		</script>

		<!-- book line item template -->
		<script type="text/template" id="book-template">
			<span>Title: <%- title %></span>
			<span>Author: <%- authorLast %>, <%- authorFirst %><br/></span>
			<span>Genre: <%- genre %><br/></span>
			<button class="delete">X</button>
		</script>

		<!-- genre option template -->
		<script type="text/template" id="genre-template">
		<%- title %>
		</script>

		<!-- author option template -->
		<script type="text/template" id="author-template">
		<%- lastName %>, <%- firstName %>
		</script>

		<!-- libraries -->
		<script type="text/javascript" src="js/jquery-1.10.2.min.js"></script>
		<script type="text/javascript" src="js/underscore.js"></script>
		<script type="text/javascript" src="js/backbone.js"></script>
		<script type="text/javascript" src="js/backbone.localStorage.js"></script>
		<script type="text/javascript" src="js/backbone.marionette.js"></script>

		<!-- app code -->
		<script type="text/javascript" src="js/script.js"></script>
	</body>
</html>

And here is the style sheet:

#libraryApp {
	width: 600px;
	margin: 0 auto;
}
h1 {
	float:left;
	text-align: center;
	width: 600px;
	margin: 0 auto 10px auto;
	border-bottom: 1px solid #cdcdcd;
}
#inputRegion,#outputRegion {
	float: left;
	width: 100%;
	margin: 0 0 10px 0;
}
#inputRegion > div {
	float: left;
	clear: both;
}
#inputRegion label {
	float: left;
	width: 120px;
}
#inputRegion input {
	float: left;
	width: 200px;
}
#inputRegion .author-row input {
	width: 98px;
}
#inputRegion .selectContainer {
	float: left;
}
#inputRegion select {
	float: left;
	margin-left: 20px;
	width: 200px;
}
.buttons {
	float: left;
	clear: both;
}
#outputRegion ul{
	list-style: none;
	margin: 0;
	padding: 0;
	float: left;
	width: 100%;
	border-top: 1px solid #ccc;
}
#outputRegion li {
	position: relative;
	float: left;
	width: 100%;
	height: 60px;
	border-bottom: 1px solid #ccc;
}
#outputRegion li span {
	float: left;
	width: 80%;
}
#outputRegion li button {
	position: absolute;
	right: 0;
	top: 5px;
}

These are the code libraries used in this demo: