Skip to content

Instantly share code, notes, and snippets.

@vinicius-stutz
Created April 24, 2018 22:38
Show Gist options
  • Save vinicius-stutz/26d0f6d0747c8f78a87050a2f3ffe422 to your computer and use it in GitHub Desktop.
Save vinicius-stutz/26d0f6d0747c8f78a87050a2f3ffe422 to your computer and use it in GitHub Desktop.
Using DataTables with C# and Web API

Using DataTables with Web API Part 1: Making a simple GET Request

I've faced this need several times over the years:

So, we've got our dashboard, and we need to show customer/user/whatever data in a table so we can search, sort, filter, all that good stuff...

Sound familiar? Well, there are a few ways we could approach this. To start with, we could put it all together by hand. Creating the table structure and binding each individual row from a data source. But that's loads of work! What we need is some kind of helper or plugin to make our lives easier. Something that will turn our ordinary table into a powerhouse of searchable goodness.

Enter DataTables...

For those that don't know, DataTables is a feature-rich jQuery plugin. It allows you to connect an HTML table to a data source. If you want to check it out, you can download it from http://datatables.net. It's got a stack of useful features and settings that we can take advantage of. In this article, we'll look at making a GET request to a Web API service and displaying the data. In the next article, we'll examine what happens when we make a POST request instead. For now, let's keep things simple.

The client-side code

To start with, we'll knock up a quick MVC project with a View that will display our table. Crank open Visual Studio and create a new Web Application.

We'll need Web API and MVC for this project, but no authentication).

In this example, we'll be displaying a list of customers (name, address, telephone number). Open up the Indexview in the Views/Home folder and add an HTML table with the following structure:

<div class="panel panel-primary">
    <div class="panel-heading">
        <h3 class="panel-title">Customers</h3>
    </div>
    <div class="panel-body">
        <table id="CustomersTable" class="table table-striped table-bordered table-hover responsive" width="100%">
            <thead class="thin-border-bottom">
            <tr>
                <th>Name</th>
                <th>Address</th>
                <th>Postcode</th>
                <th>Tel</th>
            </tr>
            </thead>
        </table>
    </div>
</div>

Now add a link to the DataTables script and stylesheet. We'll use the DataTables CDN for now. Open up the _Layout page in Views/Shared. Pop the link tag into the head of the page and the script tag just above the scripts section.

The head section should look like this:

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width" />
  <title>@ViewBag.Title</title>
  @Styles.Render("~/Content/css")
  @Scripts.Render("~/bundles/modernizr")
  <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/v/dt/dt-1.10.12/datatables.min.css" />
</head>

Here's how the scripts look:

@Scripts.Render("~/bundles/jquery")
@Scripts.Render("~/bundles/bootstrap")
<script type="text/javascript" src="https://cdn.datatables.net/v/dt/dt-1.10.12/datatables.min.js"></script>
@RenderSection("scripts", false)

DataTables contains a handy jQuery plugin, which we'll use to activate our table. We'll give it a URL, which will be the endpoint for our Web API service (/api/CustomerSearch). We'll come to that shortly. Here's the script we'll need. Pop this scripts section at the bottom of the Index View:

@section scripts {
  <script>
    (function($) {
      var generateCustomerTable = $("#CustomerTable")
        .dataTable({
          "processing": true,
          "serverSide": true,
          "ajax": {
            "url": "/api/customerSearch"
          },
          "columns": [
            { "data": "companyName" }, { "data": "address" }, { "data": "postcode" },
            { "data": "telephone" }
          ],
          "language": {
            "emptyTable": "There are no customers at present.",
            "zeroRecords": "There were no matching customers found."
          },
          "searching": false,
          "ordering": true,
          "paging": true
        });
    })(jQuery);
  </script>
}

We need to make sure that the id in our script (in our case CustomerTable) matches the table id in the HTML. Now, when we load our page in a browser, the plugin will make a GET request to our Web API controller. At the moment we'll get a 404 error, but it gives us a chance to have a look at the call the plugin makes. We'll need some of the attributes in the query string on the API side shortly. You can ignore the error popup that appears. It's just DataTables telling us that the API Controller doesn't exist yet. We'll fix that in a bit.

What info does DataTables send across to the server?

If you're using Chrome, F12 brings up the developer tools. You can then click the Network tab and filter on api to see the call:

Let's have a closer look at that URL:

http://localhost:56835/api/customerSearch?draw=1&columns%5B0%5D%5Bdata%5D=companyName&columns%5B0%5D%5Bname%5D=&columns%5B0%5D%5Bsearchable%5D=true&columns%5B0%5D%5Borderable%5D=true&columns%5B0%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B0%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B1%5D%5Bdata%5D=address&columns%5B1%5D%5Bname%5D=&columns%5B1%5D%5Bsearchable%5D=true&columns%5B1%5D%5Borderable%5D=true&columns%5B1%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B1%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B2%5D%5Bdata%5D=postcode&columns%5B2%5D%5Bname%5D=&columns%5B2%5D%5Bsearchable%5D=true&columns%5B2%5D%5Borderable%5D=true&columns%5B2%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B2%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B3%5D%5Bdata%5D=telephone&columns%5B3%5D%5Bname%5D=&columns%5B3%5D%5Bsearchable%5D=true&columns%5B3%5D%5Borderable%5D=true&columns%5B3%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B3%5D%5Bsearch%5D%5Bregex%5D=false&order%5B0%5D%5Bcolumn%5D=0&order%5B0%5D%5Bdir%5D=asc&start=0&length=10&search%5Bvalue%5D=&search%5Bregex%5D=false&_=1479364345110

Looks scary, doesn't it? Don't worry, it's encoded. It has to be like that before the browser makes the call. Let's do a couple of replacements to make it easier to read. We'll replace %5B with [ and %5D with ]. Here's how it looks now:

http://localhost:56835/api/customerSearch?draw=1&columns[0][data]=companyName&columns[0][name]=&columns[0][searchable]=true&columns[0][orderable]=true&columns[0][search][value]=&columns[0][search][regex]=false&columns[1][data]=address&columns[1][name]=&columns[1][searchable]=true&columns[1][orderable]=true&columns[1][search][value]=&columns[1][search][regex]=false&columns[2][data]=postcode&columns[2][name]=&columns[2][searchable]=true&columns[2][orderable]=true&columns[2][search][value]=&columns[2][search][regex]=false&columns[3][data]=telephone&columns[3][name]=&columns[3][searchable]=true&columns[3][orderable]=true&columns[3][search][value]=&columns[3][search][regex]=false&order[0][column]=0&order[0][dir]=asc&start=0&length=10&search[value]=&search[regex]=false&_=1479364345110

What have we got here? For starters, it contains a bunch of info about the columns in our table (it's an array, hence the [0] etc - we'll use that in the next article). It also contains a reference to the column we're using for ordering. We've got paging and filtering info at the end (Start, Length and Search). We'll look at those in part 3 of this series.

We've also got a Draw counter. This is important, because DataTables keeps a count of the number of times it redraws the table. Every time you interact with the table, DataTables fetches the data again from the server. It also increments that counter. When the call comes back with the data, the Draw parameter needs to be there with that same value or we'll get an error. You can read more about the parameters here: DataTables: Server Side

A couple of things to note about using GET (as opposed to POST) to make this request. It's less secure than POST, because anyone keeping an eye on the data is it goes across the wire could read and/or change it. In our case, they might be able to change the names of some columns or some such. Not a massive issue. A bigger issue is that this query string is 1038 characters. The greatest length supported by browsers is 2048 characters. If we need to show more columns in our table, it'll add a bunch more parameters onto our query string. It doesn't take long before it goes over the max length. To begin with, making a GET request is great for us though. It shows us how DataTables structures the data that it sends across. We'll need that knowledge on the other side. Let's go there now.

The server-side code

Let's add a Web API controller to our project. We'll call it CustomerSearchController. We'll also need a bunch of classes to hold the data that we'll pass back and forth.

Let's start by looking at the data classes:

public class SearchRequest
{
    public int Draw { get; set; }
}

public abstract class SearchDetail
{
}

public class CustomerSearchDetail : SearchDetail
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Postcode { get; set; }
    public string Telephone { get; set; }
}

public abstract class SearchResponse<T> where T : SearchDetail
{
    public int Draw { get; set; }

    public int RecordsTotal { get; set; }

    public int RecordsFiltered { get; set; }

    public IList<T> Data { get; set; }
}

public class CustomerSearchResponse : SearchResponse<CustomerSearchDetail>
{
}

