Monday, February 18, 2013

Work with WinJS ListView Control - The ASP.NET Way

In this post a Windows 8 JavaScript app is created to display a list of phones with sorting and editing functions. The WinJS ListView control is used in the app much like what we do with GridView in typical ASP.NET web application.

Note that this post presents a way to resolve some common problem and demos how we can work wiht Windows 8 WinJS. It doesn't mean that's the recommended way or the best practice.

HTML (home.html)

Create a Windows 8 JavaScript project using Navigation App template (other template should also work), update the home.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>homePage</title>

    <!-- WinJS references -->
    <link href="//Microsoft.WinJS.1.0/css/ui-dark.css" rel="stylesheet" />
    <script src="//Microsoft.WinJS.1.0/js/base.js"></script>
    <script src="//Microsoft.WinJS.1.0/js/ui.js"></script>

    <script src="/js/jquery-1.8.2.js"></script>
    <link href="/css/default.css" rel="stylesheet" />
    <link href="/pages/home/home.css" rel="stylesheet" />
    <script src="/pages/home/home.js"></script>

</head>
<body>
    <!-- The content that will be loaded and displayed. -->
    <div class="fragment homepage">
        <header aria-label="Header content" role="banner">
            <button class="win-backbutton" aria-label="Back" disabled type="button"></button>
        </header>

        <section aria-label="Main content" role="main">

            <div id="listviewPage">
                <div class="header">
                    <h1>My Smartphone List</h1>
                </div>
                <div class="row title">
                    <span id="titleName">Name</span>
                    <span id="titleBland">Bland</span>
                    <span id="titleOS">OS</span>
                    <span id="titleSize">Size</span>
                    <span id="titlePrice">PriceFrom</span>
                    <span id="titleAdd">Add</span>
                </div>
                <div id="lvPhonesTemplate" data-win-control="WinJS.Binding.Template">
                    <div class="row">
                        <span data-win-bind="innerText: Name"></span>
                        <span data-win-bind="innerText: Bland"></span>
                        <span data-win-bind="innerText: OS"></span>
                        <span data-win-bind="innerText: Size"></span>
                        <span class="price" data-win-bind="innerText: PriceFrom"></span>
                        <span class="edit">Edit</span>
                    </div>
                </div>
                <div class="listview">
                    <div id="lvPhones" data-win-control="WinJS.UI.ListView"
                        data-win-options="{tapBehavior: 'none', selectionMode: 'none', layout: {type: WinJS.UI.ListLayout}}">
                    </div>
                </div>
            </div>

            <div id="editPage">
                <div class="header">
                    <h1><label id="editTitle">Edit</label></h1>
                </div>
                <div class="form">
                    <div class="row">
                        <label for="editName">Name</label><input id="editName" type="text" required="required" />
                    </div>
                    <div class="row">
                        <label for="editBland">Bland</label><input id="editBland" type="text" required="required" />
                    </div>
                    <div class="row">
                        <label for="editOS">OS</label><input id="editOS" type="text" required="required" />
                    </div>
                    <div class="row">
                        <label for="editSize">Size (inch)</label><input id="editSize" type="text" required="required" />
                    </div>
                    <div class="row">
                        <label for="editPrice">Price From</label><input id="editPrice" type="text" required="required" />
                    </div>
                    <div class="error"></div>
                    <div class="footer">
                        <button id="btnCancel">Cancel</button>
                        <button id="btnEdit">Update</button>
                    </div>
                </div>
            </div>
        </section>
    </div>
</body>
</html>

The UI is quite simply. It contains two sections: a page to display tabular data using a ListView and a template for the ListView, and an edit form for editing an existing phone or adding a new phone entry. The jQuery is included so we can manipulate DOM elements the way we deal with traditional web apps.

CSS (home.css)

#listviewPage, #editPage { width: 900px; }
#listviewPage .header, #editPage .header { height: 50px; text-align: center; margin-bottom: 30px; }
#listviewPage .listview { width: 900px; overflow: auto; border: solid; }
#listviewPage .row.title { padding-left: 15px; border-bottom-style: none; }
#listviewPage .row { height: 30px; padding: 5px; border-bottom-style: solid; }
#listviewPage .row span:nth-child(6) { width: 50px; font-weight: bold; }
#listviewPage .row span { display: inline-block; width: 150px; }

#editPage .form { width: 800px; padding: 10px; }
#editPage .row { height: 40px; }
#editPage .row input { width: 400px; }
#editPage .row label { display: inline-block; width: 200px; padding: 10px; text-align: right; }
#editPage .footer { margin: 40px 200px; float: right; }
#editPage .footer button { margin: 10px; }

