Async Search with ASP.NET MVC + jQuery + CoNatural - Part II

Roger Torres - May 3rd, 2009
4 Comments

In Part I of this article, I described how to write an asynchronous search component that spawns multiple parallel search operations, and notify the caller when completed or canceled. It’s now time to build a user interface to Start, Cancel, Delete, and Monitor our search operations asynchronously.

After playing with the ASP.NET MVC framework for a while, this technology is quickly becoming my favorite presentation platform, specially for small to medium size projects where I don’t need fancy controls… so I decided to develop this GUI with ASP.NET MVC. I will also write a small jQuery plugin to encapsulate the client side of the search, including a protocol to exchange AJAX/JSON messages with the controllers.

Preparing the Solution

Let’s start by creating a new ASP.NET MVC application. [Download ASP.NET MVC Here].

Create ASP.NET MVC Application

Create ASP.NET MVC Application

This step is very straightforward, just follow the default application template. Don’t forget to build and execute the solution to make sure all the pieces are in the right place. Note: I’m using Visual Studio 2008 SP1 and the .NET framework 3.5 SP1 which is a requirement for ASP.NET MVC.

Now you can add a reference to the AsyncOp library from Part I, and we are done configuring our solution.

Preparing the Site

Before we start writing code, let’s make sure our master page has a reference to the jQuery library under the /Scripts folder:

<script src="../../Scripts/jquery-1.3.2.js" type="text/javascript"></script>

We are going to write a jQuery plugin, so let’s add that reference in advance:

<script src="../../Scripts/async.js" type="text/javascript"></script>

And finally, let’s define a block of CSS styles for the panel that will hold our search operations:

    <style type="text/css">
        .async ul {background-color:White;border:solid 1px black;padding:5px;list-style-type:none;}
        .async li {border-bottom:dotted 1px silver; padding:5px;}
        .async span {border:solid 1px silver;background-color:Yellow;padding:5px;font-size:80%;font-style:italic;margin:5px;}
        .async input {border:solid 1px silver;padding:5px;margin:5px;}
        .async input:hover {background-color:Gray;}
    </style>

The master page should look like this:

Site.Master

<head runat="server">
    <title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
    <link href="../../Content/Site.css" rel="stylesheet" type="text/css" />
    <script src="../../Scripts/jquery-1.3.2.js" type="text/javascript"></script>
    <script src="../../Scripts/async.js" type="text/javascript"></script>
 
    <style type="text/css">
        .async ul {background-color:White;border:solid 1px black;padding:5px;list-style-type:none;}
        .async li {border-bottom:dotted 1px silver; padding:5px;}
        .async span {border:solid 1px silver;background-color:Yellow;padding:5px;font-size:80%;font-style:italic;margin:5px;}
        .async input {border:solid 1px silver;padding:5px;margin:5px;}
        .async input:hover {background-color:Gray;}
    </style>
</head>

Managing Search Operations

Our goal is to provide a GUI to manage our asynchronous search operations, so we will need cover the following scenarios:

  1. List and monitor all running and completed operations.
  2. Start new search operations with different criteria.
  3. Cancel a running operation.
  4. Delete a completed operation (so it won’t be listed anymore).
  5. Present the results of a completed operation.

In order to simplify this example, the server side logic will be implemented inside the HomeController class, and I’m going to store the active search operations in the ASP.NET Session. In a production environment, I recommend to develop a more scalable “Pool” of operations where the administrator can control the maximum number of active searches and the memory used by the results.

We will start by writing a new Search action method to return all the active operations to the client. This method will only accept HTTP GET verbs, and will serialize the results back to the client using the new DataContractJsonSerializer implemented in .NET 3.5 SP1 under System.Runtime.Serialization.Json.

In Part I we annotated our AsyncOp class with DataContract and DataMember attributes to make sure only the elements defined in our contract are serialized to the client. The JsonResult class that ships with ASP.NET MVC Version 1.0 doesn’t use DataContractJsonSerializer, so the entire class (including the results) is serialized. I hope the MVC team is taking care of this little issue, but in the mean time, we can write our own JsonResult class as follows:

JsonResult.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Web.Mvc;
using System.Runtime.Serialization.Json;
 
