Click here to Skip to main content
15,124,187 members
Articles / Web Development / ASP.NET
Article
Posted 17 Dec 2011

Stats

32K views
1K downloads
23 bookmarked

Knockout Navigation for ASP.NET Web Forms

Rate me:
Please Sign up or sign in to vote.
5.00/5 (5 votes)
17 Dec 2011CPOL9 min read
Progressively enhanced ASP.NET Web Forms version of Knockout tutorial sample

Introduction

Single page applications are all the rage. They are web sites where all navigation takes place within a single page and without any full page refreshes.

Typically, these are built using web services returning JSON on the server and a templating engine client side to convert the data into HTML, which is used to update the DOM. However, these web sites tend to have the following problems:

  1. They are not fully accessible - These sites rely on JavaScript which is not enabled in every browser. They are not keyboard friendly, e.g., elements that must be clicked are not reachable using the tab key.
  2. They are SEO unfriendly – web crawlers can be thought of as JavaScript disabled clients and so work best with standard hyperlinks returning HTML.

An example of such a web site is detailed in the Knockout tutorial titled 'Single page applications'.

In this article, we'll demonstrate that it's just as easy to build this Knockout sample web site using ASP.NET Web Forms. We'll use progressive enhancement, a web design strategy that allows everyone to access the basic content whilst providing an enhanced experience for supporting clients, and so the resulting web site will be fully accessible and SEO friendly.

Background

You should have an intermediate understanding of the Navigation for ASP.NET framework, http://navigation.codeplex.com/.

It can be installed via NuGet by running the Install-Package Navigation command in the Package Manager Console.

Building a webmail client

Since we're building a single page application, it will consist of only one ASPX page, to which we'll add a list representing our mail folders: Inbox, Archive, Sent, and Spam. We'll build each folder using a NavigationHyperLink as this lets us set our navigation details declaratively, i.e., without having to use the code-behind. We'll use refresh navigation, by setting the control's Direction property to Refresh, as this means we'll remain on the same page when the links are clicked.

ASP.NET
<ul class="folders">
    <li><nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox" Direction="Refresh" /></li>
    <li><nav:NavigationHyperLink ID="Archive" runat="server" Text="Archive" Direction="Refresh" /></li>
    <li><nav:NavigationHyperLink ID="Sent" runat="server" Text="Sent" Direction="Refresh" /></li>
    <li><nav:NavigationHyperLink ID="Spam" runat="server" Text="Spam" Direction="Refresh" /></li>
</ul>

Making the folders selectable

To mark a folder as selected, we need to know which one has been clicked, so we'll set the folder name in the ToData of each NavigationHyperLink.

ASP.NET
<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox" 
         Direction="Refresh" ToData="<%$ NavigationData: folder=inbox %>" />

In order to keep our code-behinds empty and code nicely structured, we'll use ASP.NET Data Binding together with ObjectDataSource controls. This involves us creating a separate class to contain our logic. To this class, we'll add a method that sets up four properties, one for each folder's CSS class setting.

C#
public object Display(string folder)
{
    folder = folder ?? "inbox";
    return new
    {
        InboxCss = folder == "inbox" ? "selected" : string.Empty,
        ArchiveCss = folder == "archive" ? "selected" : string.Empty,
        SentCss = folder == "sent" ? "selected" : string.Empty,
        SpamCss = folder == "spam" ? "selected" : string.Empty,
    };
}

We now need to wrap our folder list UI inside a FormView and connect this to an ObjectDataSource hooked up to the Display method we just created.

ASP.NET
<asp:FormView ID="Display" runat="server" DataSourceID="DisplaySource" RenderOuterTable="false">
    <ItemTemplate>
        <ul class="folders">
            ...
        </ul>
    </ItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="DisplaySource" runat="server" 
          TypeName="Mail.Controllers.MailController" SelectMethod="Display">
</asp:ObjectDataSource> 

Because the Display method has the folder as a parameter, we need to add a SelectParameter to the ObjectDataSource. We'll use a NavigationDataParameter for this with a Name that matches the key used in the ToData of our NavigationHyperLinks.

ASP.NET
<asp:ObjectDataSource ID="DisplaySource" runat="server" 
               TypeName="Mail.Controllers.MailController" SelectMethod="Display">
    <SelectParameters>
        <nav:NavigationDataParameter Name="folder" />
    </SelectParameters>
</asp:ObjectDataSource> 

Now we just data bind the CssClass property of our NavigationHyperLinks. So, by clicking on a folder, its CSS class name is set to selected and so, with the right CSS, it will be highlighted.

ASP.NET
<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox" 
   ToData="<%$ NavigationData: folder=inbox %>" 
   Direction="Refresh" CssClass='<%# Eval("InboxCss") %>' /> 