.homepage section[role=main] { margin-left: 120px; }

JavaScript (home.js)

(function () {
    "use strict";
    WinJS.Binding.optimizeBindingReferences = true;
    WinJS.UI.disableAnimations(); // Use jQuery animation instead

    var editedPhone = null;
    var smartphones = generateSampleData();
    var smartphoneList = new WinJS.Binding.List(smartphones);
    
    function generateSampleData() {
        var blands = ["Apple", "Nokia", "Samsung"];
        var oss = ["iOS", "Windows Phone", "Android"];
        var phones = [
            { ID: 1, Name: "iPhone 4", Bland: blands[0], OS: oss[0], Size: 3.5, PriceFrom: 449 },
            { ID: 2, Name: "iPhone 4S", Bland: blands[0], OS: oss[0], Size: 3.5, PriceFrom: 549 },
            { ID: 3, Name: "iPhone 5", Bland: blands[0], OS: oss[0], Size: 4.0, PriceFrom: 649 },
            { ID: 4, Name: "Lumia 820", Bland: blands[1], OS: oss[1], Size: 4.3, PriceFrom: 399 },
            { ID: 5, Name: "Lumia 900", Bland: blands[1], OS: oss[1], Size: 4.3, PriceFrom: 349 },
            { ID: 6, Name: "Lumia 920", Bland: blands[1], OS: oss[1], Size: 4.5, PriceFrom: 449 },
            { ID: 7, Name: "Galaxy S2", Bland: blands[2], OS: oss[2], Size: 4.3, PriceFrom: 349 },
            { ID: 8, Name: "Galaxy S3", Bland: blands[2], OS: oss[2], Size: 4.8, PriceFrom: 499 },
            { ID: 9, Name: "Galaxy Note", Bland: blands[2], OS: oss[2], Size: 5.3, PriceFrom: 599 },
            { ID: 10, Name: "Galaxy Note2", Bland: blands[2], OS: oss[2], Size: 5.5, PriceFrom: 699 },
        ];
        return phones;
    }

    // Listview item databound event  
    function onItemDataBound(container, itemData) {
        var $price = $(".price", container);
        var price = parseFloat($price.text());
        if (price < 400)
            $price.css("color", "green");
        else if ( price > 600)
            $price.css("color", "red");
    }

    // Listview template function
    function listViewItemTemplateFunction(itemPromise) {
        return itemPromise.then(function (item) {
            var template = document.getElementById("lvPhonesTemplate");
            var container = document.createElement("div");
            template.winControl.render(item.data, container);
            onItemDataBound(container, item.data);
            return container;
        });
    }

    // Display listview page
    function showListviewPage(skipDatabinding) {
        $(editPage).hide();
        if (!skipDatabinding) {
            smartphoneList = new WinJS.Binding.List(smartphones);
            lvPhones.winControl.itemDataSource = smartphoneList.dataSource;
            lvPhones.winControl.itemTemplate = listViewItemTemplateFunction;
        }
        $(listviewPage).fadeIn();
    }

    WinJS.UI.Pages.define("/pages/home/home.html", {
        ready: function (element, options) {
            showListviewPage();
        }
    });
})();

A listViewItemTemplateFunction function is defined for the ListView's itemTemplate property so we have granular control on each ListView item at run-time. The template function gets the template elements defined in home.html and injects them inside a div container, then use template's render function to emit the content. The ListView is fully populated at this point.

How to do more business logic for each item like what we do in GridView's itemDataBound event in ASP.NET? Here we define another onItemDataBound function to simulate such process. In our example we apply a logic to show different price color based on its amount: green if less than $400 and red if greater than $600. In ASP.NET we find the control inside template by its ID, here we locate an HTML element by its CSS class. Later we will set the edit button handler inside the onItemDataBound function.

The ListView screen looks like:

Sorting Implementation

The single-column sorting is implemented by updating the datasource of the ListView:
    var sortors = { Name: "asc", Bland: "asc", OS: "asc", Size: "asc", PriceFrom: "asc" };

    // Sorting event handler
    function sortingChanged(title) {
        try {
            var sorter = sortors[title];
            smartphones.sort(function (first, second) {
                var firstValue = first[title];
                var secondValue = second[title];
                if (typeof firstValue == "string")
                    return sorter == "asc" ?
                        firstValue.localeCompare(secondValue) : secondValue.localeCompare(firstValue);
                else {
                    if (firstValue == secondValue)
                        return 0;
                    else if (firstValue > secondValue)
                        return sorter == "asc" ? 1 : -1;
                    else
                        return sorter == "asc" ? -1 : 1;
                }
            });
            sortors[title] = sorter == "asc" ? "desc" : "asc";
            showListviewPage();
        } catch (e) {
            console.log("sort error: " + e.message);
        }
    }

    WinJS.UI.Pages.define("/pages/home/home.html", {
        ready: function (element, options) {
            // listview sorting
            $(".row.title", listviewPage).children().each(function (index, columnTitle) {
                var titleText = $(columnTitle).text();
                if (titleText != "Add") {
                    $(columnTitle).on("click", function () {
                        sortingChanged(titleText);
                    });
                }
            });
   //...
        }
    });

