Wednesday, March 7, 2012

Building Single Page Apps with ASP.NET MVC4 - Part 2 - Advanced Desktop Application

This is the second article in a series about building single page applications using ASP.NET MVC4. I assume that you have read the first part.
  1. Single Page Applications - Part 1 - Basic Desktop application
  2. Single Page Applications - Part 2 - Advanced Desktop Application
  3. Single Page Applications - Part 3 - Basic Mobile Application
  4. Single Page Applications - Part 4 - Sorting, Filtering and Manipulation data with upshot.js
  5. Single Page Applications - Part 5 - With MVC4 RC

In part 1 I recreate the first part of a demo by Steven Sanderson that shows a list of deliveries on screen, allows a user to mark an item as delivered and have the results immediately sent back to the server. In this second part the application will also show the deliveries grouped by customer and uses "Save All" and "Revert All" buttons so that the user can decide when to update the back-end.

Group by Customer

In order to group deliveries by customer, you'll need to a bit of javascript code that can go over the observable 'deliveries' array and group them together based on the referenced customer. This grouped data needs to be observable too. Steven has released the following bit of javascript called arrayUtils.js in order to achieve this:



/// <reference path="knockout.debug.js" />

(function () {
    function firstWhere(array, condition) {
        for (var i = 0; i < array.length; i++)
            if (condition(array[i]))
                return array[i];
    }

    ko.observableArray.fn.groupBy = function (keySelector) {
        if (typeof keySelector === "string") {
            var key = keySelector;
            keySelector = function (x) 
                          { return ko.utils.unwrapObservable(x[key]) };
        }
        var observableArray = this;

        return ko.computed(function () {
            var groups = [], array = observableArray();
            for (var i = 0; i < array.length; i++) {
                var item = array[i],
                existingGroup = firstWhere(groups, function (g) 
                                { return g.key === keySelector(item) });
                if (existingGroup)
                    existingGroup.values.push(item);
                else
                    groups.push({ key: keySelector(item), values: [item] });
            }
            return groups;
        })
    }
})();
You can now add a new datasource to the DeliveriesViewModel.js like this:
    self.deliveries = self.dataSource.getEntities();
    self.deliveriesForCustomer = self.deliveries.groupBy("Customer");
To show this new information on screen, add following code to index.cshtml. The groupBy operator has put every customer object on the "key" parameter and all his deliveries on the "values" parameter.
    <h3>Customers</h3>
    <ul data-bind="foreach: deliveriesForCustomer">
        <li>
            <div>Name: <strong data-bind="text: key.Name"></strong></div>
            <ul data-bind="foreach: values">
                <li data-bind="text: Description, 
                                css: { highlight: IsDelivered }">                
                </li>
            </ul>
        </li>    
    </ul>

If you would run the application now you will notice that knockout.js is keeping the values is the deliveries list and the customers list in sync.

Save, Revert, Exclude if delivered

Upshot can be configured to NOT synchronize the changes to the backend, meaning the user will have to click a "Save" button when he wants to submit. There is also the possibility to undo the accumulated changes or to put a filter on the datasource. In order to enable above functionality, make the following changes to the DeliveriesViewModel.js
  • Set bufferChanges to true so that the upshot datasource will keep the changes in memory and not sync them with the back-end
  • Create a self.localDataSource that we can use for client-side operations like filtering the data
  • Create a self.excludeDelivered variable to keep track of the value of the checkbox saying the user wants to exclude already delivered items from the list
  • Create the self.saveAll and self.revertAll function to commit or revert the changes on the upshot datasource
  • Subscribe a function to the self.excludeDelivered observable that gets called everytime the value changes. Inside this function a new filter rule is created which is then applied to the local data source
/// <reference path="_references.js" />

function DeliveriesViewModel() {
    // Private
    var self = this;
    var dataSourceOptions = {
        providerParameters: { url: "/api/DataService",
                    operationName: "GetDeliveriesForToday" },
        entityType: "Delivery:#DeliveryTracker.Models",
        bufferChanges: true,
        mapping : Delivery
    };

    // Public Properties
    self.dataSource = new upshot.RemoteDataSource(dataSourceOptions).refresh();
    self.localDataSource = upshot.LocalDataSource({ source: self.dataSource,
                                               autoRefresh: true });

    self.deliveries = self.localDataSource.getEntities();
    self.deliveriesForCustomer = self.deliveries.groupBy("Customer");
    self.excludeDelivered = ko.observable(false);

    // Operations
    self.saveAll = function () { self.dataSource.commitChanges() }
    self.revertAll = function () { self.dataSource.revertChanges() }

    // Delegates
    self.excludeDelivered.subscribe(function (shouldExcludeDelivered) {
        var filterRule = shouldExcludeDelivered 
            ? { property: "IsDelivered", operation: "==", value: false }
            : null;
        self.localDataSource.setFilter(filterRule);
        self.localDataSource.refresh();
    });
}