Displaying a grid of mails

Now that we can select a folder, let's show the mails contained in that folder. As before, we'll use data binding, so first we'll create our class method to bind to. Assuming we already have a repository set up, the method uses a LINQ query to filter by the selected folder.

C#
public IEnumerable<MailInfo> List(string folder)
{
    return _Repository.MailInfos.Where(m => m.Folder == folder).ToList();
}

We'll create a List.ascx user control containing a ListView connected to an ObjectDataSource hooked up to the List method we just created.

ASP.NET
<asp:ListView ID="MailList" runat="server" DataSourceID="MailListSource">
    <LayoutTemplate>
        <table class="mails">    
          <thead><tr><th>From</th><th>To</th>
                   <th>Subject</th><th>Date</th></tr></thead>
            <tbody><tr runat="server" id="itemPlaceholder" /></tbody>
        </table>
    </LayoutTemplate>
    <ItemTemplate>
        <tr>
            <td><%# HttpUtility.HtmlEncode(Eval("From"))%></td>
            <td><%# HttpUtility.HtmlEncode(Eval("To"))%></td>
            <td><%# HttpUtility.HtmlEncode(Eval("Subject"))%></td>
            <td><%# HttpUtility.HtmlEncode(Eval("Date", "{0:MMM d, yyyy}"))%></td>
        </tr>
    </ItemTemplate>
</asp:ListView>
<asp:ObjectDataSource ID="MailListSource" runat="server" 
            TypeName="Mail.Controllers.MailController" SelectMethod="List">
    <SelectParameters>
        <nav:NavigationDataParameter Name="folder" />
    </SelectParameters>
</asp:ObjectDataSource>

Now we'll drag our List.ascx onto our ASPX page so that it appears below our folder list.

ASP.NET
<ItemTemplate>
    <ul class="folders">
        ...
    </ul>
    <mail:List ID="List" runat="server"/>
</ItemTemplate>

If we run our application, wel won't see any mails when it first loads, although subsequent folder selections do in fact show the grid of mails correctly.

This is because when it first loads, the folder has not been set in NavigationData yet and so is passed as blank to our List method and no mails are returned. We can fix this by setting a default value in our Display method (since this method runs before our List method). The first line of the Display method already initialises the folder if it is blank, so we'll just modify this line to set this value in NavigationData via the dynamic Bag property.

C#
StateContext.Bag.folder = folder = folder ?? "inbox";

Viewing individual emails

Now that we can show mails in a selected folder, let's open a mail for reading. Our approach is identical to that taken for displaying a grid of mails above. So first we'll have our class method, this time taking in the ID of the selected mail.

C#
public MailInfo Details(int id)
{
    if (id == 0) return null;
    return _Repository.MailInfos.Single(m => m.Id == id);
}

Next, we'll create a Details.ascx user control, this time the NavigationDataParameter points to the ID of the selected mail.

ASP.NET
<asp:FormView ID="MailDetails" runat="server" DataSourceID="MailDetailsSource" RenderOuterTable="false">
    <ItemTemplate>
        <h1><%# HttpUtility.HtmlEncode(Eval("Subject"))%></h1>
        <p><%# HttpUtility.HtmlEncode(Eval("From"))%></p>
        <p><%# HttpUtility.HtmlEncode(Eval("To"))%></p>
        <p><%# HttpUtility.HtmlEncode(Eval("Date", "{0:MMM d, yyyy}"))%></p>
        <p><%# Eval("Message")%></p>
    </ItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="MailDetailsSource" runat="server" 
        TypeName="Mail.Controllers.MailController" SelectMethod="Details">
    <SelectParameters>
        <nav:NavigationDataParameter Name="id" />
    </SelectParameters>
</asp:ObjectDataSource

And we'll drag our Details.ascx onto our page below our List.ascx.

ASP.NET
<ItemTemplate>
    <ul class="folders">
        ...
    </ul>
    <mail:List ID="List" runat="server"/>
    <mail:Details ID="Details" runat="server"/>
</ItemTemplate>

The code is using the selected mail ID, however we've not actually set this anywhere yet. We'll take the same approach as for selecting folders, so in our List.ascx above, we'll replace the content of our tds with NavigationHyperLinks, with the ToData populated with the mail ID.

ASP.NET
<nav:NavigationHyperLink ID="DetailsLink" runat="server" 
  ToData='<%# new NavigationData(){{ "id", Eval("Id") }} %>' 
  Direction="Refresh" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' />

If we run our application, selecting a row in our mail grid correctly displays the email below the grid, however the folder highlighted is always the inbox no matter which mail the folder is in.