When the user clicks the header (title) of one column, the header text is passed to sortingChanged function so the sorting function knows which column is to sort. For simply demo purpose the "Add" button is put as the Edit column header which is not sortable, so it's exluded from the sorting event binding.

Add and Edit Interaction

The HTML has already included a simple edit form but the related logic is missing. We need to define the navigation handling between the list view and the edit form:

    // Listview item databound event  
    function onItemDataBound(container, itemData) {
        //...
        var $edit = $(".edit", container);
        $edit.on("click", itemData, showEditPage);
    }
    
 // Display Add/Edit form page
    function showEditPage(event) {
        if (event && event.data && event.data.ID) {
            editTitle.textContent = "Edit Smartphone";
            btnEdit.textContent = "Update";
            editedPhone = event.data;
            editName.value = editedPhone.Name;
            editBland.value = editedPhone.Bland;
            editOS.value = editedPhone.OS;
            editSize.value = editedPhone.Size;
            editPrice.value = editedPhone.PriceFrom;
        } else {
            editedPhone = null;
            editTitle.textContent = "Add A New Smartphone ";
            btnEdit.textContent = "Add";
            $("input", editPage).each(function (index, input) {
                $(input).val("");  // Cleanup all the input textbox
            });
        }
        $(listviewPage).fadeOut(function () {
            $(editPage).fadeIn();
        });
    }

    // Add or update an item
    function updateOrAddPhone() {
        if (editedPhone) { // Update existing phone
            editedPhone.Name = editName.value;
            editedPhone.Bland = editBland.value;
            editedPhone.OS = editOS.value;
            editedPhone.Size = editSize.value;
            editedPhone.PriceFrom = editPrice.value;
        } else { // Add a new phone
            var phone = {
                ID: smartphones.length + 1,
                Name: editName.value,
                Bland: editBland.value,
                OS: editOS.value,
                Size: editSize.value,
                PriceFrom: editPrice.value
            };
            smartphones.push(phone);
        }
        showListviewPage();
    }
    
    WinJS.UI.Pages.define("/pages/home/home.html", {
        ready: function (element, options) {
            titleAdd.addEventListener("click", showEditPage);
            btnEdit.addEventListener("click", updateOrAddPhone);
            btnCancel.addEventListener("click", function () { showListviewPage(true); });
            //...
        }
    });

The add and edit share the same form. We toggle the display between listView and edit form page section when editting or finishing editting and list item. In this demo the code doesn't have validation for the edit form, but in reality we should always implement validation logic for the user input.

Another note is that in order to get input elements inside the ListView control to work, such as textbox and select dropdowns, you need to add "win-interactive" class for those elements:

    <input id="amount" class="win-interactive" type="number" />
The Add and Edit forms' screen-shot:

The Final JavaScript (home.js)