Include the new arrayUtils.js in the Index.cshtml file

<script src="~/Scripts/arrayUtils.js"
        type="text/javascript"></script>
<script src="~/Scripts/App/DeliveriesViewModel.js"
        type="text/javascript"></script>
To take advantage of the functionalities you can bind the view to the functions that have been put in place in the DeliveriesViewModel
  • Added two buttons of which knockout binds the click event to the appropriate functions in the DeliveriesViewModel
  • The "Exclude Delivered items" checkbox bound to the 'excludeDelivered' property in the viewmodel
  • The 'delivered' CSS class is bound to the existing 'IsDelivered' property which was foreseen on the view model. The 'updated' CSS class however is bound to the 'IsUpdated' property that comes built-in with the observable datasource
<div>
    <h3>Deliveries</h3>

    <button data-bind="click: saveAll">Save All</button>
    <button data-bind="click: revertAll">Revert All</button>

    <label>
        <input data-bind="checked: excludeDelivered" type="checkbox" />
         Exclude delivered items</label>

    <ol data-bind="foreach: deliveries">
        <li data-bind="css: { delivered: IsDelivered, 
                                        updated: IsUpdated}">
            <strong data-bind="text: Description"></strong>
            is for <em data-bind="text: Customer().Name"></em>
            <label><input data-bind="checked: IsDelivered" 
                  type="checkbox"/>Delivered</label>
        </li>
    </ol>

    <h3>Customers</h3>
    <ul data-bind="foreach: deliveriesForCustomer">
        <li>
            <div>Name: <strong data-bind="text: key.Name"></strong>
            </div>
            <ul data-bind="foreach: values">
                <li data-bind="text: Description, 
                    css: { delivered: IsDelivered,
                             updated: IsUpdated}">                
                </li>
            </ul>
        </li>    
    </ul>
</div>
I've defined these new styles in Site.css
.delivered
{
    text-decoration: line-through;
    color: #008000;
}

.updated
{
    background-color: #FFFF00;
}

If you now mark items as delivered, they will get the 'delivered' AND 'updated' styles in both lists. These are nicely kept in sync by upshot. When the user clicks the "SaveAll" button, all changes will be submitted to the server and the 'updated' style will disappear. The "RevertAll" button undoes all the changes. Selecting "Exclude Delivered Items" will remove the delivered items from the shared datasource so they won't show up in either list.

Download the source code

You can download the code (Visual Studio 2010 project file) from my SkyDrive

4 comments:

Anonymous said...

Excellent work Bart.

Any chance you could post the code for this project as well?

Bart Jolling said...

You can download the code from my Skydrive now

https://skydrive.live.com/redir.aspx?cid=92fec8e2df222664&resid=92FEC8E2DF222664!453&parid=92FEC8E2DF222664!399&authkey=!ANN90ogRSODRCrw

While putting this together I discovered some missing information and a bug in above post. I've highlighted the additions and corrections

Anonymous said...

Hi Bart, have been following your articles with interest as there is very little documentation available.

Do you know of a way to detect the user clicking the back button with Nav.js?

I have followed the basic SPA tutorials and one thing I noticed is that if you add a TodoItem then click the back button, the TodoItem remains in the list of TodoItems, which I understand but how can you remove that newly added item to the array on back button?

Thanks for any help

Bart Jolling said...

If you look around line 50 in the file 'TodoItemsViewModel.js' inside the 'scripts' folder, you'll see that a 'onNavigate' function is defined here.

self.nav = new NavHistory({
params: { edit: null, page: 1, pageSize: 10 },
onNavigate: function (navEntry, navInfo) {
...
}
}).initialize({ linkToUrl: true });

The 'navInfo' object holds a 'isBack' property. So inside this function you test on 'isBack' and then call the 'deleteEntity' function on the datasource and you pass in the entity (todoItem) that you want to remove.