I've left CustomerData.cs out for the moment. I'll come back to it when I talk about the Controller code. What's going on here? Well, we can see the Draw property on both the SearchRequest and SearchResponse. This keeps DataTables happy. We've got a couple of counters on the SearchResponse. They tell DataTables whether we've filtered any results out through searching. We'll cover that in part 3 of this series. For now, they'll both contain the total customers. We've also using a bit of generics to control which type we use for the Data property on the SearchResponse.

Web API services are RESTful by nature. We're making a GET request, so we'll be adding a method called Get to our controller. In the next article we'll add POST support via, you guessed it, a method called Post. Let's have a look at the Controller code now:

public class CustomerData
{
    public IList<CustomerSearchDetail> Data { get; set; }
}

public class CustomerSearchController : ApiController
{
    private const string CustomerData = @"
{
  ""Data"": [
    {
      ""CompanyName"": ""Microsoft"",
      ""Address"": ""1 Microsoft Way, London"",
      ""Postcode"": ""N1 1NN"",
      ""Telephone"": ""020 7100 1000""  
    },
    {
      ""CompanyName"": ""Nokia"",
      ""Address"": ""2 Nokia Way, London"",
      ""Postcode"": ""N2 2NN"",
      ""Telephone"": ""020 7200 2000""
    },
    {
      ""CompanyName"": ""Apple"",
      ""Address"": ""3 Apple Way, London"",
      ""Postcode"": ""N3 3NN"",
      ""Telephone"": ""020 7300 3000""
    },
    {
      ""CompanyName"": ""Google"",
      ""Address"": ""4 Google Way, London"",
      ""Postcode"": ""N4 4NN"",
      ""Telephone"": ""020 7400 4000""
    },
    {
      ""CompanyName"": ""Samsung"",
      ""Address"": ""5 Samsung Way, London"",
      ""Postcode"": ""N5 5NN"",
      ""Telephone"": ""020 7500 5000""
    }
  ] 
}";

    public IHttpActionResult Get([FromUri]SearchRequest request)
    {
        var allCustomers = JsonConvert.DeserializeObject<CustomerData>(CustomerData);
        var response = new CustomerSearchResponse
        {
            Data = allCustomers.Data,
            Draw = request.Draw,
            RecordsFiltered = allCustomers.Data.Count,
            RecordsTotal = allCustomers.Data.Count
        };
        return Ok(response);
    }
}

All we're doing here is turning a json string into a CustomerData object. We're using the Json.NET library for this, via the DeserializeObject method. If this were a production system, we'd be pulling our data from a database of some kind. This is perfect for our example though. The CustomerData object has a structure that matches the structure in our json string. The Json.NET library turns it into something our code can work with.

The FromUri attribute next to the request tells our Controller to try and create a SearchRequest object from the QueryString data in the URL.

Using DataTables with Web API Part 2: Making a POST Request

So far we’ve looked at hooking up DataTables to a Web API Controller via a simple GET request. Is that the best way to make our server side call though? We’ve seen a couple of problems with that approach. Anyone with the know-how could spy on the data as we send it across. If we want to show more columns, we’ll push the URL length towards that maximum. Time for plan B.

Plan B involves a small change to our client code. Instead of making a GET request, we’ll make a POST request instead.

If you haven't done so yet, grab the code from the previous article: Making a simple GET Request. Open up the Index view in Visual Studio (Views/Home/Index.cshtml) and scroll down to the scripts section. We're changing the AJAX method from GET (the default) to POST. It now looks like this:

"ajax": {
    "url": "/api/customerSearch",
    "method": "POST" // << add this new attribute here
},

If we now fire up the page in a browser, we get the old error alert again. If we examine what's happened within Developer Tools, we'll see a 405 error (method not allowed).

This is because our Web API Controller has a Get method, but needs a Post method. Let's fix that now. Open up the CustomerSearchController and change the method name to Post. You can remove the FromUri attribute too. Why? Because the data is now coming from the request body rather than the URI. Here's the new method:

public IHttpActionResult Post(SearchRequest request)
{
    var allCustomers = JsonConvert.DeserializeObject<CustomerData> (CustomerData);
    var response = new CustomerSearchResponse
    {
        Data = allCustomers.Data,
        Draw = request.Draw,
        RecordsFiltered = allCustomers.Data.Count,
        RecordsTotal = allCustomers.Data.Count
    };
    return Ok(response);
}