namespace MvcAsyncSearch.MyTools {
   public class JsonResult : ActionResult {
      public Encoding ContentEncoding { get; set; }
      public string ContentType { get; set; }
      public object Data { get; set; }
 
      public override void ExecuteResult(ControllerContext context) {
         if (context == null)
            throw new ArgumentNullException("context");
 
         HttpResponseBase response = context.HttpContext.Response;
         if (!String.IsNullOrEmpty(ContentType))
            response.ContentType = ContentType;
         else
            response.ContentType = "application/json";
 
         if (ContentEncoding != null)
            response.ContentEncoding = ContentEncoding;
 
         if (Data != null) {
            DataContractJsonSerializer serializer = new DataContractJsonSerializer(Data.GetType());
            serializer.WriteObject(response.OutputStream, Data);
         }
      }
   }
}

Going back to our Action method, the following code takes care of presenting/monitoring all active searches. Note that I’m using a couple of helper methods to find the operations in the ASP.NET Session, and refresh the Status property of the AsyncOp instances before the results are serialized to the client.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[AcceptVerbs(HttpVerbs.Get)]
public ActionResult Search() {
   Dictionary<string, AsyncOp.AsyncOp> operations = GetAsyncOps(HttpContext.Session);
   SetStatus(operations);
   MyTools.JsonResult result = new MvcAsyncSearch.MyTools.JsonResult();
   result.Data = operations.Values.ToArray();
   return result;
}
 
private Dictionary<string, AsyncOp.AsyncOp> GetAsyncOps(HttpSessionStateBase session) {
   object ops = session["async"];
   if (ops == null) {
      ops = new Dictionary<string, AsyncOp.AsyncOp>();
      session["async"] = ops;
   }
   return (Dictionary<string, AsyncOp.AsyncOp>)ops;
}
 
private void SetStatus(Dictionary<string, AsyncOp.AsyncOp> operations) {
   foreach(AsyncOp.AsyncOp op in operations.Values) {
      if (op.Error != null)
         op.Status = "Error: " + op.Error.Message;
      else {
         if (op.Completed || op.Cancelled) {
            if (op.Completed)
               op.Status = "DONE.";
            else
               op.Status = "Cancelled.";
            if (op.Results.Count > 0)
               op.Status += " <a href=\"" + Url.Action("Results", 
                  new { asyncId = op.AsyncId }) + "\">" + op.Results.Count + " results found.</a>";
            else
               op.Status += " No results found.";
         }
         else
            op.Status = op.Results.Count + " results found ...";
      }
   }
}

Initially, we won’t have any active searches to display… so, we need another Search action method to Start, Cancel, and Remove operations from the pool. In this case, only HTTP POST verbs will be allowed, and the client side will be responsible for configuring the request with “Form” parameters as follows:

  • To cancel an operation, include parameter “cancel={AsyncId}”
  • To delete an operation, include parameter “dismiss={AsyncId}”
  • To start an operation, include parameter “criteria={Search Criteria}”

This Action will also return all the active operations to the client (same JSON results), effectively updating the user interface without having to wait for the next refresh request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Search(FormCollection collection) {
   Dictionary<string, AsyncOp.AsyncOp> operations = GetAsyncOps(HttpContext.Session);
   AsyncOp.AsyncOp op;
 
   if (collection.AllKeys.Contains("cancel")) {
      string asyncId = collection["cancel"];
      if (operations.TryGetValue(asyncId, out op)) {
         op.Cancel();
         op.Status = "Cancelling...";
      }
   }
   else if (collection.AllKeys.Contains("dismiss")) {
      string asyncId = collection["dismiss"];
      operations.Remove(asyncId);
   }
   else {
      string criteria = collection["criteria"];
      op = new AsyncOp.AsyncOp(criteria.Trim().ToUpper());
      operations.Add(op.AsyncId.ToString(), op);
      op.Start();
   }
 
   SetStatus(operations);
   MyTools.JsonResult result = new MvcAsyncSearch.MyTools.JsonResult();
   result.Data = operations.Values.ToArray();
   return result;
}

Here is how the final HomeController class should look like:

HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using AsyncOp;
 
namespace MvcAsyncSearch.Controllers {
   [HandleError]
   public class HomeController : Controller {
      public ActionResult Index() {
         ViewData["Message"] = "Welcome to ASP.NET MVC!";
 
         return View();
      }
 
      public ActionResult About() {
         return View();
      }
 
      [AcceptVerbs(HttpVerbs.Get)]
      public ActionResult Search() {
         Dictionary<string, AsyncOp.AsyncOp> operations = GetAsyncOps(HttpContext.Session);
         SetStatus(operations);
         MyTools.JsonResult result = new MvcAsyncSearch.MyTools.JsonResult();
         result.Data = operations.Values.ToArray();
         return result;
      }
 
      [AcceptVerbs(HttpVerbs.Post)]
      public ActionResult Search(FormCollection collection) {
         Dictionary<string, AsyncOp.AsyncOp> operations = GetAsyncOps(HttpContext.Session);
         AsyncOp.AsyncOp op;
         if (collection.AllKeys.Contains("cancel")) {
            string asyncId = collection["cancel"];
            if (operations.TryGetValue(asyncId, out op)) {
               op.Cancel();
               op.Status = "Cancelling...";
            }
         }
         else if (collection.AllKeys.Contains("dismiss")) {
            string asyncId = collection["dismiss"];
            operations.Remove(asyncId);
         }
         else {
            string criteria = collection["criteria"];
            op = new AsyncOp.AsyncOp(criteria.Trim().ToUpper());
            operations.Add(op.AsyncId.ToString(), op);
            op.Start();
         }
 
         SetStatus(operations);
         MyTools.JsonResult result = new MvcAsyncSearch.MyTools.JsonResult();
         result.Data = operations.Values.ToArray();
         return result;
      }
 
      [AcceptVerbs(HttpVerbs.Get)]
      public ActionResult Results(string asyncId) {
         Dictionary<string, AsyncOp.AsyncOp> operations = GetAsyncOps(HttpContext.Session);
         AsyncOp.AsyncOp op;
         if (operations.TryGetValue(asyncId, out op))
            return View(op.Results);
         else
            return View(new List<AsyncOp.AsyncOp.AsyncOpResult>());
      }
 
      private Dictionary<string, AsyncOp.AsyncOp> GetAsyncOps(HttpSessionStateBase session) {
         object ops = session["async"];
         if (ops == null) {
            ops = new Dictionary<string, AsyncOp.AsyncOp>();
            session["async"] = ops;
         }
         return (Dictionary<string, AsyncOp.AsyncOp>)ops;
      }
 
      private void SetStatus(Dictionary<string, AsyncOp.AsyncOp> operations) {
         foreach(AsyncOp.AsyncOp op in operations.Values) {
            if (op.Error != null)
               op.Status = "Error: " + op.Error.Message;
            else {
               if (op.Completed || op.Cancelled) {
                  if (op.Completed)
                     op.Status = "DONE.";
                  else
                     op.Status = "Cancelled.";
                  if (op.Results.Count > 0)
                     op.Status += " <a href=\"" + Url.Action("Results", 
                        new { asyncId = op.AsyncId }) + "\">" + op.Results.Count + " results found.</a>";
                  else
                     op.Status += " No results found.";
               }
               else
                  op.Status = op.Results.Count + " results found ...";
            }
         }
      }
   }
}

Implementing the client side with jQuery.

I really started loving client side JavaScript after I discovered jQuery. I don’t want to get into the details here (tales of my previous suffering with JS), but I will tell you this: If you haven’t tried jQuery yet, please do so and you won’t regret it.

One of the nicest things about jQuery is that you can extend the API very naturally. In this case, I’m writing a plugin to encapsulate the client side of my async search module, including presentation of active searches, polling the server for updates via AJAX requests with JSON serialization, and handling client events to start, cancel, and delete operations.

The first thing we should do when writing a jQuery plugin (or any other JavaScript library) is define a “namespace” where we can implement our own private and public variables and interfaces. I usually start with a JavaScript closure, passing the jQuery instance (to be extended internally). Next, declare the private namespace to define my private variables and functions (visible only inside the closure), and extend jQuery with a public namespace to expose my public interface.

Let’s begin our plugin from the following template. In this case we will be extending jQuery with two public methods ($.async.get and $.async.post):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// create closure
(function($) {
 
    // "async" namespace for private variables and functions
    var async = {
        var1: {},
        var2: [],
        f1 : function() {...},
        f2 : function() {...}
    };
 
    // extend jQuery with "async" namespace
    $.async = {
        defaults: {
             //TODO good practice to define default values
        },
 
        // GET ajax request to update the status of running async operations
        get: function(url, options) {
            // TODO ajax get (url) to the Search action defined previously in HomeController
            // TODO options to override the default parameters
        },
 
        // POST ajax request to start,cancel,delete async operations
        post: function(url, data, options) {
            // TODO ajax post (url) to the Search action defined previously in HomeController
            // TODO data with form parameters (criteria={criteria} | cancel={asyncId} | dismiss={asyncId})
            // TODO options to override the default parameters
        }
    };
 
})(jQuery);

Our plugin is initialized the first time a “Get” or “Post” operation is invoked from a Page, and by default it will use the “body” element as the container for the panels showing the async search operations. The plugin will also monitor the operations by polling the server every 5 seconds (by default) until all the searches are completed. Of course, I will override these defaults later to display the progress of my searches inside a smaller panel (styled with CSS). Let’s take a look at the public interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    // extend jQuery with "async" namespace
    $.async = {
        defaults: {
            container: "body",
            pollingInterval: 5000
        },
 
        // GET ajax request to update the status of running async operations
        get: function(url, options) {
            async.options = $.extend({ url: url }, $.async.defaults, options, async.options);
            async.poll();
        },
 
        // posts request to start a new async operation
        post: function(url, data, options) {
            async.options = $.extend({ url: url }, $.async.defaults, options, async.options);
            $.post(url, data, async.callback, "json");
        }
    };

There is nothing special here other than storing the options inside a private variable and invoking the server actions. The core of the plugin is encapsulated inside the private namespace, where a group of callback functions are responsible for adding and removing search operation panels, polling the server for updates, and handling click events (to start-cancel-remove operations). Here I keep a list of panels synchronized with the server, so the plugin will know when to display a new panel. The “cancel” and “dismiss” handlers should be straightforward (unless you are new to jQuery).

The main engine is implemented inside the “callback” function, which receives a list of JSON-formatted active operations from AJAX requests to the server, and depending on the status of each operation, it creates a new panel, updates the status labels and buttons, and restarts the timer to keep polling the server for updates:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
    // "async" namespace private variables and functions
    var async = {
        // panels showing async operations
        panels: [],
 
        // cancel handler
        cancel: function() {
            var asyncId = $(this).attr("asyncId");
            var panel = async.panels[asyncId];
            if (panel) {
                $("input", panel).hide();
                $.post(async.options.url, { cancel: asyncId }, async.callback, "json");
            }
        },
 
        // dismiss handler
        dismiss: function() {
            var asyncId = $(this).attr("asyncId");
            var panel = async.panels[asyncId];
            if (panel) {
                $.post(async.options.url, { dismiss: asyncId }, function() {
                    $("#" + asyncId, async.panelcontainer).remove();
                    async.panels[asyncId] = null;
                }, "json");
            }
        },
 
        // ajax callback to handle async operations
        callback: function(result) {
            var pending = 0;
            for (var i in result) {
                var asyncOp = result[i];
                var panel = async.panels[asyncOp.AsyncId];
 
                // handle new operations
                if (!panel) {
                    // create panel container on demand
                    if (!async.panelcontainer) {
                        async.panelcontainer = $("<ul id='async'></ul>");
                        $(async.options.container).prepend(async.panelcontainer);
                    }
 
                    // create panel for this operation
                    panel = async.panels[asyncOp.AsyncId] = $("<li id='" + asyncOp.AsyncId + "'></li>");
                    async.panelcontainer.append(panel);
 
                    // add status and cancel button to panel
                    panel.append("<b>Searching [" + asyncOp.Criteria + "]:&nbsp;</b>");
                    panel.append("<span>Starting...</span>");
                    var cancelBtn = $("<input type='button' value='Cancel' asyncId='" + asyncOp.AsyncId + "' />").click(async.cancel);
                    panel.append(cancelBtn);
                }
 
                // check op status
                if (asyncOp.Completed) {
                    $("input", panel).val("Dismiss").unbind("click", async.cancel).click(async.dismiss).show();
                }
                else if (asyncOp.Cancelled) {
                    $("input", panel).val("Dismiss").unbind("click", async.cancel).click(async.dismiss).show();
                }
                else {
                    // report progress
                    pending++;
                }
                $("span", panel).html(asyncOp.Status);
            }
 
            // polling
            if (async.timeoutId) {
                clearTimeout(async.timeoutId);
                async.timeoutId = null;
            }
            if (pending > 0)
                async.timeoutId = setTimeout(async.poll, async.options.pollingInterval);
        },
 
        // polling timeout callback
        poll: function() {
            $.get(async.options.url, {}, async.callback, "json");
        }
    };

The final async.js JavaScript file should be added to the /Scripts folder and will look like this:

async.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// create closure
(function($) {
    // "async" namespace private variables and functions
    var async = {
        // panels showing async operations
        panels: [],
 
        // cancel handler
        cancel: function() {
            var asyncId = $(this).attr("asyncId");
            var panel = async.panels[asyncId];
            if (panel) {
                $("input", panel).hide();
                $.post(async.options.url, { cancel: asyncId }, async.callback, "json");
            }
        },
 
        // dismiss handler
        dismiss: function() {
            var asyncId = $(this).attr("asyncId");
            var panel = async.panels[asyncId];
            if (panel) {
                $.post(async.options.url, { dismiss: asyncId }, function() {
                    $("#" + asyncId, async.panelcontainer).remove();
                    async.panels[asyncId] = null;
                }, "json");
            }
        },
 
        // ajax callback to handle async operations
        callback: function(result) {
            var pending = 0;
            for (var i in result) {
                var asyncOp = result[i];
                var panel = async.panels[asyncOp.AsyncId];
 
                // handle new operations
                if (!panel) {
                    // create panel container on demand
                    if (!async.panelcontainer) {
                        async.panelcontainer = $("<ul id='async'></ul>");
                        $(async.options.container).prepend(async.panelcontainer);
                    }
 
                    // create panel for this operation
                    panel = async.panels[asyncOp.AsyncId] = $("<li id='" + asyncOp.AsyncId + "'></li>");
                    async.panelcontainer.append(panel);
 
                    // add status and cancel button to panel
                    panel.append("<b>Searching [" + asyncOp.Criteria + "]:&nbsp;</b>");
                    panel.append("<span>Starting...</span>");
                    var cancelBtn = $("<input type='button' value='Cancel' asyncId='" + asyncOp.AsyncId + "' />").click(async.cancel);
                    panel.append(cancelBtn);
                }
 
                // check op status
                if (asyncOp.Completed) {
                    $("input", panel).val("Dismiss").unbind("click", async.cancel).click(async.dismiss).show();
                }
                else if (asyncOp.Cancelled) {
                    $("input", panel).val("Dismiss").unbind("click", async.cancel).click(async.dismiss).show();
                }
                else {
                    // report progress
                    pending++;
                }
                $("span", panel).html(asyncOp.Status);
            }
 
            // polling
            if (async.timeoutId) {
                clearTimeout(async.timeoutId);
                async.timeoutId = null;
            }
            if (pending > 0)
                async.timeoutId = setTimeout(async.poll, async.options.pollingInterval);
        },
 
        // polling timeout callback
        poll: function() {
            $.get(async.options.url, {}, async.callback, "json");
        }
    };
 
    // extend jQuery with "async" namespace
    $.async = {
        defaults: {
            container: "body",
            pollingInterval: 5000
        },
 
        // GET ajax request to update the status of running async operations
        get: function(url, options) {
            async.options = $.extend({ url: url }, $.async.defaults, options, async.options);
            async.poll();
        },
 
        // posts request to start a new async operation
        post: function(url, data, options) {
            async.options = $.extend({ url: url }, $.async.defaults, options, async.options);
            $.post(url, data, async.callback, "json");
        }
    };
 
})(jQuery);

Almost there

OK, we have the server and client sides ready to go. It’s now time to decide which pages will display or initiate our async search operations. We are going to use the main page in this case.

Let’s insert the following HTML block to /Views/Home/Index.aspx:

    <div id="container" class="async">
    </div>
 
    <h2>Search</h2>
    <input id="searchCriteria" type="text" maxlength="20" />
    <br />
    <input id="startSearch" type="button" value="Start Search" />
    <br />
 
    <script type="text/javascript">
        $(function() {
            $("#startSearch").click(function() {
                $.async.post("/Home/Search", { criteria: $("#searchCriteria").val() });
            });
 
            // refresh
            $.async.get("/Home/Search", { container: $("#container") });
        });
    </script>

Here we are defining a container to hold our search panels (styled with CSS class “async”), a couple of input elements to initiate new search operations, and a block of JavaScript code that gets called when the page is ready. We want to display all active searches every time we refresh this page, that’s why we need to invoke $.async.get(GetUrl, options) to synchronize with the server. The click handler to initiate new operations is also trivial, invoking $.async.post(PostUrl, data, options) and passing the “criteria” argument from the input element #searchCriteria. The application is almost ready!, we just need to take care of one final detail…

Creating the View to display Results.

Let’s review the HomeController for a second. You might have noticed the “Results” action, and how the operation status is updated with a link to this action when the search completed with some results.

HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using AsyncOp;
 
namespace MvcAsyncSearch.Controllers {
   [HandleError]
   public class HomeController : Controller {
      public ActionResult Index() {... }
      public ActionResult About() {... }
 
      [AcceptVerbs(HttpVerbs.Get)]
      public ActionResult Search() {...}
 
      [AcceptVerbs(HttpVerbs.Post)]
      public ActionResult Search(FormCollection collection) {...}
 
      [AcceptVerbs(HttpVerbs.Get)]
      public ActionResult Results(string asyncId) {
         Dictionary<string, AsyncOp.AsyncOp> operations = GetAsyncOps(HttpContext.Session);
         AsyncOp.AsyncOp op;
         if (operations.TryGetValue(asyncId, out op))
            return View(op.Results);
         else
            return View(new List<AsyncOp.AsyncOp.AsyncOpResult>());
      }
 
      private Dictionary<string, AsyncOp.AsyncOp> GetAsyncOps(HttpSessionStateBase session) {...}
 
      private void SetStatus(Dictionary<string, AsyncOp.AsyncOp> operations) {
         foreach(AsyncOp.AsyncOp op in operations.Values) {
            if (op.Error != null)
               op.Status = "Error: " + op.Error.Message;
            else {
               if (op.Completed || op.Cancelled) {
                  if (op.Completed)
                     op.Status = "DONE.";
                  else
                     op.Status = "Cancelled.";
                  if (op.Results.Count > 0)
                     op.Status += " <a href=\"" + Url.Action("Results", 
                        new { asyncId = op.AsyncId }) + "\">" + op.Results.Count + " results found.</a>";
                  else
                     op.Status += " No results found.";
               }
               else
                  op.Status = op.Results.Count + " results found ...";
            }
         }
      }
   }
}

This is the last scenario we needed to cover, and with MVC we just create a new View under /Views/Home/Result.aspx (the view name matching the action name is a nice MVC shortcut) as follows:

Results.aspx

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<IEnumerable<AsyncOp.AsyncOp.AsyncOpResult>>" %>
 
<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
	Results
</asp:Content>
 
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
 
    <h2>Results</h2>
 
    <table>
        <thead>
            <tr>
                <th>Type</th>
                <th>Name</th>
            </tr>
        </thead>
        <tbody>
        <% foreach (AsyncOp.AsyncOp.AsyncOpResult r in ViewData.Model) { %>
            <tr>
                <td><%= r.Type %></td>
                <td><%= r.Name %></td>
            </tr>
        <% } %>
        </tbody>
    </table>
 
</asp:Content>

So here is how it goes. The plugin eventually receives a status with a link to the “Results”/{asyncId} action for each completed operation with results… and when clicked… the HomeController finds the operation in the Session cache and redirects to the Results.aspx view, passing the operations results as arguments.

Conclusion

In this article I wanted to show how to solve a common programming problem with a combination of modern technologies and some of the components I use in my projects. But I’m only scratching the surface… so, if you really want to learn how to exploit these technologies, I recommend some reading first [ASP.NET MVC, jQuery], and many butt hours with your debuggers on ;-)