Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Project Silk Navigation for ASP.NET Web Forms

0.00/5 (No votes)
13 Dec 2012 1  
A conversion of Project Silk to ASP.NET Web Forms using the Navigation for ASP.NET Web Forms codeplex project, concentrating on the navigational aspects

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:

  1. 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.
  2. 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:

  1. NavigationData is initialised from the query string the first time a page loads.
  2. NavigationData is persisted in ControlState and so is preserved across post backs.
  3. 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:

  1. 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
  2. Direction - set to Refresh since we want to hyperlink to the same page we are currently on
  3. 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

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here