Creating Rich Interactive Web Apps With KnockOut.js – Part 3
Intro
In Part 1, we introduced the key concepts and patterns including KnockoutJS. In Part 2, we started to build the first increment of our Savings Goal Simulation rich web app. The objectives of this third tutorial are:
- Build up the rest of the application
- Gain more practice with View Models, View Mediators, and KnockoutJS
- Incorporate some basic elements of usability
Here is the overall target structure of the application:
Building Our App Step-By-Step
Here is what the app will look like at the end of this tutorial:
Here is an outline of the approach:
# | To Do | Area |
---|---|---|
1 | Create the Consumption Scenarios View | View |
2 | Create the Consumption Scenarios ViewModel | View Model |
3 | Create the Consumption Scenarios View Mediator | View Mediator |
4 | Link the Page and the View Mediator | Main App |
5 | Create the Coffee Pricing ViewModel | View Model |
6 | Create the Coffee Pricing ViewMediator | View Mediator |
7 | Link the Page and the Coffee Pricing View Mediator | Main App |
8 | Leveraging Independent View Models | View Model |
9 | Create the Savings Forecast View | View Mediator |
10 | Create the Savings Forecast ViewModel | View Model |
11 | Create the Savings Forecast View Mediator | View Mediator |
12 | Link the Page and the Savings Forecast View Mediator | Main App |
1. Create the Consumption Scenarios View
In our index.html page, let’s add a second section tag to represent our second panel / view: the consumption-scenarios-view.The view will let us enter current habits and see how modifying these habits can yield savings counting towards our saving goal.
<body> <!-- ... --> <section id='consumption-scenarios-view'> </section> </body>
This is what the view will look like:
Let’s add a table with 3 columns, one for the dimension to capture and affect (e.g. drink size), one for current habits, and one for our proposed consumption:
<body> <!-- ... --> <section id='consumption-scenarios-view'> <header>Weekly Coffee Consumption</header> <table> <thead> <tr> <th>Coffee Options</th> <th>Current Habits</th> <th>Proposed Change</th> </tr> </thead> <tbody> <tr> <td>Type:</td> <td></td> <td></td> </tr> <tr> <td>Size:</td> <td></td> <td></td> </tr> <tr> <td>Frequency:</td> <td></td> <td></td> </tr> <tr> <td><label for="drinks-per-day">Drinks per Day:</label></td> <td></td> <td></td> </tr> <tr> <td><label for="cost-per-week">Cost Per Week:</label></td> <td></td> <td></td> </tr> </tbody> </table> </section> </body>
Let’s fill the [Drink] Type row, consisting of sets of radio buttons for Regular, Latte, and Espresso, in both Current and Proposed columns:
<tbody> <tr> <td>Type:</td> <td> <div id="current-drink-type"> <input type="radio" id="current-drink-regular" name="CurrentDrinkType" value="Regular" /> <label for="current-drink-regular">Regular</label><br/> <input type="radio" id="current-drink-latte" name="CurrentDrinkType" value="Latte" /> <label for="current-drink-latte">Latte</label><br/> <input type="radio" id="current-drink-espresso" name="CurrentDrinkType" value="Espresso" /> <label for="current-drink-espresso">Espresso</label><br/> </div> </td> <td> <div id="proposed-drink-type"> <input type="radio" id="proposed-drink-regular" name="ProposedDrinkType" value="Regular" /> <label for="proposed-drink-regular">Regular</label><br/> <input type="radio" id="proposed-drink-latte" name="ProposedDrinkType" value="Latte" /> <label for="proposed-drink-latte">Latte</label><br/> <input type="radio" id="proposed-drink-espresso" name="ProposedDrinkType" value="Espresso" /> <label for="proposed-drink-espresso">Espresso</label><br/> </div> </td> </tr> <!-- ... --> </tbody>
Let’s fill the [Drink] Size row, consisting of sets of radio buttons for Tall, Grande, and Venti, in both Current and Proposed columns:
<tbody> <!-- ... --> <tr> <td>Size:</td> <td> <div id="current-drink-size"> <input type="radio" id="current-size-tall" name="CurrentDrinkSize" value="Tall" /> <label for="current-size-tall">Tall</label><br/> <input type="radio" id="current-size-grande" name="CurrentDrinkSize" value="Grande" /> <label for="current-size-grande">Grande</label><br/> <input type="radio" id="current-size-venti" name="CurrentDrinkSize" value="Venti" /> <label for="current-size-venti">Venti</label><br/> </div> </td> <td> <div id="proposed-drink-size"> <input type="radio" id="proposed-size-tall" name="ProposedDrinkSize" value="Tall" /> <label for="proposed-size-tall">Tall</label><br/> <input type="radio" id="proposed-size-grande" name="ProposedDrinkSize" value="Grande" /> <label for="proposed-size-grande">Grande</label><br/> <input type="radio" id="proposed-size-venti" name="ProposedDrinkSize" value="Venti" /> <label for="proposed-size-venti">Venti</label><br/> </div> </td> </tr> <!-- ... --> </tbody>
Let’s fill the [Drink] Frequency row, consisting of sets of radio buttons for Everyday, Monday-through-Friday, and Other (i.e. N days per week), in both Current and Proposed columns:
<tbody> <!-- ... --> <tr> <td>Frequency:</td> <td> <div id="current-drink-frequency"> <input type="radio" id="current-frequency-everyday" name="CurrentDrinkFrequency" value="Everyday" /> <label for="current-frequency-everyday">Everyday</label><br/> <input type="radio" id="current-frequency-workdays" name="CurrentDrinkFrequency" value="WorkDays" /> <label for="current-frequency-workdays">Mon-Fri</label><br/> <input type="radio" id="current-frequency-other" name="CurrentDrinkFrequency" value="Other" /> <label for="current-frequency-other">Other:</label> <input type="text" id="current-custom-frequency" name="CurrentCustomFrequency" /><br/> </div> </td> <td> <div id="proposed-drink-frequency"> <input type="radio" id="proposed-frequency-everyday" name="ProposedDrinkFrequency" value="Everyday" /> <label for="proposed-frequency-everyday">Everyday</label><br/> <input type="radio" id="proposed-frequency-workdays" name="ProposedDrinkFrequency" value="WorkDays" /> <label for="proposed-frequency-workdays">Mon-Fri</label><br/> <input type="radio" id="proposed-frequency-other" name="ProposedDrinkFrequency" value="Other" /> <label for="proposed-frequency-other">Other:</label> <input type="text" id="proposed-custom-frequency" name="ProposedCustomFrequency" /><br/> </div> </td> </tr> <!-- ... --> </tbody>
Let’s fill the Drinks Per Day row consisting of an simple input field, in both Current and Proposed columns:
<tbody> <!-- ... --> <tr> <td><label for="drinks-per-day">Drinks per Day:</label></td> <td> <input type="text" id="current-drinks-per-day" name="CurrentDrinksPerDay" /> </td> <td> <input type="text" id="proposed-drinks-per-day" name="ProposedDrinksPerDay" /> </td> </tr> <!-- ... --> </tbody>
Let’s fill the Cost Per Week row consisting of <div>: results elements, in both Current and Proposed columns:
<tbody> <!-- ... --> <tr> <td><label for="cost-per-week">Cost Per Week:</label></td> <td> <div id="current-cost-per-week"></div> </td> <td> <div id="proposed-cost-per-week"></div> </td> </tr> <!-- ... --> </tbody>
Let’s fill the Savings Per Week row consisting of <div>: results elements, in the Proposed column:
<tbody> <!-- ... --> <tr> <td><label for="savings-per-week">Savings Per Week:</label></td> <td> &nsbsp; </td> <td> <div id="savings-per-week"></div> </td> </tr> <!-- ... --> </tbody>
2. Create the Consumption Scenarios ViewModel
In the ViewModels folder under the scripts folder, create a file named: sgs.model.consumption-scenarios.js.And let’s add a statement to load our new module in in our LAB.js section:
$LAB // ... .script("scripts/viewmodel/sgs.model.savings-goal.js").wait() .script("scripts/viewmodel/sgs.model.savings-goal.js").wait() .script("scripts/viewmediator/sgs.mediator.savings-goal.js").wait() .script("scripts/viewmodel/sgs.model.consumption-scenarios.js").wait() // ...
Let’s add the code snippet to lazy-create the namespace:
// Lazy initialize our namespace context: sgs.model.consumptionscenarios if (typeof(sgs) == 'undefined') sgs = { } if (typeof(sgs.model) == 'undefined') sgs.model = { } if (typeof(sgs.model.consumptionscenarios) == 'undefined') sgs.model.consumptionscenarios = { }
Let’s create our initializeViewModel function to initialize the view model and its three value models:
- currentConsumption will track our current coffee drinking habits
- proposedConsumption will allow us to experiment to see how much we could save
- savingsPerWeek will show us the difference between proposed and current costs and will be implemented as a dependent observable function
- savingsPerMonth(self explanatory)
For now let’s just assign currentConsumption and proposedConsumption to a ko.observable(null) for now – we’ll change that later (once we create the needed sub-view model).
sgs.model.consumptionscenarios.initializeViewModel = function (pageSettings) { // We can use properties of the pageSettings as default values for any of our ValueModels // If pageSettings are not provided we'll initialize an empty object if (typeof(pageSettings) == 'undefined') var pageSettings = { } var viewModel = { currentConsumption: ko.observable(null), proposedConsumption: ko.observable(null) }; // Stub implementation to be fully implemented later on viewModel.savingsPerWeek = ko.dependentObservable(function() { var result = 0; return result; }, viewModel); viewModel.savingsPerMonth = ko.dependentObservable(function() { var perMonth = this.savingsPerWeek() * 4; var result = Math.round(perMonth * 100) / 100; return result; }, viewModel); return viewModel; }
We need a way to model our coffee consumption.
So we’ll create a new module file named sgs.model.coffee-consumption.js under scripts/viewmodel.
And let’s add a statement to load our new module in in our LAB.js section (right before consumption-scenarios):
$LAB // ... .script("scripts/viewmodel/sgs.model.coffee-consumption.js").wait() .script("scripts/viewmodel/sgs.model.consumption-scenarios.js").wait() // ...
Let’s add the code snippet to lazy-create the namespace:
// Lazy initialize our namespace context: sgs.model.coffeeconsumption if (typeof(sgs) == 'undefined') sgs = { } if (typeof(sgs.model) == 'undefined') sgs.model = { } if (typeof(sgs.model.coffeeconsumption) == 'undefined') sgs.model.coffeeconsumption = { } if (typeof(console) != 'undefined' && console) console.info("sgs.model.coffeeconsumption loading!");
Let’s create our initializeViewModel function to initialize the view model based on the pageSettins as well as a scenario name. We’ll have the following value models:
- scenarioName – will be either Current or Proposed
- drinkType
- drinkSize
- drinkFrequency
- customFrequency
- drinksPerDay
sgs.model.coffeeconsumption.initializeViewModel = function (pageSettings, scenarioName) { // We can use properties of the pageSettings as default values for any of our ValueModels // If pageSettings are not provided we'll initialize an empty object if (typeof(pageSettings) == 'undefined') var pageSettings = { } var viewModel = { scenarioName: ko.observable(scenarioName), drinkType: ko.observable("Regular"), drinkSize: ko.observable("Tall"), drinkFrequency: ko.observable("Everyday"), customFrequency: ko.observable(1), drinksPerDay: ko.observable(1) } return viewModel; }
Let’s add two dependent observable functions named drinkHasStandardSize (will allow us to later restrict the drink size options based on the drink type) and drinkDaysPerWeek (will allow to calculate the cost per week):
sgs.model.coffeeconsumption.initializeViewModel = function (pageSettings, scenarioName) { // ... var viewModel = { // ... } viewModel.drinkHasStandardSize = ko.dependentObservable(function() { return this.drinkType() != 'Espresso'; }, viewModel); viewModel.drinkDaysPerWeek = ko.dependentObservable(function() { var count = 0; switch(this.drinkFrequency()) { case "Everyday": count = 7; break; case "WorkDays": count = 5; break; case "Other": count = this.customFrequency(); break; } return count; }, viewModel); return viewModel; }
I encourage you to test as you develop along , whether or not you are using a TDD or BDD approach (and framework like Jasmine). So refresh your browser and in the Firebug (or Webkit browser) console, instantiate a sgs.model.coffeeconsumption view model and invoke its drinkDaysPerWeek dependent observable like so:
var cs = sgs.model.coffeeconsumption.initializeViewModel() cs.drinkDaysPerWeek()
You can also step through the code in the debugger if you really want to check out what happens.
Now let’s move on and add a stub for a third dependent observable function named: costPerWeek
sgs.model.coffeeconsumption.initializeViewModel = function (pageSettings, scenarioName) { // ... var viewModel = { // ... } // ... // Stub implementation to be fully implemented later on viewModel.costPerWeek = ko.dependentObservable(function() { var result = 0; return result; }, viewModel); return viewModel; }
Later on we’ll come back to this view model and complete the stub implementation of the costPerWeek dependentObservable function to calculate the actual cost of the consumption model (based on pricing).
Now let’s go back to the initializeViewModel of our sgs.model.consumptionscenarios module and update the value model definitions to use custom instantiations of the sgs.model.coffeeconsumption view model so that the relationship between the two view models looks like this:
sgs.model.consumptionscenarios.initializeViewModel = function (pageSettings) { // We can use properties of the pageSettings as default values for any of our ValueModels // If pageSettings are not provided we'll initialize an empty object if (typeof(pageSettings) == 'undefined') var pageSettings = { } var current = sgs.model.coffeeconsumption.initializeViewModel (pageSettings, "Current"); var proposed = sgs.model.coffeeconsumption.initializeViewModel (pageSettings, "Proposed"); var viewModel = { currentConsumption: ko.observable(current), proposedConsumption: ko.observable(proposed) } // Stub implementation to be fully implemented later on viewModel.savingsPerWeek = ko.dependentObservable(function() { var result = 0; return result; }, viewModel); viewModel.savingsPerMonth = ko.dependentObservable(function() { var perMonth = this.savingsPerWeek() * 4; var result = Math.round(perMonth * 100) / 100; return result; }, viewModel); return viewModel; }
So now you have implemented an example of hierarchical view model! Again I invite you to test it using the browser Javascript console and debugger.
3. Create the Consumption Scenarios View Mediator
Let’s tackle the view mediator and connect the parts!
In the ViewMediator folder under the scripts folder, create a file named: sgs.mediator.consumption-scenarios.js.
This is the module where we’ll place our data-binding logic as well as any code related to mediating access between
the page, the consumption scenario view, and its view model.
And let’s add a statement to load our new module in in our LAB.js section:
$LAB // ... .script("scripts/viewmodel/sgs.model.consumption-scenarios.js").wait() .script("scripts/viewmediator/sgs.mediator.consumption-scenarios.js").wait() // ...
Let’s define our namespace:
// Lazy initialize our namespace context: sgs.mediator.consumptionscenarios if (typeof(sgs) == 'undefined') sgs = { } if (typeof(sgs.mediator) == 'undefined') sgs.mediator = { } if (typeof(sgs.mediator.consumptionscenarios) == 'undefined') sgs.mediator.consumptionscenarios = { } if (typeof(console) != 'undefined' && console) console.info("sgs.mediator.consumptionscenarios loading!");
Let’s create our createViewMediator function (per our convention) which we will eventually invoke from the InitializeApplication method in application.js. The createViewMediator function will have 4 responsibilities:
- Instantiate a view model for the consumption scenarios view
- Declare the data-binding between the HTML elements of the view and their corresponding value models in the view model
- Ask KnockoutJS to make the bindings effective (they will be live right after that)
- Save off the view model so we can access it from other parts of the app
So let’s implement the first responsibility to instantiate a view model for the consumption scenario view (using the initializeViewModel function we just created in the consumption scenario view model module).
sgs.mediator.consumptionscenarios.createViewMediator = function (pageSettings) { // Create the view Consumption Scenarios view-specific view model var viewModel = sgs.model.consumptionscenarios.initializeViewModel(pageSettings); }
Now let’s declare the data-bindings we need. For input elements we used the value attribute in our data-bind declaration, but since we have some radio buttons, it is the checked attribute of each radio button we need to target. Note that our binding will have a level of indirection via the currentConsumption value model, which is itself a view model with value models like drinkType. Here is what a snippet looks like for the radio buttons located inside the current-drink-type div:
sgs.mediator.consumptionscenarios.createViewMediator = function (pageSettings) { // Create the view Consumption Scenarios view-specific view model var viewModel = sgs.model.consumptionscenarios .initializeViewModel(pageSettings); // Declare the HTML element-level data bindings for the Current Habits column $("#current-drink-type input[type=radio]") .attr("data-bind", "checked: currentConsumption().drinkType");
Let’s finish up the rest of the bindings:
// ... // Declare the HTML element-level data bindings for the Current Habits column $("#current-drink-type input[type=radio]") .attr("data-bind","checked: currentConsumption().drinkType"); $("#current-drink-size input[type=radio]") .attr("data-bind","checked: currentConsumption().drinkSize"); $("#current-drink-frequency input[type=radio]") .attr("data-bind","checked: currentConsumption().drinkFrequency"); $("#current-custom-frequency") .attr("data-bind","value: currentConsumption().customFrequency"); $("#current-drinks-per-day") .attr("data-bind","value: currentConsumption().drinksPerDay"); $("#current-cost-per-week") .attr("data-bind","text: currentConsumption().costPerWeek"); // Declare the HTML element-level data bindings for the Proposed Change column $("#proposed-drink-type input[type=radio]") .attr("data-bind","checked: proposedConsumption().drinkType"); $("#proposed-drink-size input[type=radio]") .attr("data-bind","checked: proposedConsumption().drinkSize"); $("#proposed-drink-frequency input[type=radio]") .attr("data-bind","checked: proposedConsumption().drinkFrequency"); $("#proposed-custom-frequency") .attr("data-bind","value: proposedConsumption().customFrequency"); $("#proposed-drinks-per-day") .attr("data-bind","value: proposedConsumption().drinksPerDay"); $("#proposed-cost-per-week") .attr("data-bind","text: proposedConsumption().costPerWeek"); $("#savings-per-week") .attr("data-bind","text: savingsPerWeek"); // ...
Let’s add a new kind of binding attribute: “enable“, which allow us to control whether or not the data-bound HTML element should be enabled. This is a very useful binding as it will simplify enabling/disabling rules a lot. Here we’ll use it to control whether or not the drink size radio buttons should be enabled, since an espresso does not typically come in a Tall / Grande / Venti size! (Wow imagine the buzz on Venti!)
Reminder: The syntax of data-bind in KnockoutJS is:
databind="attribute1: valueModel1, attribute2: valueModel2, etc." // comma-separated list
This means that we will need to comma-separate and concatenate our new “attribute: valueModel” binding pair at the end of our existing declaration for the “checked” attribute like so:
// ... // Declare the HTML element-level data bindings for the Current Habits column // ... $("#current-drink-size input[type=radio]") .attr("data-bind","checked: currentConsumption().drinkSize " + ", enable: currentConsumption().drinkHasStandardSize"); // ... // Declare the HTML element-level data bindings for the Proposed Change column // ... $("#proposed-drink-size input[type=radio]") .attr("data-bind","checked: proposedConsumption().drinkSize" + ", enable: proposedConsumption().drinkHasStandardSize"); // ...
Caution: the name of the attribute is “enable” (not “enabled” – I have been bit by that many times)!
Now we’ll ask KnockoutJS to “apply”, i.e. register / enable our bindings.
sgs.mediator.consumptionscenarios.createViewMediator = function (pageSettings) { // ... // Ask KnockoutJS to data-bind the view model to the view var viewNode = $('#consumption-scenarios-view')[0]; ko.applyBindings(viewModel, viewNode); }
Now let’s add the 2 functions to store retrieve our consumption scenarios view model:
sgs.mediator.consumptionscenarios.getViewModel = function() { return $(document).data("sgs.model.consumptionscenarios.viewmodel"); } sgs.mediator.consumptionscenarios.setViewModel = function(viewModel) { $(document).data("sgs.model.consumptionscenarios.viewmodel", viewModel); }
Now we can call the setViewModel function from inside createViewMediator to save off our freshly-created view model.
sgs.mediator.consumptionscenarios.createViewMediator = function (pageSettings) { // ... // Save the view model sgs.mediator.consumptionscenarios.setViewModel(viewModel); if (typeof(console) != 'undefined' && console) console.info("sgs.mediator.coffeeconsumption ready!"); }
If you want to test the mediator, just refresh the browser, and invoke the createViewMediator in the browser Javascript console like so:
sgs.mediator.consumptionscenarios.createViewMediator({})
You will then see the radio buttons get set to their default value. And clicking on the Espresso radio button should cause the Size radio buttons to become disabled.
4. Link the Page and the Consumptions Scenarios View Mediator
To instantiate our new mediator, we just need to add a call to the overall page InitializeApplication function (located in our scripts/application.js) to our consumption scenario createViewMediator function like so:
function InitializeApplication() { if (typeof(console) != 'undefined' && console) console.info("InitializeApplication starting ..."); // Initialize our page-wide settings var pageSettings = { defaultSavingsGoal: 500 } // Create / launch our view mediator(s) sgs.mediator.savingsgoal.createViewMediator(pageSettings); sgs.mediator.consumptionscenarios.createViewMediator(pageSettings); if (typeof(console) != 'undefined' && console) console.info("InitializeApplication done ..."); }
So now you should be able to load your index.html page in Firefox with the Firebug console on and watch the debug statement and finally our new consumption scenarios view displays with our default values:
5. Create the Coffee Pricing ViewModel
Now we need a pricing engine! It needs to ake into account : the drink type and drink size. And we can apply frequency and drinks per day to calculate the cost. To keep logic encapsulated, let’s create a new module file in the scripts/viewmodel folder named sgs.model.coffee-pricing.js. The new module will be responsible for:
- having a default example pricing list for the various coffee options
- providing its own view model with a pricing value model
- loading / saving its content to the browser local storage (using jStorage, a cross-browser HTML 5 local storage abstraction library)
Let’s add the code snippet to lazy-create the namespace:
// Lazy initialize our namespace context: sgs.model.coffeeconsumption if (typeof(sgs) == 'undefined') sgs = { } if (typeof(sgs.model) == 'undefined') sgs.model = { } if (typeof(sgs.model.coffeepricing) == 'undefined') sgs.model.coffeepricing = { } if (typeof(console) != 'undefined' && console) console.info("sgs.model.coffeepricing loading!");
Update the LAB.js section of the index.html page so we can load the new module (before sgs.model.coffee-consumption):
$LAB // ... .script("scripts/viewmodel/sgs.model.coffee-pricing.js").wait() .script("scripts/viewmodel/sgs.model.coffee-consumption.js").wait() // ...
Now we’ll create an examplePricing function which will return a hash of key-value pairs where the key will be a composite of the coffee type and size, and the value an amount.
sgs.model.coffeepricing.examplePricing = function() { var priceList = { "Regular-Tall": 1.40, "Regular-Grande": 1.60, "Regular-Venti": 1.70, "Latte-Tall": 2.55, "Latte-Grande": 3.10, "Latte-Venti": 3.40, "Espresso": 1.75, "EspressoShot": 0.25 } return priceList; }
Now let’s define our initializeViewModel function with a place holder for the price list:
sgs.model.coffeepricing.initializeViewModel = function (pageSettings) { // We can use properties of the pageSettings as default values for any of our ValueModels // If pageSettings are not provided we'll initialize an empty object if (typeof(pageSettings) == 'undefined') var pageSettings = { } var priceList = { } var viewModel = { pricing: ko.observable(priceList) } return viewModel; }
Ok, now we want to check if we have a price list in local storage and lazy-initialize it otherwise. We’ll encapsulate the logic in a function named: getPriceList. We’ll try to retrieve the “coffee-price-list” using $.jStorage.get(key) API:
sgs.model.coffeepricing.getPriceList = function () { // Check if we have ever stored the price list locally var priceList = $.jStorage.get("coffee-price-list"); if (priceList == null) { // If not create an example priceList = sgs.model.coffeepricing.examplePricing(); // Save it off $.jStorage.set("coffee-price-list", priceList); } return priceList; }
So now we can edit our in initializeViewModel function and plug in the getPriceList for the initialization of the pricing value model:
sgs.model.coffeepricing.initializeViewModel = function (pageSettings) { // We can use properties of the pageSettings as default values for any of our ValueModels // If pageSettings are not provided we'll initialize an empty object if (typeof(pageSettings) == 'undefined') var pageSettings = { } // Lazy-initialize the price list var priceList = sgs.model.coffeepricing.getPriceList(); var viewModel = { pricing: ko.observable(priceList) } return viewModel; }
To encapsulate pricing data requests, let’s add a “normal” function (i.e. not an dependent observable) named getCoffeeBeveragePrice to our view model:
sgs.model.coffeepricing.initializeViewModel = function (pageSettings) { // ... viewModel.getCoffeeBeveragePrice = function (drinkType, drinkSize) { var key = drinkType; if (drinkType != 'Espresso' && drinkSize) { key += '-' + drinkSize; } var price = viewModel.pricing()[key]; return price; } return viewModel; }
Once reloading the index.html page, you should be able to experiment with jStorage. To see all keys stored with jStorage (should be an empty array initially):
$.jStorage.index()
Now, let’s make a call to sgs.model.coffeepricing.getPriceList(). This will lazy-initialize the price list and store it. So when asking for the jStorage index a second time the console should display an array with our “coffee-price-list” key. And we can access the value for our key using:
sgs.model.coffeepricing.getPriceList() $.jStorage.index() $.jStorage.get("coffee-price-list")
6. Create the Coffee Pricing ViewMediator
In the Viewmediator folder under the scripts folder, create a file named: sgs.mediator.coffee-pricing.js. This is the module where we’ll place our data-binding logic as well as any code related to mediating access between the page, a future pricing editing view, and its view model.
And let’s add a statement to load our new module in in our LAB.js section:
$LAB // ... .script("scripts/viewmodel/sgs.model.coffee-pricing.js").wait() .script("scripts/viewmediator/sgs.mediator.coffee-pricing.js").wait() // ...
Let’s define our namespace:
// Lazy initialize our namespace context: sgs.mediator.coffeepricing if (typeof(sgs) == 'undefined') sgs = { } if (typeof(sgs.mediator) == 'undefined') sgs.mediator = { } if (typeof(sgs.mediator.coffeepricing) == 'undefined') sgs.mediator.coffeepricing = { } if (typeof(console) != 'undefined' && console) console.info("sgs.mediator.coffeepricing loading!");
Let’s create our createViewMediator function (per our convention) which we will eventually invoke from the InitializeApplication method in application.js. The createViewMediator function will have 3 responsibilities:
- Instantiate a view model for the future pricing editor view
- Ask KnockoutJS to make the bindings effective (they will be live right after that)
- Save off the view model so we can access it from other parts of the app
Note: this mediator will not need to setup any data-bindings with the view, since we are not planning on displaying the pricing. However, the consumption scenario mediator will collaborate with it in order to access its pricing view model.
So let’s implement the first responsibility to instantiate a view model
(using the initializeViewModel function we just created in the coffee pricing view model module).
sgs.mediator.coffeepricing.createViewMediator = function (pageSettings) { // Create the view Pricing Editor view-specific view model var viewModel = sgs.model.coffeepricing.initializeViewModel(pageSettings); }
Now let’s add the 2 functions to store retrieve our coffee pricing view model:
sgs.mediator.coffeepricing.getViewModel = function() { return $(document).data("sgs.model.coffeepricing.viewmodel"); } sgs.mediator.coffeepricing.setViewModel = function(viewModel) { $(document).data("sgs.model.coffeepricing.viewmodel", viewModel); }
Now we can use the setViewModel function inside createViewMediator to save off our freshly-created view model.
sgs.mediator.coffeepricing.createViewMediator = function (pageSettings) { // ... // Save the view model sgs.mediator.coffeepricing.setViewModel(viewModel); if (typeof(console) != 'undefined' && console) console.info("sgs.mediator.coffeepricing ready!"); }
7. Link the Page and the Coffee Pricing View Mediator
To instantiate our new mediator, we just need to add a call to the overall page InitializeApplication function (located in our scripts/application.js) to our coffee pricing createViewMediator function like so:
function InitializeApplication() { if (typeof(console) != 'undefined' && console) console.info("InitializeApplication starting ..."); // Initialize our page-wide settings var pageSettings = { defaultSavingsGoal: 500 } // Create / launch our view mediator(s) sgs.mediator.savingsgoal.createViewMediator(pageSettings); sgs.mediator.coffeepricing.createViewMediator(pageSettings); sgs.mediator.consumptionscenarios.createViewMediator(pageSettings); if (typeof(console) != 'undefined' && console) console.info("InitializeApplication done ..."); }
8. Leveraging Independent View Models
At this point we have created the consumption, scenarios, and pricing view models, but we have not yet “connected” all the parts to allow for the costPerWeek value model of coffeeconsumption to work. Before we proceed let’s review a few requirements and guiding principles:
- The consumptionscenarios view model knows about the current and proposed coffee consumptions
- The coffeeconsumption view model has the costPerWeek dependent observable function
- The coffeepricing view model has the pricing
- The coffeepricing view mediator manages access to its view model
- We want to keep the view models from knowing about the mediators
- Over time, we may want to test different pricing models in each scenario
So to maintain isolation but yet gain some flexibility we will:
- Add a pricing value model to the coffeeconsumption view model – this way costPerWeek will have access to it
- Add a pricing value model to the consumptionscenarios view model – this way it can pass it on each of the current and proposed consumptions models
- Add a subscription to the pricing change in the consumptionscenarios view model so we can update the pricing of the current and proposed consumptions models
- Have the consumption-scenario and coffee pricing view mediators share the pricing view model
Here is what the relationship between the various mediators and model will look like:
First, let’s add a pricing value model to the coffeeconsumption view model (located in scripts/viewmodel/sgs.model.coffeeconsumption.js):
sgs.model.coffeeconsumption.initializeViewModel = function (pageSettings, scenarioName) { // ... var viewModel = { pricing: ko.observable(null), scenarioName: ko.observable(scenarioName), // ... } // ... }
Second, let’s add a pricing value model to the consumptionscenarios view model (located in scripts/viewmodel/sgs.model.consumptionscenarios.js)
sgs.model.consumptionscenarios.initializeViewModel = function (pageSettings) { // ... var viewModel = { pricing: ko.observable(null), currentConsumption: ko.observable(current), proposedConsumption: ko.observable(proposed) } // ... }
Third, let’s add a subscription to pricing value model so we can re-act to pricing model changes and pass the new pricing down to our 2 consumption models:
sgs.model.consumptionscenarios.initializeViewModel = function (pageSettings) { // ... viewModel.pricing.subscribe(function(newPricing) { viewModel.currentConsumption().pricing(newPricing); viewModel.proposedConsumption().pricing(newPricing); }); // ... }
Fourth, let’s have the consumption-scenario view mediator set the pricing based on the view model of the coffee pricing view mediator:
sgs.mediator.consumptionscenarios.createViewMediator = function (pageSettings) { // ... // Save the view model sgs.mediator.consumptionscenarios.setViewModel(viewModel); // Set the pricing based on the Coffee Pricing view model var priceList = sgs.mediator.coffeepricing.getViewModel(); viewModel.pricing(priceList); // ... }
So now we have the bits in place to fully implement the costPerWeek function of the coffeeconsumption view model. (Remember we had just put in a stub implementation returning 0 in step “2. Create the Consumption Scenarios ViewModel”). It’s a matter of performing the calculations like so:
sgs.model.coffeeconsumption.initializeViewModel = function (pageSettings, scenarioName) { // ... viewModel.costPerWeek = ko.dependentObservable(function() { // Get the base price if (this.pricing() == null) { return 0; } var basePrice = this.pricing().getCoffeeBeveragePrice(this.drinkType(), this.drinkSize()); var dailyCost = basePrice * this.drinksPerDay(); var weeklyCost = dailyCost * this.drinkDaysPerWeek(); var result = Math.round(weeklyCost * 100) / 100; return result; }, viewModel); return viewModel; }
And finally we can go back and finish the implementation of the savingsPerWeek function in the consumptionscenarios view model. (Remember we had just put in a stub implementation returning 0 in step “2. Create the Consumption Scenarios ViewModel”)
sgs.model.consumptionscenarios.initializeViewModel = function (pageSettings) { // ... viewModel.savingsPerWeek = ko.dependentObservable(function() { var costDifference = this.currentConsumption().costPerWeek() - this.proposedConsumption().costPerWeek(); var result = Math.round(costDifference * 100) / 100; return result; }, viewModel); // ... return viewModel; }
Now we’re ready to refresh the index.html page, select Grande Latte under Current Habits and Tall Regular under Proposed Changes and you should see the costs update as well as the savings. We now have a functioning Weekly Coffee Consumption panel! :-)
I will leave the following usability additions for you to do:
- Formatting of the costs and saving amounts (dependent observables and Accounting.js – see Part 1 ToDo #9 – Applying Formatting Rules)
- Add visual feedback (using the jQuery highlight effect) for the costs and savings fields see Part 1 ToDo #10 – Applying Visual Feedback Clues
- Overall styling
9. Create the Savings Forecast View
In our index.html page, let’s add a second section tag to represent our third panel / view:
the savings-forecast-view.
The view will help us compare projected savings against our goal.
<body> <!-- ... --> <section id='savings-forecast-view'> </section> </body>
This is what the view will look like:
The view will have three pairs of labels and spans to show calculated values for:
- Savings forecast per month
- Forecast variance from the target monthly savings goal
- Forecasted number of months to achieve the savings goal
<label for="savings-forecast-per-month">Savings Forecast Per Month:</label> <span id="savings-forecast-per-month" ></span><br/> <label for="forecast-variance-per-month">Forecast Variance:</label> <span id="forecast-variance-per-month"></span><br/> <label for="time-to-goal-in-months">Months To Savings Goal:</label> <span id="time-to-goal-in-months"></span><br/>
10. Create the Savings Forecast ViewModel
In the ViewModels folder under the scripts folder, create a file named: sgs.model.savings-forecast.js.
And let’s add a statement to load our new module in in our LAB.js section:
$LAB // ... .script("scripts/viewmodel/sgs.model.savings-forecast.js").wait() // ...
Let’s add the code snippet to lazy-create the namespace:
// Lazy initialize our namespace context: sgs.model.savingsforecast if (typeof(sgs) == 'undefined') sgs = { } if (typeof(sgs.model) == 'undefined') sgs.model = { } if (typeof(sgs.model.savingsforecast) == 'undefined') sgs.model.savingsforecast = { } if (typeof(console) != 'undefined' && console) console.info("sgs.model.savingsforecast loading!");
Let’s create our initializeViewModel function to initialize the view model.
We need two dependent observable functions:
- forecastVariancePerMonth will allow us to experiment to see how much we could save
- timeToGoalInMonths will show us the difference between proposed and current costs and will be implemented as a dependent observable function
sgs.model.savingsforecast.initializeViewModel = function (pageSettings) { // We can use properties of the pageSettings as default values for any of our ValueModels // If pageSettings are not provided we'll initialize an empty object if (typeof(pageSettings) == 'undefined') var pageSettings = { } var viewModel = { }; viewModel.forecastVariancePerMonth = ko.dependentObservable(function() { // TBD }, viewModel); viewModel.timeToGoalInMonths = ko.dependentObservable(function() { // TBD }, viewModel); return viewModel; }
Design Note: in this series of tutorial I have promoted the idea of decoupling all views, models, and mediators (as possible) as it makes the application more modular and it is easier to test all parts independently. So for this view model, instead of requesting the value models from other mediators namely savingsGoalAmount, savingsTargetPerMonth and savingsPerMonth, we will create dedicated value models which will later synchronize with using subscriptions in our mediators – since it is ok for the mediators to collaborate.
sgs.model.savingsforecast.initializeViewModel = function (pageSettings) { // ... var viewModel = { savingsGoalAmount: ko.observable(0), savingsTargetPerMonth: ko.observable(0), savingsPerMonth: ko.observable(0) }; // ... return viewModel; }
Let’s flesh-out our calculations:
sgs.model.savingsforecast.initializeViewModel = function (pageSettings) { // ... viewModel.forecastVariancePerMonth = ko.dependentObservable(function() { var variance = this.savingsPerMonth() - this.savingsTargetPerMonth(); var result = Math.round(variance * 100) / 100; return result; }, viewModel); viewModel.timeToGoalInMonths = ko.dependentObservable(function() { var timeToGoal = 0; var savingsPerMonth = this.savingsPerMonth(); var savingsGoalAmount = this.savingsGoalAmount(); if (savingsPerMonth != 0) { timeToGoal = savingsGoalAmount / savingsPerMonth; } }, viewModel); // ... }
11. Create the Savings Forecast View Mediator
In the Viewmediator folder under the scripts folder, create a file named: sgs.mediator.savings-forecast.js. This is the module where we’ll place our data-binding logic as well as any code related to mediating access between the page, the consumption scenario view, and its view model.
And let’s add a statement to load our new module in in our LAB.js section:
$LAB // ... .script("scripts/viewmodel/sgs.model.savings-forecast.js").wait() .script("scripts/viewmediator/sgs.mediator.savings-forecast.js").wait() // ...
Let’s define our namespace:
// Lazy initialize our namespace context: sgs.mediator.savingsforecast if (typeof(sgs) == 'undefined') sgs = { } if (typeof(sgs.mediator) == 'undefined') sgs.mediator = { } if (typeof(sgs.mediator.savingsforecast) == 'undefined') sgs.mediator.savingsforecast = { } if (typeof(console) != 'undefined' && console) console.info("sgs.mediator.savingsforecast loading!");
Let’s create our createViewMediator function (per our convention) which we will eventually invoke from the InitializeApplication method in application.js. The createViewMediator function will have 5 responsibilities:
- Instantiate a view model for the savings forecast view
- Establish subscriptions for various value models to keep the main view model synchronized
- Declare the data-binding between the HTML elements of the view and their corresponding value models in the view model
- Ask KnockoutJS to make the bindings effective (they will be live right after that)
- Save off the view model so we can access it from other parts of the app
So let’s implement the first responsibility to instantiate a view model for the consumption scenario view (using the initializeViewModel function we just created in the consumption scenario view model module).
sgs.mediator.savingsforecast.createViewMediator = function (pageSettings) { // Create the view Savings Forecast view-specific view model var viewModel = sgs.model.savingsforecast.initializeViewModel(pageSettings); }
Now we need to establish 3 subscriptions so that we can keep our saving forecast view model in synch with value models from the savings goal and consumption scenarios view models. Here is a graphical depiction of what we need:
To make this happen we need the savingsforecast mediator to request specific value models from the view models of the savingsgoal and consumptionsscenarios mediators:
sgs.mediator.savingsforecast.createViewMediator = function (pageSettings) { // ... // Subscribe to changes in savingsGoalAmount and savingsTargetPerMonth // to synchronize our own equivalent value models var savingsGoalModel = sgs.mediator.savingsgoal.getViewModel(); // Initialize the current savingsGoalAmount viewModel.savingsGoalAmount(savingsGoalModel.savingsGoalAmount()); savingsGoalModel.savingsGoalAmount.subscribe(function(newValue) { viewModel.savingsGoalAmount(newValue); }); savingsGoalModel.savingsTargetPerMonth.subscribe(function(newValue) { viewModel.savingsTargetPerMonth(newValue); }); // Subscribe to changes in savingsPerMonth to synchronize our own equivalent value model var consumptionscenariosModel = sgs.mediator.consumptionscenarios.getViewModel(); consumptionscenariosModel.savingsPerMonth.subscribe(function(newValue) { viewModel.savingsPerMonth(newValue); }); // ... }
Now let’s declare the data-bindings we need.
sgs.mediator.savingsforecast.createViewMediator = function (pageSettings) { // Create the view Savings Forecast view-specific view model var viewModel = sgs.model.savingsforecast .initializeViewModel(pageSettings); // Declare the HTML element-level data bindings $("#savings-forecast-per-month") .attr("data-bind","text: savingsPerMonth"); $("#forecast-variance-per-month") .attr("data-bind","text: forecastVariancePerMonth"); $("#time-to-goal-in-months") .attr("data-bind","text: timeToGoalInMonths"); // ...
Now we’ll ask KnockoutJS to “apply”, i.e. register / enable our bindings.
sgs.mediator.savingsforecast.createViewMediator = function (pageSettings) { // ... // Ask KnockoutJS to data-bind the view model to the view var viewNode = $('#savings-forecast-view')[0]; ko.applyBindings(viewModel, viewNode); }
Now let’s add the 2 functions to store retrieve our savings forecast view model:
sgs.mediator.savingsforecast.getViewModel = function() { return $(document).data("sgs.model.savingsforecast.viewmodel"); } sgs.mediator.savingsforecast.setViewModel = function(viewModel) { $(document).data("sgs.model.savingsforecast.viewmodel", viewModel); }
Now we can use the setViewModel function inside createViewMediator to save off our freshly-created view model.
sgs.mediator.savingsforecast.createViewMediator = function (pageSettings) { // ... // Save the view model sgs.mediator.savingsforecast.setViewModel(viewModel); if (typeof(console) != 'undefined' && console) console.info("sgs.mediator.savingsforecast ready!"); }
Again you can try your new mediator using the browser console:
sgs.mediator.savingsforecast.createViewMediator({})
Playing with the simulation should now update the savings-forecast view.
12. Link the Page and the Savings Forecast View Mediator
To instantiate our new mediator, we just need to add a call to the overall page InitializeApplication function (located in our scripts/application.js) to our consumption scenario createViewMediator function like so:
function InitializeApplication() { if (typeof(console) != 'undefined' && console) console.info("InitializeApplication starting ..."); // Initialize our page-wide settings var pageSettings = { defaultSavingsGoal: 500 } // Create / launch our view mediator(s) sgs.mediator.savingsgoal.createViewMediator(pageSettings); sgs.mediator.coffeepricing.createViewMediator(pageSettings); sgs.mediator.consumptionscenarios.createViewMediator(pageSettings); sgs.mediator.savingsforecast.createViewMediator(pageSettings); if (typeof(console) != 'undefined' && console) console.info("InitializeApplication done ..."); }
Now you can refresh your page and should be able to see the new savings forecast view update as you experiment!
Overall Recap
In this part 3 of the tutorial we have added quite a bit more functionality and complexity. Hopefully the modularization, separation into different files and folders, the namespacing, and various patterns are now starting to reveal their purpose and value. ;-)
So here is a quick recap of the steps we followed:
# | To Do | Area (Module) |
---|---|---|
1 | Create the Consumption Scenarios View | View |
2 | Create the Consumption Scenarios ViewModel | View Model |
3 | Create the Consumption Scenarios View Mediator | View Mediator |
4 | Link the Page and the View Mediator | Main App |
5 | Create the Coffee Pricing ViewModel | View Model |
6 | Create the Coffee Pricing ViewMediator | View Mediator |
7 | Link the Page and the Coffee Pricing View Mediator | Main App |
8 | Leveraging Independent View Models | View Model |
9 | Create the Savings Forecast View | View Mediator |
10 | Create the Savings Forecast ViewModel | View Model |
11 | Create the Savings Forecast View Mediator | View Mediator |
12 | Link the Page and the Savings Forecast View Mediator | Main App |
So What?
This part 3 gave you a lot more practice with the core patterns and also showed you the following:
- Create hierarchical view models
- Setup custom subscriptions to keep independent view models synchronized
- Configure multiple attributes inside a data-bind declaration (e.g. checked and enabled)
- Leverage jStorage for local browser data caching
If we step back and look at the modularization of our application so far, we’ll notice that although our models and view mediators are modular, the actual index.html web page has become larger and is not modular at all. Wouldn’t it be nice if we could extract each of our views into external modules? Well, this is exactly what part 4 will cover!
- Leveraging jQuery templates
- Extracting views into templates (and in separate files)
- … and more!
So stay tuned!
References and Resources
Patterns
- Model View – ViewModel Pattern
- MVC Xerox Parc 1978-79
- MVC Pattern
- Observer Pattern
- Publish Subscribe Pattern
- Value Model Pattern
- Data Binding Pattern
- Summary of Namespacing Approaches (by Elijah Manor)
- Namespacing using Object Literals
- Namespacing using Functions
Frameworks And Blogs
- KnockoutJS
- Learn KnockoutJS (Interactive Tutorial / Playground)
- jQuery
- jQuery UI
- jQuery Template
- jQuery Template API
- Knock Me Out (Steve Sanderson’s Blog)
- Knock Me Out (Ryan Niemeyer’s Blog)
- Patterns For Large-Scale Javascript Architecture (Addy Osmani)
Javascript Loaders
jQuery Plugins
Other Javascript Libraries
- Modernizr
- BlockUI
- Accounting.js
- CurrencyMask JS
- Masked Input
- Raphael JS
- gRaphael JS
- jStorage
- Underscore
- Ajax Fixtures
Javascript Test Frameworks
My Other Related Posts:
- Creating Rich Interactive Web Apps With KnockOut.js – Part 1
- Creating Rich Interactive Web Apps With KnockOut.js – Part 2
Full Source Of The Savings Goal Simulator (KnockoutJS Demo)
The whole application is available on Github under techarch / savings-goal-simulator.