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:
- 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.
- 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.
<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
.
<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.
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: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 NavigationHyperLink
s.
<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 NavigationHyperLink
s. So, by clicking on a folder, its CSS class name is set to selected and so, with the right CSS,
it will be highlighted.
<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.
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: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.
<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.
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.
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: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.
<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 td
s with NavigationHyperLink
s, with the ToData
populated with the mail ID.
<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.
<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.
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.
<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 NavigationHyperLink
s 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 NavigationHyperLink
s to pass a layout of list.
<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 NavigationDataParameter
s to the corresponding ObjectDataSource
s), we can check it to determine if it is worth making the call to the repository.
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 NavigationHyperLink
s causes
them to work in post back mode rather than as normal hyperlinks (if JavaScript is off, they will continue to function as hyperlinks).
<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: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: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.
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.
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.
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.