Because we’re using POST this time, we can take advantage of model binding. We can create a set of classes that mirror the structure we see in that URL. We’ll still use the same SearchRequest in our Controller. Let’s modify the SearchRequest a little and add some new classes:

public class SearchRequest
{
    public int Draw { get; set; }
    public int Start { get; set; }
    public int Length { get; set; }
    public ColumnRequestItem[] Columns { get; set; }
    public OrderRequestItem[] Order { get; set; }
    public SearchRequestItem Search { get; set; }
}

public class ColumnRequestItem
{
    public string Data { get; set; }
    public string Name { get; set; }
    public bool Searchable { get; set; }
    public bool Orderable { get; set; }
    public SearchRequestItem Search { get; set; }
}

public class OrderRequestItem
{
    public int Column { get; set; }
    public string Dir { get; set; }
}

public class SearchRequestItem
{
    public string Value { get; set; }
    public bool Regex { get; set; }
}

What’s going on here? Let’s remind ourselves of the URL that we saw in the previous article:

http://localhost:56835/api/customerSearch?draw=1&columns[0][data]=companyName&columns[0][name]=&columns[0][searchable]=true&columns[0][orderable]=true&columns[0][search][value]=&columns[0][search][regex]=false&columns[1][data]=address&columns[1][name]=&columns[1][searchable]=true&columns[1][orderable]=true&columns[1][search][value]=&columns[1][search][regex]=false&columns[2][data]=postcode&columns[2][name]=&columns[2][searchable]=true&columns[2][orderable]=true&columns[2][search][value]=&columns[2][search][regex]=false&columns[3][data]=telephone&columns[3][name]=&columns[3][searchable]=true&columns[3][orderable]=true&columns[3][search][value]=&columns[3][search][regex]=false&order[0][column]=0&order[0][dir]=asc&start=0&length=10&search[value]=&search[regex]=false&_=1479364345110

We can tell that the columns and order items are arrays in the URL. The [0] just after columns or order tells us this. Search, on the other hand, doesn’t have an index. It just has value and regex properties. We can call the classes whatever we like. All the model binder cares about is that we have properties with the names it expects to find.

We need an array called Columns and an array called Order. We need an object called Search and a property on the request called Draw. If we add anything else, it won’t contain any data. Things will still work, though. Likewise, if we miss out any properties on our classes, we won’t get that data. Things will still work though. Now, we could create a custom model binder for this. I’d rather create the classes manually in this example so we can see what’s going on.

And that's all there is to it. We've added the scaffolding for paging, sorting and searching. In the next article we'll build on this and add paging, sorting and search capabilities to our table.

Using DataTables with Web API Part 3: Paging, Sorting and Searching

The internet is awash with data. So much so, it's hard to make sense of it all. Data about customers. Financial data. Data about stuff. Data that we need to keep track of and change. Most of the time we store it in databases. Sometimes we get hold of it through APIs. On occasion we present to people for analysis. One thing's for certain though. Howsoever we store it, we need to avoid information overload if we're viewing it on a website.

How do we avoid information overload? By viewing our data in bite-sized chunks. By allowing us to control how much we see and in which order we see it. DataTables gives us a powerful window onto our data. We can view it a few items at a time. We can search and filter to zero in on what's relevant. We can change the order, allowing for easy comparison.

First up: Paging and Ordering

We're building atop the previous article on hooking up DataTables and Web API via POST. If you want to follow along, head over there first and grab the Visual Studio solution. I'll wait for you to get back...

Got it? Let's crack on. We need to make a small change to our JavaScript plugin code to enable all of this juicy stuff. Let's switch on paging, ordering and searching in our script:

$("#CustomerTable")
    .dataTable({
        "processing": true,
        "serverSide": true,
        "ajax": {
            "url": "/api/customerSearch",
            "method": "POST"
        },
        "columns": [
            { "data": "companyName" },
            { "data": "address" },
            { "data": "postcode" },
            { "data": "telephone" }
        ],
        "language": {
            "emptyTable": "There are no customers at present.",
            "zeroRecords": "There were no matching customers found."
        },
        "searching": true, // <-- this should be set to true
        "ordering": true, // <-- this should be set to true
        "paging": true // <-- this should be set to true
    });

That's it for the front end. Head on over to the API code. We're going to add a base Controller with paging and ordering methods. We'll use this just before we send the data back to the client. Add a SearchController to the Controllers/api folder that looks like this:

public abstract class SearchController : ApiController
{
    protected static IList<TDetail> PageResults<TDetail> (IEnumerable<TDetail> results, SearchRequest request) where TDetail : SearchDetail
    {
        var skip = request.Start;
        var pageSize = request.Length;
        var orderedResults = OrderResults(results, request);
        return pageSize > 0 ? orderedResults.Skip(skip).Take(pageSize).ToList() : orderedResults.ToList();
    }

    private static IEnumerable<TDetail> OrderResults<TDetail> (IEnumerable<TDetail> results, SearchRequest request) where TDetail : SearchDetail
    {
        if (request.Order == null) return results;
        var columnIndex = request.Order[0].Column;
        var sortDirection = request.Order[0].Dir;
        var columnName = request.Columns[columnIndex].Data.AsPropertyName();
        var prop = typeof(TDetail).GetProperty(columnName);
        return sortDirection == "asc" ? results.OrderBy(prop.GetValue) : results.OrderByDescending(prop.GetValue);
    }
}

So what have we got here? We're using LINQ to perform our paging and ordering. Our request contains the Start and Length values that we need for paging. We're using Skip and Take to give us a page of results. For example, Start 0 and Length 10 gives us the first 10 results. Start 20 and Length 10 means skip over the first 20 results and return the next 10. We need to call the paging code after we've done our ordering. If we don't, we'll get some odd results if we change the default order and then page through.

If you want to see the paging in action, you'll need to do one of 2 things. The default page length is 10. You could add more customers to the data source, so you've got more than 10. If you don't fancy doing that, you can change the default page length. Would you believe it, but the property is called pageLength. Let's add it to the end of our plugin options code:

    "paging": true,
    "pageLength": 3

One other small thing to note is the AsPropertyName method. That's a small extension method that capitalises the first letter of our Columns Data property. It changes companyName to CompanyName, for example. The JavaScript will send across companyName. The GetProperty method expects to receive CompanyName or it won't work. Let's add an Extensions class:

public static class Extensions
{
    public static string AsPropertyName(this string source)
    {
        return char.ToUpper(source[0]) + source.Substring(1);
    }
}

Searching and filtering

We'll use these new methods in our controller in a moment. Before that, let's take a look at the code to handle the searching side of things. We'll do that with a static helper class. Add an ApiHelper class to the project:

public static class ApiHelper
{
    public static IEnumerable<CustomerSearchDetail> FilterCustomers(IEnumerable<CustomerSearchDetail> details, string searchText)
    {
        if (searchText.IsEmpty())
        {
            return details;
        }

        var results = details.Where(x => x.CompanyName.ContainsIgnoringCase(searchText)
            || x.Address.ContainsIgnoringCase(searchText)
            || x.Postcode.ContainsIgnoringCase(searchText)
            || x.Telephone.ContainsIgnoringCase(searchText)
            );

        return results;
    }
}

I've added another extension method, ContainsIgnoringCase, which makes our code read a little better. The search will be case sensitive by default. We need to make sure we still return results if they differ by case alone. Let's add it to our Extensions class:

public static bool ContainsIgnoringCase(this string source, string substring)
{
    return source.ToLower().Contains(substring.ToLower());
}

Now we need to change our CustomerSearchController to use the new paging and ordering methods. Here's how it now looks:

public class CustomerSearchController : SearchController
{
    public IHttpActionResult Post(SearchRequest request)
    {
        var allCustomers = JsonConvert.DeserializeObject<CustomerData> (CustomerData.DataSource);
        var response = WrapSearch(allCustomers.Data, request);
        return Ok(response);
    }

    private static CustomerSearchResponse WrapSearch(ICollection<CustomerSearchDetail> details, SearchRequest request)
    {
        var results = ApiHelper.FilterCustomers(details, request.Search.Value).ToList();
        var response = new CustomerSearchResponse
        {
            Data = PageResults(results, request),
            Draw = request.Draw,
            RecordsFiltered = results.Count,
            RecordsTotal = details.Count
        };
        return response;
    }
}

The key takeaway here is how we're using RecordsFiltered and RecordsTotal. This allows us to tell DataTables that we're bringing back partial results.

That's it for paging, ordering and searching. Next up we'll examine how to package this up so we can re-use it. Our requirements are evolving. We need to show 4 tables of data now. Let's see if we can avoid copying and pasting that JavaScript code all over the place!