This is because links in our mail grid only pass the mail ID and not the folder, so the folder selected is lost and defaults back to showing the inbox as selected. We could fix this by changing the ToData property to include the folder, but there is a better way: by setting the IncludeCurrentData property to true, this will pass the current NavigationData along with that specified in the ToData and so the currently selected folder will be included.

ASP.NET
<nav:NavigationHyperLink ID="DetailsLink" runat="server" 
  ToData='<%# new NavigationData(){{ "id", Eval("Id") }} %>' 
  Direction="Refresh" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' IncludeCurrentData="true" /> 

Sorting out the user experience

Currently the mails grid and individual mails are displayed together. We need to hide the grid when viewing a mail and vice versa. To address this, we'll create another piece of NavigationData called layout and use this to control the visibility of the List and Details user controls.

We'll add this layout parameter to the Display method and use this to set up two properties to determine the visibility settings. We'll also make sure it defaults to show the list and that this default is set into NavigationData so it is available for other methods to use, as we did for the folder.

C#
public object Display(string layout, string folder)
{
    StateContext.Bag.layout = layout = layout ?? "list";
    StateContext.Bag.folder = folder = folder ?? "inbox";
    return new
    {
        ListVisible = layout == "list",
        DetailsVisible = layout == "details",
        InboxCss = folder == "inbox" ? "selected" : string.Empty,
        ArchiveCss = folder == "archive" ? "selected" : string.Empty,
        SentCss = folder == "sent" ? "selected" : string.Empty,
        SpamCss = folder == "spam" ? "selected" : string.Empty,
    };
}

We'll then add a layout NavigationDataParameter to our page's ObjectDataSource and bind the Visible property of our user control to the two properties we've created.

ASP.NET
<ItemTemplate>
    <ul class="folders">
        ...
    </ul>
    <mail:List ID="List" runat="server" Visible='<%# Eval("ListVisible") %>'/>
    <mail:Details ID="Details" runat="server" Visible='<%# Eval("DetailsVisible") %>'/>
</ItemTemplate>

If we run our application, our grid still works but selecting a mail doesn't as the individual mail is never visible. This is because the layout is currently always set to list and never changed to details. We'll fix this by changing the NavigationHyperLinks in our mail grid to pass a layout of details; and, to make sure it is reset when we select a folder, we'll change our folder NavigationHyperLinks to pass a layout of list.

ASP.NET
<nav:NavigationHyperLink ID="DetailsLink" runat="server" 
  ToData='<%# new NavigationData(){{ "id", Eval("Id") }, { "layout", "details" }} %>' 
  Direction="Refresh" IncludeCurrentData="true" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' />

<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox" 
  ToData="<%$ NavigationData: folder=inbox, layout=list %>" 
  Direction="Refresh" CssClass='<%# Eval("InboxCss") %>' /> 

Now that the layout is in NavigationData, we can use it to performance tune our code. By adding it to our List and Details methods (and therefore also as NavigationDataParameters to the corresponding ObjectDataSources), we can check it to determine if it is worth making the call to the repository.

C#
public IEnumerable<MailInfo> List(string layout, string folder)
{
    if (layout != "list") return null;
    return _Repository.MailInfos.Where(m => m.Folder == folder).ToList();
}

public MailInfo Details(string layout, int id)
{
    if (layout != "details") return null;
    return _Repository.MailInfos.Single(m => m.Id == id);
}

Preventing full page refreshes

So far we've only a very basic application with all folder and mail selection navigation done through normal hyperlinks. We're going to enhance this to use ASP.NET AJAX so that supporting clients can benefit from a richer user experience. The problem is ASP.NET AJAX works on the post back model and we currently don't ever post back. However, setting the PostBack property of all the NavigationHyperLinks causes them to work in post back mode rather than as normal hyperlinks (if JavaScript is off, they will continue to function as hyperlinks).

ASP.NET
<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox" 
  ToData="<%$ NavigationData: folder=inbox, layout=list %>" 
  Direction="Refresh" CssClass='<%# Eval("InboxCss") %>' PostBack="true" />

<nav:NavigationHyperLink ID="DetailsLink" runat="server" 
  ToData='<%# new NavigationData(){{ "id", Eval("Id") }, { "layout", "details" }} %>' 
  Direction="Refresh" IncludeCurrentData="true" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' PostBack="true" /> 

If we run our application, we'll see that clicking on folders and mails no longer causes the URL to change as we're always posting back. Now that we've changed our links to post back, we can easily change these into partial page requests by adding a ScriptManager to our page and wrapping its FormView in an UpdatePanel.

