Introduction
Project Silk, a Microsoft sample offering guidance for building cross-browser web applications, is written using ASP.NET MVC3 and jQuery. This article demonstrates that it is much simpler to build using ASP.NET Web Forms, resulting in a considerably smaller, cleaner and more maintainable code base.
We'll concentrate on the following Project Silk navigation design principles:
- Ajax must be used to prevent full page refreshes while retaining a working browser back button and the ability to bookmark a page in a specific state.
- The site must work with javascript disabled
Project Silk requires a lot of complex JavaScript code to achieve the first point and a lot of duplicated controller and view code to achieve the second point.
This article shows that by using ASP.NET Web Forms, both points can be achieved without writing any JavaScript and without any duplicated code at all.
Background
You should have a basic familiarity with Project Silk, http://silk.codeplex.com/.
You should have an intermediate understanding of the Navigation for ASP.NET framework, http://navigation.codeplex.com/. It can be installed via NuGet using the package name 'Navigation
'.
Although we're dealing with a Web Forms application, the code and folder structure is identical to a typical MVC one as it has view models, controllers and a repository. In order to concentrate on the navigational aspects, the non-navigational code has been simplified as much as possible, e.g., the repository uses in memory collections rather than a database; the controllers are devoid of business logic; and the repository model classes are used as the view models.
Data Binding
The application consists of a Dashboard page and one List
and one Details
user control for each of the three functional areas: vehicle, fill ups and reminders. All the communication between these views and the controllers is via data binding. Each view consists of a ListView
or a FormView
which is databound to a controller method using an ObjectDataSource
. Using databinding in this way means we adhere to good coding practices, in particular, it leads to empty code behinds and testable controllers.
We'll begin with the List
and Details
methods on the FillupController
. These are very basic methods that just filter the list of Repository fill ups:
public IEnumerable<FillupEntry> List(int vehicleId)
{
return _Repository.Fillups.Where(f => f.VehicleId == vehicleId).ToList();
}
public FillupEntry Details(int vehicleId, int fillupId)
{
return _Repository.Fillups.Single(f => f.VehicleId == vehicleId &&
f.FillupId == fillupId);
}
Both these methods require parameters to be passed, so to databind to them, the ObjectDataSource
must have SelectParameters
specified. However, none of the built in ASP.NET parameter types are appropriate as our parameters need to be sourced differently in different scenarios, e.g., when JavaScript is disabled, a QueryString
parameter would suit, but when ASP.NET Ajax is used, a QueryString
parameter would not suffice.
The Navigation for ASP.NET Web Forms framework solves this problem. This framework has its own data store, called NavigationData
, with the following key features:
NavigationData
is initialised from the query string the first time a page loads.
NavigationData
is persisted in ControlState
and so is preserved across post backs.
NavigationData
can be managed programmatically (via the dynamic Bag
property on the StateContext
class).
This makes it the perfect candidate as we don't need to worry about whether the data is sourced from the original query string values (JavaScript disabled) or whether it has been updated as the result of a post back (ASP.NET Ajax). The Navigation
framework provides a NavigationData
parameter for use with declarative data binding, so the ObjectDataSource
for the Details
method becomes:
<asp:ObjectDataSource ID="DetailsSource" runat="server"
TypeName="MileageStats.Web.Controllers.FillUpController" SelectMethod="Details">
<SelectParameters>
<nav:NavigationDataParameter Name="vehicleId" />
<nav:NavigationDataParameter Name="fillupId" />
</SelectParameters>
</asp:ObjectDataSource>
When the page is first loaded, the NavigationData
will be empty, so we need to manually set the vehicle and fill up ids in order for the NavigationData
parameter values to be populated. Because of the ordering of the user controls on the page, the List
methods are called before the Details
method and, in particular, the vehicle List
method is called the first of all. So we'll set an initial value in each controller's List
method, i.e., the VehicleController
List will initialise the vehicle id, the FillupController
will initialise the fill up id, etc. That ensures the requisite NavigationData
will be populated prior to each controller method's execution. Shown below is the List
method programmatically setting the fill up id in NavigationData
:
public IEnumerable<FillupEntry> List(int vehicleId)
{
IEnumerable<FillupEntry> fillups = _Repository.Fillups.Where
(f => f.VehicleId == vehicleId).ToList();
StateContext.Bag. fillupId = fillups.First().FillupId;
return fillups;
}
In order to make the fill ups in the List
selectable, we need to add a hyperlink that points to this Dashboard page and passes the current vehicle id and the selected fill up id. The Navigation
framework has a NavigationHyperLink
control to help with this movement and data passing between pages. The important property settings of the NavigationHyperLink
are:
ToData
- contains the changed NavigationData
to pass, i.e., because the fill up id is different but the vehicle id is unchanged, we need only pass the fill up id
Direction
- set to Refresh
since we want to hyperlink to the same page we are currently on
IncludeCurrentData
- set to true
to include the current NavigationData
together with the ToData
, in particular it means the current vehicle id will be passed
So adding the NavigationHyperLink
to our fill up ListView
gives:
<asp:ListView ID="FillUps" runat="server" DataSourceID="ListSource">
<LayoutTemplate>
<asp:PlaceHolder ID="itemPlaceholder" runat="server" />
</LayoutTemplate>
<ItemTemplate>
<nav:NavigationHyperLink ID="DetailsLink" runat="server"
ToData='<%# new NavigationData(){{ "fillupId", Eval("FillupId") }} %>'
Direction="Refresh" IncludeCurrentData="true" Text="Select"/>
</ItemTemplate>
</asp:ListView>
Clicking this fill up hyperlink will cause the selected fill up id and current vehicle id to be passed in the query string and so the NavigationData
will be initialised correctly. However, the logic added above to initialise the id in the List
methods is now problematic since it will overwrite the NavigationData
passed. To fix this, we need to change the List
methods so they only initialise the id if it is not already populated. This involves adding the id as a parameter to the List
method so that it can be checked (hence, we also need to add the corresponding NavigationData
parameter to the List
's ObjectDataSource
):
public IEnumerable<FillupEntry> List(int vehicleId, int? fillupId)
{
IEnumerable<FillupEntry> fillups = _Repository.Fillups.Where
(f => f.VehicleId == vehicleId).ToList();
if (!fillupId .HasValue)
StateContext.Bag.fillupId = fillups.First().FillupId;
return fillups;
}
We've fixed the problem for when we move between fill ups for a given vehicle, but we have a different problem when we switch between vehicles when viewing fill ups. This is because the fill up id set in NavigationData
will point to a fill up for the previous vehicle and will not be valid for the newly selected one. To fix this, we'll change the List
method so it checks if the fill up id exists for that vehicle and if not it initialises it:
public IEnumerable<FillupEntry> List(int vehicleId, int? fillupId)
{
IEnumerable<FillupEntry> fillups = _Repository.Fillups.Where
(f => f.VehicleId == vehicleId).ToList();
if (!fillupId .HasValue || fillups.Where(f => f.FillupId ==
fillupId).FirstOrDefault() == null)
StateContext.Bag.fillupId = fillups.First().FillupId;
return fillups;
}
With just this small amount of code, we've got ourselves a Dashboard that works with JavaScript off.
However, the List
and Details
methods always execute regardless of the functional area being viewed, i.e., even when viewing the reminders the List
and Details
methods on the FillupController
still run. This can lead to performance problems. Luckily, we can make use of another piece of NavigationData
, namely layout
. The layout
is maintained by the containing Dashboard page, and its associated controller, and tracks which functional area is being viewed. We just need to add the layout
parameter to all our controller methods (and corresponding NavigationDataParameter
to all ObjectDataSources
), which can then be inspected to decide whether it is worth querying the repository:
public FillupEntry Details(string layout, int vehicleId, int fillupId)
{
if (layout != "fillups") return null;
return _Repository.Fillups.Single
(f => f.VehicleId == vehicleId && f.FillupId == fillupId);
}
Ajax
To add ajax functionality to the code, we'll use the progressive enhancement capabilities of the Navigation
framework together with ASP.NET Ajax. NavigationHyperLinks
by default render as normal links, but by setting their PostBack
property to true
, they'll render an onclick
attribute so that, if JavaScript is enabled, they will post back instead:
<a href="http://www.codeproject.com/Dashboard" onclick="__doPostBack('Link');
return false" id="Link">Select</a>
Traditionally our code would have to be aware of whether a GET
or a POST
was performed in order to source its data correctly, e.g., from the query string
for the former, and from a control for the latter. However, we don't have this problem as we're using NavigationData
and this will be populated identically regardless of whether or not JavaScript is enabled. So, we can set the PostBack
property of our NavigationHyperLinks
to true
without having to change any of our controller code:
<nav:NavigationHyperLink ID="DetailsLink" runat="server"
ToData='<%# new NavigationData(){{ "fillupId", Eval("FillupId") }} %>'
Direction="Refresh" IncludeCurrentData="true" Text="Select" PostBack="true"/>
Now that the NavigationHyperLinks
are posting back, it is a simple matter to add ajax functionality. Wrapping each FormView
and ListView
in an UpdatePanel
will turn all requests into partial page requests:
<asp:UpdatePanel ID="Content" runat="server" UpdateMode="Conditional">
<ContentTemplate>
<asp:ListView ID="FillUps" runat="server" DataSourceID="ListSource">
<LayoutTemplate>
<asp:PlaceHolder ID="itemPlaceholder" runat="server" />
</LayoutTemplate>
<ItemTemplate>
<nav:NavigationHyperLink ID="DetailsLink" runat="server"
ToData='<%# new NavigationData(){{ "fillupId", Eval("FillupId") }} %>'
Direction="Refresh" IncludeCurrentData="true" Text="Select"'
PostBack="true"/>
</ItemTemplate>
</asp:ListView>
</ContentTemplate>
</asp:UpdatePanel>
We've almost finished adding ajax functionality, however one problem remains. Clicking the fill up selection hyperlink does not cause the fill up details to change. This is because the hyperlink is inside one update panel and the details inside another and hence this latter update panel will not be automatically updated. Since the Details FormView
is databound
whenever one of its NavigationData
parameters changes, we'll add a listener to the FormView
's DataBound
event and manually update the UpdatePanel
content:
protected void Details_DataBound(object sender, EventArgs e)
{
Content.Update();
}
With just this small amount of code, we've ajaxified our Dashboard.
Back Button and Bookmarking
ASP.NET Ajax history can be used to add back button support and, because it uses the URL hash to store the application state, it automatically supports bookmarking. History points can be added programmatically and when the back button is pressed, the ScriptManager
raises a Navigate
event passing the historic state data. As long as this data is set back into the NavigationData
, then the current controller code will automatically work without any changes. The Navigation
framework will do this for us, providing we call its AddHistoryPoint
and NavigateHistory
methods.
We want to add history every time the user switches vehicles or functional area. In terms of NavigationData
, this corresponds to a change in either the vehicle id or the layout, so we'll change the Dashboard's DataBound
event listener to call AddHistoryPoint
:
protected void Details_DataBound(object sender, EventArgs e)
{
Content.Update();
if (ScriptManager.GetCurrent(this).IsInAsyncPostBack &&
!ScriptManager.GetCurrent(this).IsNavigating)
{
NavigationData toData = new NavigationData();
toData.Bag.vehicleId = StateContext.Bag.vehicleId;
toData.Bag.layout = StateContext.Bag.layout;
StateController.AddHistoryPoint(this, toData, null);
}
}
Then to restore the NavigationData
we simply add a listener to the ScriptManager
's Navigate
event and pass the state data to the NavigateHistory
method:
protected void ScriptManager_Navigate(object sender, HistoryEventArgs e)
{
if (ScriptManager.IsInAsyncPostBack)
{
StateController.NavigateHistory(e.State);
}
}
With just this small amount of code, we've added back button and bookmarking support to our Dashboard.
Conclusion
There we have it, Project Silk converted to ASP.NET Web Forms at a fraction of the complexity and code size. Although we haven't shown it here, the controller code can be fully unit tested. The code is very easy to maintain and enhance, e.g., to add a history point each time the user switches between fill ups, you just need to add a single line of code (this would be considerably harder in Project Silk).
Although this is a cut down version of the Dashboard (e.g. it doesn't support editing), I have forked the Project Silk code and implemented a complete Dashboard at NavigationSilk (make sure you set the new MileageStats.Web.Navigation
project as the start up project and Default.aspx as the start page).
History
- 1st December, 2011: Initial post