Using DataTables with Web API Part 4: Re-use

Data comes in all shapes and sizes. We use it to describe attributes of all sorts of things. There are loads of web based systems out there for managing that data. Within them, we often need to look at different sets of data at the same time. Manipulate and analyse them in isolation from each other. DataTables provides a powerful way to do that. It has a lot of options though. There's a fair bit of repetition. Copying and pasting that plugin code everywhere. Can we create it once, pass a few options in, and generate it on the fly? Of course we can. We can do similar with the boilerplate code surrounding the table data as well.

New requirement: show some user data as well

Got it? Onwards and upwards. While you were away, we got a new requirement in for the project. We now need to display user data alongside our customer info. This means we need a second table. Before we do that, we need to add some user data to our project. Head on over to the API code and add a UserData class to Controllers/Api:

public class UserData
{
    public const string DataSource = @"
{
  ""Data"": [
    {
      ""CompanyName"": ""Microsoft"",
      ""FirstName"": ""Dave"",
      ""LastName"": ""Smith"",
      ""Email"": ""[email protected]"",
      ""JobTitle"": ""Project Manager""
    },
    {
      ""CompanyName"": ""Nokia"",
      ""FirstName"": ""John"",
      ""LastName"": ""Smith"",
      ""Email"": ""[email protected]"",
      ""JobTitle"": ""Project Manager""
    },
    {
      ""CompanyName"": ""Apple"",
      ""FirstName"": ""Paul"",
      ""LastName"": ""Jones"",
      ""Email"": ""[email protected]"",
      ""JobTitle"": ""Product Manager""
    },
    {
      ""CompanyName"": ""Google"",
      ""FirstName"": ""Leslie"",
      ""LastName"": ""Richards"",
      ""Email"": ""[email protected]"",
      ""JobTitle"": ""Product Director""
    },
    {
      ""CompanyName"": ""Samsung"",
      ""FirstName"": ""Michelle"",
      ""LastName"": ""Davis"",
      ""Email"": ""[email protected]"",
      ""JobTitle"": ""Programme Manager""
    }
  ] 
}";

    public IList<UserSearchDetail> Data { get; set; }
}

We also need a UserSearchDetail and a UserSearchResponse. Let's add those:

public class UserSearchDetail : SearchDetail
{
    public string CompanyName { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string JobTitle { get; set; }
}

public class UserSearchResponse : SearchResponse<UserSearchDetail>
{
}

We'd better add a filtering method to our ApiHelper:

public static IEnumerable<UserSearchDetail> FilterUsers(IEnumerable<UserSearchDetail> details, string searchText)
{
    if (searchText.IsEmpty())
    {
        return details;
    }

    var results = details.Where(x => x.CompanyName.ContainsIgnoringCase(searchText)
        || x.FirstName.ContainsIgnoringCase(searchText)
        || x.LastName.ContainsIgnoringCase(searchText)
        || x.Email.ContainsIgnoringCase(searchText)
        || x.JobTitle.ContainsIgnoringCase(searchText)
        );

    return results;
}

Let's wrap it all up in a new UserSearchController:

public class UserSearchController : SearchController
{
    public IHttpActionResult Post(SearchRequest request)
    {
        var allUsers = JsonConvert.DeserializeObject<UserData> (UserData.DataSource);
        var response = WrapSearch(allUsers.Data, request);
        return Ok(response);
    }

    private static UserSearchResponse WrapSearch(ICollection<UserSearchDetail> details, SearchRequest request)
    {
        var results = ApiHelper.FilterUsers(details, request.Search.Value).ToList();
        var response = new UserSearchResponse
        {
            Data = PageResults(results, request),
            Draw = request.Draw,
            RecordsFiltered = results.Count,
            RecordsTotal = details.Count
        };
        return response;
    }
}

It's like our Customer search stuff, so I won't go into detail about it again.

Enter Display Templates...

Now we have a couple of search functions for retrieving customers and users. Wonderful. Now, how to best integrate them into our application? Remember, we're trying to avoid repeating the plugin code or table markup. Our aim is to display 2 tables on our page.

The first thing we'll do is add a HomeViewModel to our Index view. Within it, we'll have a TableWidgetViewModel for each of our tables. If you haven't got one, create a Models folder inside your project. Add these 2 classes to it:

public class TableWidgetViewModel
{
    public IList<string> ClientColumns { get; set; }
    public IList<string> Columns { get; set; }
    public string TableId { get; set; }
    public string WidgetHeading { get; set; }

