Thanks to Sumit Maitra for co-authoring this article with me.
Our DataList Requirement
- We want a paginated repeater like functionality in ASP.NET MVC for tabular data.- Page Size should be definable and table refresh should involve only Ajax Posts.
Sample Data
We have a list of TimeCard entities. The TimeCard Entity is defined as followspublic class TimeCard
{
public int Id { get; set; }
[MaxLength(200)]
public string Subject { get; set; }
public string Description { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}
Note the two Date fields, these are in there to handle the special Date Scenario in JSON.
Building our Application using MVC and KnockoutJS
Step 1: We start off with the MVC4, Intranet Template to create a new ProjectStep 2: We add the above TimeCard entity in the Models folder
Step 3: We scaffold an MVC Controller
Step 4: Now we can run the Application and add some Sample Data. The Index Page Would look like this
Now this page does behave a little like a repeater except that it doesn’t have Pagination or Page Sizes and is generated by looping over Server Side data using Razor Syntax. The cshtml markup is as follows
@model IEnumerable<MvcKoDatalist.Models.TimeCard>
@{
ViewBag.Title = "Index";
}
<h2>Index</h2>
<p>
@Html.ActionLink("Create New", "Create")
</p>
<table>
<tr>
<th>@Html.DisplayNameFor(model => model.Subject)</th>
<th>@Html.DisplayNameFor(model => model.Description)</th>
<th>@Html.DisplayNameFor(model => model.StartDate)</th>
<th>@Html.DisplayNameFor(model => model.EndDate)</th>
<th></th>
</tr>
@foreach (var item in Model)
{
<tr>
<td>@Html.DisplayFor(modelItem => item.Subject)</td>
<td>@Html.DisplayFor(modelItem => item.Description)</td>
<td>@Html.DisplayFor(modelItem => item.StartDate)</td>
<td>@Html.DisplayFor(modelItem => item.EndDate)</td>
<td>@Html.ActionLink("Edit", "Edit", new { id = item.Id }) |
@Html.ActionLink("Details", "Details", new { id = item.Id }) |
@Html.ActionLink("Delete", "Delete", new { id = item.Id })
</td>
</tr>
}
</table>
As we can see we are essentially rendering a table. But this rendering happens on the server.
Refreshing Dependencies
In a recent article by Mahesh titled Dynamic UI in ASP.NET MVC using Knockout.js and Template binding, we saw how to use KO Templates. Today we’ll see how we can use the same to build a paginated data grid now.Before we get started let’s update our JavaScript libraries.
PM> update-package jquery.validation
PM> update-package jquery -version 1.9.1
PM> update-package jquery.ui.combined
PM> update-package knockoutjs
PM> install-package knockout.Mapping
Creating the Knockout ViewModel
1. We add a JavaScript file to the Scripts folder called paged-datalist-vm.js2. We define the Knockout ViewModel as follows
var viewModel = function (data)
{
this.pageSize = ko.observable(data.PageSize);
this.currentPage = ko.observable(data.PageNumber);
this.pageData = ko.observableArray(ko.mapping.fromJS(data.Data));
this.recordCount = ko.observable(data.RecordCount);
this.isLastPage = function ()
{
var recordsLeft = (this.recordCount() - (this.pageSize() * this.currentPage()))
return recordsLeft <= 0;
}
this.isFirstPage = function ()
{
return this.currentPage() == 1;
}
}
Let’s dissect this code in detail
a. The ViewModel is defined as a function (as opposed to a JavaScript object).
b. The pageSize property is an observable containing the number of rows being displayed per page.
c. The pageData property has the array of JavaScript Objects that needs to be displayed. Note we are using the ko.mapping plugin to convert the JavaScript objects to KO Observables. In our case today, we don’t need the values to be Observables actually, so we could skip the mapping and assign the data.Data directly to the ko.observableArray. The inner objects being observables, are more relevant when we are doing Updates as well.
d. The recordCount property stores the total number of records in the System. This is used to determine if we are at the last page or not.
e. The isLastPage and isFirstPage functions test to see if we are currently at the first page or last page of data and returns true if so.
The Client JavaScript
Now that the View Model is ready, let’s get it into action. We add another JavaScript file called paged-datalist-client.js. Let’s look at the code piecemeal.1. The Document ready Function: It sets up a global variable called vm that will be initialized on the successful load of data. It next calls the loadPage with default settings of currentPage=1 and page size = 5. It also assigns event handlers for the Previous Button, Next Button and the Page Size dropdown’s change event.
$(document).ready(function ()
{
vm = null;
loadPage(1, 5);
$(document).delegate("#prevPageButton", "click", previousPage);
$(document).delegate("#nextPageButton", "click", nextPage);
$(document).delegate("#rowsPerPage", "change", pageSizeChanged);
});
Note: The delegate method has been deprecated since jQuery 1.7. Make sure to use the .on() method whenever you are using the latest jQuery library
2. The loadPage function does an Ajax Postback to load Data from the Server. We’ll see the server side code in a bit.
On Successful return, there are two scenarios to take care, one is the first time load when we navigate to the page. This is when the vm is null and we initialize it with an instance of our viewModel. Once initialized, we use KO to applying the data to the databound elements.
Note: You should NOT call applyBindings more than once.
The second scenario is when the loadPage method is called to load any other page of data. This maybe in response to the user click Next or Previous button, or in response to the change in Page Size. In this case, we simply update the pageData by first resetting it and next pushing the new data into it.
var loadPage = function (pageNumber, pageSize)
{
$.ajax({
url: "/TimeCard/Index?pageNumber="+pageNumber+ "&pageSize=" + pageSize,
type: "POST",
success: function (data)
{
if (vm == null)
{
vm = new viewModel(data);
ko.applyBindings(vm);
return data.Data.length;
}
else
{
vm.pageData(ko.observableArray());
vm.pageData(ko.mapping.fromJS(data.Data));
return data.Data.length;
}
}
});
}
Deprecation Notice: The jqXHR.success(), jqXHR.error(), and jqXHR.complete() callbacks have been deprecated in jQuery 1.8. To prepare your code for their eventual removal, use jqXHR.done(), jqXHR.fail(), and jqXHR.always() instead.
3. The previousPage function checks if we are at the first page already. If not, it calculates the new page number and calls the loadPage method which gets data from the Server.
var previousPage = function (data)
{
if (!vm.isFirstPage())
{
vm.currentPage(vm.currentPage() - 1);
loadPage(vm.currentPage(), vm.pageSize());
}
}
4. The nextPage function checks if we are at the last page already. If not, it calculates the new page number and calls the loadPage method which gets data from the Server.
var nextPage = function (data)
{
if (!vm.isLastPage())
{
vm.currentPage(vm.currentPage() + 1);
loadPage(vm.currentPage(), vm.pageSize());
}
}
5. The pageSizeChanged function is called when the user selects a different value for number of lines per page. We check if the new value selected is the different from the value in pageSize property. If it is we update the VM, set the currentPage to 1 and call loadPage method to get data from the server.
var pageSizeChanged = function (data)
{
if (vm.pageSize() != $("#rowsPerPage").val())
{
vm.pageSize($("#rowsPerPage").val());
vm.currentPage(1);
loadPage(vm.currentPage(), vm.pageSize());
}
}
This completes the client side JavaScript required. Now let’s update the view so that it can bind to the view model correctly.
The Updated View
We update the Index.cshtml as follows.The <tbody> is bound to the pageData array using the foreach binding. This implies everything in the <tbody> will be used by KO as a template for each object in the pageData() array. Each <td> is bound to the properties for each TimeCard. Also we have bound the href of the Edit/Details/Delete anchors such that the designation URL is created based on the Id of the TimeCard.
In the table footer we have a select with the id rowsPerPage and options of 5, 10, 15, 20 or 25 rows of data per page.
Finally we have two images using the jQuery UI’s Previous and Next image sprites.
<table>
<thead>
<tr>
<th>Subject</th>
<th>Description</th>
<th>Start Date</th>
<th>End Date</th>
<th></th>
</tr>
</thead>
<tbody data-bind="foreach: pageData()">
<tr>
<td data-bind="text: Subject"></td>
<td data-bind="text: Description"></td>
<td data-bind="text: StartDate"></td>
<td data-bind="text: EndDate"></td>
<td>
<a data-bind="attr: { href: '/TimeCard/Edit/' + Id() }">Edit</a>
<a data-bind="attr: { href: '/TimeCard/Details/' + Id() }">Details</a>
<a data-bind="attr: { href: '/TimeCard/Details/' + Id() }">Delete</a>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<th>Page Size
<select id="rowsPerPage">
<option value="5" selected="selected">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</select>
</th>
<td>
<div style="float: left;">
<img class="ui-icon ui-widget-content ui-icon-triangle-1-w" id="prevPageButton" title="Previous" />
</div>
<div style="float: left">
<img class="ui-icon ui-widget-content ui-icon-triangle-1-e" id="nextPageButton" title="Next" />
</div>
</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
@section Scripts{
<script src="~/Scripts/knockout-2.2.1.debug.js"></script>
<script src="~/Scripts/knockout.mapping-latest.debug.js"></script>
<script src="~/Scripts/paged-datalist-client.js"></script>
<script src="~/Scripts/paged-datalist-vm.js"></script>
<script src="~/Scripts/knockout.bindings.date.js"></script>
}
At this point we can run the application but we have one gotcha to fix. Any guesses? The following screenshot should help.
Yesss… we have the hideous monstrosity that is MVC’s JSON dates. Well, luckily we already have a solution for this - JSON Dates are Different in ASP.NET MVC and Web API
So we quickly add another JavaScript file called knockout.databinding.date.js and paste the following script into it.
ko.bindingHandlers.date = {
init: function (element, valueAccessor, allBindingsAccessor)
{
var dateField = valueAccessor();
if (dateField() == "")
{
//initialize datepicker with some optional options
var options = allBindingsAccessor().datepickerOptions || {};
$(element).datepicker(options);
//handle the field changing
ko.utils.registerEventHandler(element, "change", function ()
{
var observable = valueAccessor();
observable($(element).val());
if (observable)
{
observable($(element).datepicker("getDate"));
$(element).blur();
}
});
//handle disposal (if KO removes by the template binding)
ko.utils.domNodeDisposal.addDisposeCallback(element, function ()
{
$(element).datepicker("destroy");
});
}
},
update: function (element, valueAccessor)
{
var value = ko.utils.unwrapObservable(valueAccessor());
//handle date data coming via json from Microsoft
if (String(value).indexOf('/Date(') == 0)
{
value = new Date(parseInt(value.replace(/\/Date\((.*?)\)\//gi, "$1"))) .toLocaleString();
}
//NOTE: in read-only scenario there are no date pickers. So we check
// if the datepicker method exists. If not we set the value to the
// node directly
if ($(element).datepicker)
{
current = $(element).datepicker("getDate");
if (value - current !== 0)
{
$(element).datepicker("setDate", value);
}
}
else
{
$(element)[0].innerHTML = value;
}
// Put Data back into the KO View Model
var vmVal = valueAccessor();
vmVal(new Date(value));
}
};
This code has only one change I’ve marked above. For our grid, the date is the inner html of the Table Cell rather than a date picker. So we’ve had to check if the element to which we are binding the data to is a date picker or not. If not we set the inner html appropriately.
Once we have this new binding in place we update the Index.cshtml to change the binding for the date column as below
<td data-bind="date: StartDate"></td>
<td data-bind="date: EndDate"></td>
Now when we run the application we get a nice data grid with pagination
Click on Next Page to see the remainder of the data
Change the Page Size to 10 and we get the following
There you go, a nice client side solution in MVC to replace the Repeater functionality in WebForms.
Conclusion
We saw how we could use Knockout JS and a helper script to replicate Repeater like functionality in ASP.NET MVC. If you are moving from WebForms to MVC, you can utilize this approach for your tabular data.Download the entire source code of this article (Github)
Wow - interesting Title, to bad i won't read it, no syntax highlighting? Seriously?
ReplyDeleteGood article ... regardless of the syntax highlighting.
ReplyDeleteThank you for the feedback. I know syntax highlighting is needed but Blogger has some restrictions and adding syntax highlighting leads to a lot of HTML + inline css getting generated, which I want to avoid.
ReplyDeleteAll articles come with full code to download and try out on your machines.
Wow, really posting a negative because of highlighting??
ReplyDeleteAnyways, good article. KnockoutJS is a hot topic now, so more articles the better. Thanks.
Why on Earth would you not put the button click handling into the ViewModel along with the pulling of the data from the server? When I've implemented a ko pager I passed the url for the ajax calls into the ViewModel and then it would handle all of the data retrieval. Honestly, this is a terrible example of what KnockoutJS can do.
ReplyDeleteThere are Three web management DataGrid, Datalist and Repeater, The primary similarity between the DataGrid, DataList, and Repeater Web manages is that all three have a DataSource residence and a DataBind() Method.
ReplyDeleteHi,
ReplyDeleteActually I build a application on this article in my ASP.Net web project. But I am in trouble here to return Data which is return in MVC application as Jason({...}). How could I do this? Can you help me?
ReplyDeleteLet me tell you guys, some of your articles are really superb in quality and content. Thanks for the effort and keep up the good work.
Regards,
Suresh
You guys are doing great!! impressive.
ReplyDelete