ASP.NET
<asp:ScriptManager ID="ScriptManager" runat="server"/>
<asp:UpdatePanel ID="Content" runat="server" UpdateMode="Conditional">
    <ContentTemplate>
        <asp:FormView ID="Display" runat="server" 
                     DataSourceID="DisplaySource" RenderOuterTable="false">
            <ItemTemplate>
                ...
            </ItemTemplate>
        </asp:FormView>
        <asp:ObjectDataSource ID="DisplaySource" runat="server" 
                    TypeName="Mail.Controllers.MailController" SelectMethod="Display">
            <SelectParameters>
                <nav:NavigationDataParameter Name="layout" />
                <nav:NavigationDataParameter Name="folder" />
            </SelectParameters>
        </asp:ObjectDataSource>
     </ContentTemplate>
</asp:UpdatePanel>

If we run our application, we'll see all requests are now AJAX requests if JavaScript is enabled, but that it reverts back to normal hyperlink navigation if JavaScript is disabled. However, one side effect of using AJAX is that the back button no longer works, since pages are only automatically added to history when full page refreshes are performed. Another side effect is that locations are no longer bookmarkable, as the information containing which folder and/or mail we're viewing isn't present in the URL any more and so we're always defaulted back to the inbox. We'll address these two issues in the next section.

Supporting back/forward and making locations bookmarkable

In the previous section, we added AJAX functionality but at the expense of browser history and bookmarkable locations. This could be considered a worse user experience than with JavaScript turned off! Luckily we can use ASP.NET AJAX History to bring back both these features. Firstly, we'll enable ASP.NET AJAX History via our ScriptManager control.

ASP.NET
<asp:ScriptManager ID="ScriptManager" runat="server" EnableHistory="true" EnableSecureHistoryState="false"/>

Next we need to add a history point every time either the folder or layout changes. So, we'll add a DataBound listener to our page's FormView as this will be called automatically whenever any of this data changes. We'll call the AddHistoryPoint method in the Navigation framework as this allows us to pass the current NavigationData, i.e., the currently selected folder and mail ID.

C#
protected void Display_DataBound(object sender, EventArgs e)
{
    if (ScriptManager.GetCurrent(this).IsInAsyncPostBack && 
               !ScriptManager.GetCurrent(this).IsNavigating)
    {
        StateController.AddHistoryPoint(this, new NavigationData(true), null);
    }
}

If we run our web application, we'll see that every time an AJAX request is performed, the URL is appended with a hash containing the currently selected folder and mail ID and that an item is added to the browser history. However, neither bookmarking nor the back button works; the former still always defaults back to the inbox and the latter doesn't change the display at all.

Let's add a listener to the ScriptManager's Navigate event and perform a history navigation. This restores the state information contained in the hashed part of the URL into NavigationData, and subsequently all our data bound controls refresh themselves using this new data.

C#
protected void ScriptManager_Navigate(object sender, HistoryEventArgs e)
{
    if (ScriptManager.IsInAsyncPostBack)
    {
        StateController.NavigateHistory(e.State);
    }
}

If we run our web application, we'll see that bookmarking now works. Strangely though, clicking the back button still seems to have no effect. This is because we've not told ASP.NET AJAX that the UpdatePanel's content has been updated. We'll manually update the UpdatePanel every time our page's FormView is data bound.

C#
protected void Display_DataBound(object sender, EventArgs e)
{
    Content.Update();
    ...
}

Conclusion

We've built a progressively enhanced version of the Knockout webmail sample for ASP.NET Web Forms using the Navigation framework. We've not written a single line of JavaScript, it is 100% server side code. We've adhered to DRY principles, e.g., we haven't duplicated code to cater for JavaScript on/off scenarios. The number of lines of code is about the same as in the original Knockout sample and yet ours is fully accessible and SEO friendly.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

GrahamMendick
United Kingdom United Kingdom
No Biography provided

Comments and Discussions

 
GeneralMy vote of 5 Pin
User 482203320-Dec-11 10:16
MemberUser 482203320-Dec-11 10:16 
Nice article, explaining how to address many inconveniences of ASP.NET. The title is a bit misleading (Knockout.js is used very little). Could you tell us also how we could combine NavigationHyperlinks with ASP.NET 4 Routes?

modified 6-Apr-21 21:01pm.

GeneralRe: My vote of 5 Pin
GrahamMendick20-Dec-11 10:34
MemberGrahamMendick20-Dec-11 10:34 
Questionthanks Pin
AbdullaMohammad20-Dec-11 2:07
MemberAbdullaMohammad20-Dec-11 2:07 
AnswerRe: thanks Pin
GrahamMendick20-Dec-11 2:15
MemberGrahamMendick20-Dec-11 2:15 
QuestionThe article could benefit from sample screendumps Pin
Jakob Farian Krarup20-Dec-11 0:33
MemberJakob Farian Krarup20-Dec-11 0:33 
AnswerRe: The article could benefit from sample screendumps Pin
GrahamMendick20-Dec-11 1:34
MemberGrahamMendick20-Dec-11 1:34 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.