    public TableWidgetViewModel Initialise(string[] clientColumns, string heading, 
        string tableId, params string[] columnHeadings)
    {
        ClientColumns = clientColumns;
        TableId = tableId;
        WidgetHeading = heading;
        Columns = columnHeadings.ToList();

        return this;
    }

    public MvcHtmlString RenderColumns()
    {
        var builder = new StringBuilder();
        foreach (var column in Columns)
        {
            builder.AppendFormat("<th>{0}</th>", column);
            builder.AppendLine();
        }

        var content = WrapColumns(builder.ToString());
        return content.ToHtmlString();
    }

    public MvcHtmlString RenderClientColumnScript()
    {
        if (ClientColumns == null || ClientColumns.Count == 0)
        {
            return MvcHtmlString.Empty;
        }

        var scriptBuilder = new StringBuilder();
        scriptBuilder.AppendLine("\"columns\": [");

        var scriptItems = ClientColumns.Select(WrapClientColumn).ToArray();
        var columnScript = string.Join(", ", scriptItems);
        scriptBuilder.AppendLine(columnScript);
        scriptBuilder.AppendLine("],");
        return scriptBuilder.ToHtmlString();
    }

    private static string WrapClientColumn(string columnName)
    {
        return string.Concat("{ \"data\": \"", columnName, "\" }");
    }

    private static string WrapColumns(string columnContent)
    {
        return string.Concat("<tr>", columnContent, "</tr>");
    }
}

public class HomeViewModel
{
    public TableWidgetViewModel CustomerWidget { get; set; }
    public TableWidgetViewModel UserWidget { get; set; }
}

The interesting class here is the TableWidgetViewModel. It's got 3 public methods that we'll call from our Controller code in a moment. 2 of these methods will generate the markup and client script we need. The 3rd, Initialise, will allow us to create table widgets using the same process each time. Let's add the model to our Index view. Open up Views/Home/Index.cshtml. Pop this line at the top to tell the view which type of model class it uses:

@model Levelnis.Learning.UsingDataTablesWithWebApi.Models.HomeViewModel

We now have what we call a Strongly Typed View. Our view expects to get a HomeViewModel when it renders. We'd better give it one now. Open up the HomeController and add some code to create our ViewModel:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        var model = Create();
        return View(model);
    }

    private static HomeViewModel Create()
    {
        var customerClientColumns = new[] { "companyName", "address", "postcode", "telephone" };
        var customerColumnHeadings = new[] { "Company Name", "Address", "Postcode", "Telephone" };
        var userClientColumns = new[] { "companyName", "firstName", "lastName", "email", "jobTitle" };
        var userColumnHeadings = new[] { "Company Name", "First Name", "Last Name", "Email", "JobTitle" };
        var model = new HomeViewModel
        {
            CustomerWidget = new TableWidgetViewModel().Initialise(customerClientColumns, "Customers", "CustomerTable", customerColumnHeadings),
            UserWidget = new TableWidgetViewModel().Initialise(userClientColumns, "Users", "UserTable", userColumnHeadings)
        };
        return model;
    }
}

This is an ideal scenario for using a factory to create the ViewModel. I'll leave that as an exercise for the reader though. We're going to keep things simple.

You can see we're calling the Initialise method for each widget. The client columns array contains the names of the properties on our data models. Remember when we created CustomerSearchDetail and UserSearchDetail? Well, the names here match up with the property names there. The column headings match up with the headings on our 2 tables in the screenshot above.

That's it for the Controller. Now let's turn our attention to the View. The first thing we need is a Display Template. This is a template file that's bound to a Model class, much like the HomeViewModel and Index View. We're going to take the markup from our Index view and create a Display Template with it. We'll then use that Display Template to display the table widgets on our HomeViewModel. If you want to read up on Display Templates, check out my article on keeping your Views clean with Display Templates.

First up, let's add a partial view to a new folder - Views/Shared/DisplayTemplates. The convention for Display Templates is to use the same name as the Model class. In our case, let's call it TableWidgetViewModel.cshtml. Add the markup from the Index view:

@model Levelnis.Learning.UsingDataTablesWithWebApi.Models.TableWidgetViewModel