(function () {
    "use strict";
    WinJS.Binding.optimizeBindingReferences = true;
    WinJS.UI.disableAnimations(); // Use jQuery animation instead

    var editedPhone = null;
    var smartphones = generateSampleData();
    var smartphoneList = new WinJS.Binding.List(smartphones);
    var sortors = { Name: "asc", Bland: "asc", OS: "asc", Size: "asc", PriceFrom: "asc" };

    function generateSampleData() {
        var blands = ["Apple", "Nokia", "Samsung"];
        var oss = ["iOS", "Windows Phone", "Android"];
        var phones = [
            { ID: 1, Name: "iPhone 4", Bland: blands[0], OS: oss[0], Size: 3.5, PriceFrom: 449 },
            { ID: 2, Name: "iPhone 4S", Bland: blands[0], OS: oss[0], Size: 3.5, PriceFrom: 549 },
            { ID: 3, Name: "iPhone 5", Bland: blands[0], OS: oss[0], Size: 4.0, PriceFrom: 649 },
            { ID: 4, Name: "Lumia 820", Bland: blands[1], OS: oss[1], Size: 4.3, PriceFrom: 399 },
            { ID: 5, Name: "Lumia 900", Bland: blands[1], OS: oss[1], Size: 4.3, PriceFrom: 349 },
            { ID: 6, Name: "Lumia 920", Bland: blands[1], OS: oss[1], Size: 4.5, PriceFrom: 449 },
            { ID: 7, Name: "Galaxy S2", Bland: blands[2], OS: oss[2], Size: 4.3, PriceFrom: 349 },
            { ID: 8, Name: "Galaxy S3", Bland: blands[2], OS: oss[2], Size: 4.8, PriceFrom: 499 },
            { ID: 9, Name: "Galaxy Note", Bland: blands[2], OS: oss[2], Size: 5.3, PriceFrom: 599 },
            { ID: 10, Name: "Galaxy Note2", Bland: blands[2], OS: oss[2], Size: 5.5, PriceFrom: 699 },
        ];
        return phones;
    }

    // Listview item databound event  
    function onItemDataBound(container, itemData) {
        var $price = $(".price", container);
        var price = parseFloat($price.text());
        if (price < 400)
            $price.css("color", "green");
        else if ( price > 600)
            $price.css("color", "red");
        var $edit = $(".edit", container);
        $edit.on("click", itemData, showEditPage);
    }

    // Listview template function
    function listViewItemTemplateFunction(itemPromise) {
        return itemPromise.then(function (item) {
            var template = document.getElementById("lvPhonesTemplate");
            var container = document.createElement("div");
            template.winControl.render(item.data, container);
            onItemDataBound(container, item.data);
            return container;
        });
    }

    // Display listview page
    function showListviewPage(skipDatabinding) {
        $(editPage).hide();
        if (!skipDatabinding) {
            smartphoneList = new WinJS.Binding.List(smartphones);
            lvPhones.winControl.itemDataSource = smartphoneList.dataSource;
            lvPhones.winControl.itemTemplate = listViewItemTemplateFunction;
        }
        $(listviewPage).fadeIn();
    }

    // Display Add/Edit form page
    function showEditPage(event) {
        if (event && event.data && event.data.ID) {
            editTitle.textContent = "Edit Smartphone";
            btnEdit.textContent = "Update";
            editedPhone = event.data;
            editName.value = editedPhone.Name;
            editBland.value = editedPhone.Bland;
            editOS.value = editedPhone.OS;
            editSize.value = editedPhone.Size;
            editPrice.value = editedPhone.PriceFrom;
        } else {
            editedPhone = null;
            editTitle.textContent = "Add A New Smartphone ";
            btnEdit.textContent = "Add";
            $("input", editPage).each(function (index, input) {
                $(input).val("");  // Cleanup all the input textbox
            });
        }
        $(listviewPage).fadeOut(function () {
            $(editPage).fadeIn();
        });
    }

    // Add or update an item
    function updateOrAddPhone() {
        if (editedPhone) { // Update existing phone
            editedPhone.Name = editName.value;
            editedPhone.Bland = editBland.value;
            editedPhone.OS = editOS.value;
            editedPhone.Size = editSize.value;
            editedPhone.PriceFrom = editPrice.value;
        } else { // Add a new phone
            var phone = {
                ID: smartphones.length + 1,
                Name: editName.value,
                Bland: editBland.value,
                OS: editOS.value,
                Size: editSize.value,
                PriceFrom: editPrice.value
            };
            smartphones.push(phone);
        }
        showListviewPage();
    }

    // Sorting event handler
    function sortingChanged(title) {
        try {
            var sorter = sortors[title];
            smartphones.sort(function (first, second) {
                var firstValue = first[title];
                var secondValue = second[title];
                if (typeof firstValue == "string")
                    return sorter == "asc" ?
                        firstValue.localeCompare(secondValue) : secondValue.localeCompare(firstValue);
                else {
                    if (firstValue == secondValue)
                        return 0;
                    else if (firstValue > secondValue)
                        return sorter == "asc" ? 1 : -1;
                    else
                        return sorter == "asc" ? -1 : 1;
                }
            });
            sortors[title] = sorter == "asc" ? "desc" : "asc";
            showListviewPage();
        } catch (e) {
            console.log("sort error: " + e.message);
        }
    }

    WinJS.UI.Pages.define("/pages/home/home.html", {
        ready: function (element, options) {
            // Edit form buttons event handler
            titleAdd.addEventListener("click", showEditPage);
            btnEdit.addEventListener("click", updateOrAddPhone);
            btnCancel.addEventListener("click", function () { showListviewPage(true); });

            // listview sorting
            $(".row.title", listviewPage).children().each(function (index, columnTitle) {
                var titleText = $(columnTitle).text();
                if (titleText != "Add") {
                    $(columnTitle).on("click", function () {
                        sortingChanged(titleText);
                    });
                }
            });

            showListviewPage(); // Show ListView by default
        }
    });

})();