Asynchronous TreeView in ASP.NET WebForms

Some time ago I have written about using jQuery TreeView plugin for creating an asynchronous TreeView in ASP.NET MVC. Recently I had to use the same plugin in WebForms application, so I decided to share my experience. First let's reference all JavaScript and CSS needed:
<script src="Scripts/jquery-1.4.1.min.js" type="text/javascript"></script>
<script src="Scripts/jquery.treeview.min.js" type="text/javascript"></script>
<script src="Scripts/jquery.treeview.async.js" type="text/javascript"></script>
<link href="~/Styles/jquery.treeview.css" rel="stylesheet" type="text/css" />
We should also add the markup for TreeView (an empty list):
<ul id="trFileBrowser"></ul>
Now we should provide data for TreeView, there are generally two approaches in which we can do that.
WebService approach
If we want to use WebService as the data source, we should start by adding an Ajax-enabled WCF Service to the project:
[ServiceContract(Namespace = "AsyncTreeViewExample")]
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
public class TreeView
{
...
}
and enabling the Web programming model for this service (in web.config):

...
<system.serviceModel>
<behaviors>
<endpointBehaviors>
<behavior name="AsyncTreeViewExample.TreeViewAspNetAjaxBehavior">
<webHttp />
</behavior>
</endpointBehaviors>
</behaviors>
<serviceHostingEnvironment aspNetCompatibilityEnabled="true" multipleSiteBindingsEnabled="true" />
<services>
<service name="AsyncTreeViewExample.TreeView">
<endpoint address="" behaviorConfiguration="AsyncTreeViewExample.TreeViewAspNetAjaxBehavior" binding="webHttpBinding" contract="AsyncTreeViewExample.TreeView" />
</service>
</services>
</system.serviceModel>
...
In order to write WebService method properly, we need to remember that plugin performs GET request with application/x-www-form-urlencoded content type and expects bare JSON serialized array in response. Knowing all that, the method should look like this (TreeViewNode class was described in previous post about this plugin):

[OperationContract]
//Accept GET request and return bare JSON serialized result
[WebInvoke(Method = "GET", BodyStyle = WebMessageBodyStyle.Bare, ResponseFormat = WebMessageFormat.Json)]
public List<TreeViewNode> FileBrowserData(string root)
{
DirectoryInfo rootDirectory = null;
if (root == "source")
rootDirectory = new DirectoryInfo(@"E:\");
else
rootDirectory = new DirectoryInfo(root);

var directoryChildren = from child in rootDirectory.GetFileSystemInfos()
orderby child is DirectoryInfo descending
select child;

List<TreeViewNode> nodes = new List<TreeViewNode>();
foreach (FileSystemInfo directoryChild in directoryChildren)
{
bool isDirectory = directoryChild is DirectoryInfo;
nodes.Add(new TreeViewNode()
{
id = directoryChild.FullName,
text = directoryChild.Name,
classes = isDirectory ? "folder" : "file",
hasChildren = isDirectory
});
}

return nodes;
}
Last thing to do in this approach is adding some JavaScript for initialization purposes:
<script type="text/javascript">
$(document).ready(function () {
$('#trFileBrowser').treeview({
url: '<% = ResolveClientUrl("~/TreeView.svc/FileBrowserData") %>'
});
});
</script>
PageMethod approach
Using PageMethod as a source of data is a little bit more tricky. First of all PageMethods requires request to have application/json content type (so all the parameters have to be properly JSON serialized). Also PageMethod always returns response with wrapped body. There are three ways in which we can workaround this.
First way is to modify the plugin in such a way that it will work with PageMethod requirements (I would recommend this way, as others should be considered nasty workarounds). This is modified load funtion from jquery.treeview.async.js (I'm also changing request from GET to POST here):
function load(settings, root, child, container) {
$.ajax({
//Changing request to POST
type: "POST",
url: settings.url,
//Required content type
contentType: 'application/json; charset=utf-8',
//Properly JSON serialized data
data: '{"root": "' + root + '"}',
dataType: 'json',
success: function(response) {
function createNode(parent) {
var current = $("<li/>").attr("id", this.id || "").html("<span>" + this.text + "</span>").appendTo(parent);
if (this.classes) {
current.children("span").addClass(this.classes);
}
if (this.expanded) {
current.addClass("open");
}
if (this.hasChildren || this.children && this.children.length) {
var branch = $("<ul/>").appendTo(current);
if (this.hasChildren) {
current.addClass("hasChildren");
createNode.call({
text:"placeholder",
id:"placeholder",
children:[]
}, branch);
}
if (this.children && this.children.length) {
$.each(this.children, createNode, [branch])
}
}
}
//Accessing wrapped array
$.each(response.d, createNode, [child]);
$(container).treeview({add: child});
}
});
}
After that we just need a regular PageMethod:
[System.Web.Services.WebMethod]
public static List<TreeViewNode> FileBrowserData(string root)
{
//Method body is the same as in WebService
...
}
and initalization JavaScript:
<script type="text/javascript">
$(document).ready(function () {
$('#trFileBrowser').treeview({
url: '<% = ResolveClientUrl("~/PageMethodPlugin.aspx/FileBrowserData") %>'
});
});
</script>
Second way we can call a 'client-side workaround'. The idea is to change some default settings for all Ajax requests (it's nasty and I wouldn't recommend using it):

<script type="text/javascript">
$(document).ready(function () {
//Default callback for creating the XMLHttpRequest object
var xhr = $.ajaxSettings.xhr;

//Changing default values for all Ajax requests
$.ajaxSetup({
//Required content type
contentType: 'application/json; charset=utf-8',
//New callback for creating the XMLHttpRequest object
xhr: function () {
//Wrapping url parameters with '"'
if (this.url.indexOf('=') >= 0) {
this.url = this.url.replace(/=/g, '="') + '"';
}
//Calling default callback
return xhr.call(this);
},
//Sanitize the response (remove wrapping)
dataFilter: function (data, dataType) {
if (dataType == "json") {
var result = $.parseJSON(data);
if (result.d) {
return result.d;
} else {
return data;
}
} else {
return data;
}
}
});
$('#trFileBrowser').treeview({
url: '<% = ResolveClientUrl("~/PageMethodClient.aspx/FileBrowserData") %>'
});
});
</script>
We also have to allow GET verb on PageMethod:

[System.Web.Services.WebMethod]
[System.Web.Script.Services.ScriptMethod(UseHttpGet = true)]
public static List<TreeViewNode> FileBrowserData(string root)
{
//Method body is the same as in WebService
...
}
Third way is 'server-side workaround'. We will write PageMethod in a little bit different way, so it will work with TreeView:
[System.Web.Services.WebMethod]
[System.Web.Script.Services.ScriptMethod(UseHttpGet = true)]
//Method doesn't take any parameters and return anything
public static void FileBrowserData()
{
//We are getting 'root' directly from request params
string root = HttpContext.Current.Request.Params.Get("root");

//Nodes generation code is the same as in other cases
...

//We are serializing nodes
JavaScriptSerializer serializer = new JavaScriptSerializer();
string nodesSerialized = serializer.Serialize(nodes);

//And writing them directly into response
HttpContext.Current.Response.Clear();
HttpContext.Current.Response.ContentType = "application/json; charset=utf-8";
HttpContext.Current.Response.AddHeader("Content-Length", nodesSerialized.Length.ToString());
HttpContext.Current.Response.AddHeader("Connection", "Close");
HttpContext.Current.Response.Write(nodesSerialized);
HttpContext.Current.Response.Flush();
}
What is left is few lines of JavaScript:
<script type="text/javascript">
$(document).ready(function () {
//Changing default values for all Ajax requests
$.ajaxSetup({
//Required content type
contentType: 'application/json; charset=utf-8'
});
$('#trFileBrowser').treeview({
url: '<% = ResolveClientUrl("~/PageMethodClient.aspx/FileBrowserData") %>'
});
});
</script>
Now we have all approaches covered, the result of this work looks like this:
You can download source code from repository.