<div class="panel panel-primary">
    <div class="panel-heading">
        <h3 class="panel-title"> @Model.WidgetHeading </h3>
    </div>
    <div class="panel-body">
        <table id="@Model.TableId" class="table table-striped table-bordered table-hover responsive" width="100%">
            <thead class="thin-border-bottom">
                @Model.RenderColumns()
            </thead>
        </table>
    </div>
</div>

We can now use this template in our View instead of repeating the markup for our second table. Here's the new Index markup. I've wrapped both tables in their own rows so they're not too squashed up on the page.

@model Levelnis.Learning.UsingDataTablesWithWebApi.Models.HomeViewModel
<div class="row">
    <div class="col-xs-12">
        @Html.DisplayFor(m => m.CustomerWidget)
    </div>
</div>

<div class="row">
    <div class="col-xs-12">
        @Html.DisplayFor(m => m.UserWidget)
    </div>
</div>

We use the DisplayFor extension method to render our markup for each table. The only thing left now is to create the plugin script code. We'll do this using a HelperResult. Add an App_Code folder to your project. Create a partial View inside called Helpers.cshtml. Add the following code to it:

@helper CreateActiveTableScriptlet(string id, string apiUrl, MvcHtmlString clientColumnScript, string emptyTableText, string zeroRecordsText, string isEnhanced = "true")
{
    var tableVar = "generate" + id;
    var scriptBuilder = new StringBuilder();
    scriptBuilder.AppendLine();
    scriptBuilder.AppendFormat("var {0} = $(\"#{1}\")", tableVar, id);
    scriptBuilder.AppendLine(".DataTable({");
    scriptBuilder.AppendLine("  \"processing\": true,");
    scriptBuilder.AppendLine("  \"serverSide\": true,");
    scriptBuilder.AppendLine("  \"ajax\": {");
    scriptBuilder.AppendFormat("    \"url\": \"{0}\",", apiUrl);
    scriptBuilder.AppendLine();
    scriptBuilder.AppendLine("    \"type\": \"POST\"");
    scriptBuilder.AppendLine("  },");
    scriptBuilder.Append(clientColumnScript);
    scriptBuilder.AppendLine("  \"language\": {");
    scriptBuilder.AppendFormat("    \"emptyTable\": \"{0}\",", emptyTableText);
    scriptBuilder.AppendLine();
    scriptBuilder.AppendFormat("    \"zeroRecords\": \"{0}\"", zeroRecordsText);
    scriptBuilder.AppendLine();
    scriptBuilder.AppendLine("  },");
    scriptBuilder.AppendFormat("  \"searching\": {0},", isEnhanced);
    scriptBuilder.AppendLine();
    scriptBuilder.AppendFormat("  \"ordering\": {0},", isEnhanced);
    scriptBuilder.AppendLine();
    scriptBuilder.AppendFormat("  \"paging\": {0}", isEnhanced);
    scriptBuilder.AppendLine();
    scriptBuilder.AppendLine("});");
    @scriptBuilder.ToHtmlString()
}

We're using a StringBuilder to build up the script that we'll output to the View. The very last line that starts with an @ symbol is where the magic happens. This writes the StringBuilder content to the stream of HTML that gets sent back to the browser. Now we need to use this helper. Let's replace our plugin script code with 2 calls to this Helper, one for each table. Replace the scripts section in the Index View with the following:

@section scripts {
    <script>
        (function($) {
        @Helpers.CreateTableScript("CustomerTable", "/api/customerSearch", Model.CustomerWidget.RenderClientColumnScript(), "There are no customers at present.", "There were no matching customers found.")
        @Helpers.CreateTableScript("UserTable", "/api/userSearch", Model.UserWidget.RenderClientColumnScript(), "There are no users at present.", "There were no matching users found.")
        })(jQuery);
    </script>
}

We're passing in the id of the table (which we set when we initialise the ViewModel), the API URL and column script. That sets up our tables with the correct data. That's it! To add a 3rd table to our page, we'd go through the following steps:

  • Create some data for it
  • Add a SearchDetail and SearchResponse
  • Create a filter method in the ApiHelper
  • Add a SearchController to bring back the data
  • Add a TableWidgetViewModel property to the HomeViewModel
  • Initialise the widget in the HomeController.Create method
  • Add a DisplayFor call to the view so we can display it
  • Add a CreateTableScript call to the scripts section, which connects the widget to its data
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment