I received an odd specification request recently, something that actually goes against the statelessness of the web in general. You see, the users in question often have customer phone calls interrupt their work in the application, and as such they often have to put down what they are doing and return later. The current policy does not allow multiple instances of the web application to be running, which means they have to cancel whatever they are doing, and go help the customer, only to return and start from square one.
One of the things I love about ASP .NET, is that I have yet to encounter any sort of far fetched or odd request that I haven't been able to accomplish, so I put my mind to the task. There has to be a way to retrieve all the values currently on a form, since a postback does this activity for you, as well as mimic what a postback does to take the returned data. If I could get my hands on that data, I could potentially restore the entire form!
The answer lies in the Request.Form namevaluecollection. This collection happens to have all the control ids and the values that have returned from the post back. Armed with this knowledge, all I had to do was collect some additional information about the page and then give the user access to it.
What I decided on was to create a snapshot class as follows:
public class Snapshot
{
public Snapshot(string Url, string Text, NameValueCollection FormData)
{
_url = Url;
_text = Text;
_formData = FormData;
}
private string _url = "";
private string _text = "";
private NameValueCollection _formData;
public string Text
{
get { return _text; }
set { _text = value; }
}
public NameValueCollection FormData
{
get { return _formData; }
set { _formData = value; }
}
public string Url
{
get { return _url; }
set { _url = value; }
}
}
As you can see, I'm storing the Request.Form data, the url that the form came from, and a text key that will represent this snapshot to the user. For simplicity, we're going to simply stick this data in session and not allow multiple snapshots of the same page, so no unique key is needed at this time.
The next thing I wanted to do was create a user control that would use a repeater to list all the snapshots from a user's session which are being stored as a generic list. Being a css/xhtml convert, I like the repeater since I can specify specifically the html format and thus create a nice styleable linked list for this control:
<div class="panelItem">
<asp:Label ID="lblEmptyMessage"
runat="server"
Text="You have no snapshots."
CssClass="smallText" /><br />
<asp:Repeater ID="rprTaskList" runat="server">
<HeaderTemplate>
<ul id="ulTaskList" class="LinkList">
</HeaderTemplate>
<ItemTemplate>
<li>
<a href='<%# PrepareTaskURL(DataBinder.Eval(Container.DataItem, "Url"),
DataBinder.Eval(Container.DataItem, "Text")) %>' class="ListLink">
<%# DataBinder.Eval(Container.DataItem, "Text") %>
</a>
</li>
</ItemTemplate>
<FooterTemplate>
</ul>
</FooterTemplate>
</asp:Repeater>
<br />
<asp:LinkButton ID="btnPersist"
runat="server"
Text="Take Snapshot"
Visible="false"
OnClick="btnPersist_Click" />
</div>
This control is meant to go in my masterpage in the user tools panelbar area (Thank you Telerik). You will notice that I have a button to persist a form as well as some back end code to parse snapshots and display url's properly. I'm going to pass a querystring value with the task name in the PrepareTaskURL method call. In addition, we need to make this user control intelligent enough to only show the persist form button if it detects a page that is able to be persisted. To address this, we will create a PersistedPage base class inheriting from my standard BasePage that all my pages use which contains some useful application wide page functions.:
protected void Page_Load(object sender, EventArgs e)
{
BindTasks(((BasePage)Page).RetrieveSnapshots());
if (Page is PersistedPage)
btnPersist.Visible = true;
}
public void BindTasks(List<Snapshot> Tasks)
{
rprTaskList.DataSource = Tasks;
rprTaskList.DataBind();
if(Tasks.Count > 0)
lblEmptyMessage.Visible = false;
}
protected string PrepareTaskURL(object url, object snapshotName)
{
if (url.ToString().Contains("?"))
return url + "&SnapshotName=" + snapshotName;
return url + "?SnapshotName=" + snapshotName;
}
protected void btnPersist_Click(object sender, EventArgs e)
{
if (Page is PersistedPage)
{
((PersistedPage)Page).PersistForm();
}
}
}
Notice the code that detects whether the current page is a PersistedPage to ensure that only these pages are able to be snapshotted. Also note the code for retrieve snapshots has been omitted. All it does is check the session for a generic list of snapshots.
Now onto the fun part, the PersistedPage class. Recall above, we made the user control smart enough to realize that when it is on a PersistedPage, it can enable the snapshot process which calls the PersistForm() method. If a persisted page finds the text of a snapshot (which is set by the abstract getformname method) in the querystring it attempts to look it up in the generic list and pull the snapshot record. It then parses the namevalue collection of form data and repopulates the form as it was when the snapshot was taken!
public abstract class PersistedPage : BasePage
{
/// <summary>
/// Page persistance module for task handling
/// </summary>
public void PersistForm()
{
List<Snapshot> snapshots = RetrieveSnapshots();
snapshots.Add(new Snapshot(Request.Url.PathAndQuery, GetFormName(), Request.Form));
Session["TaskList"] = snapshots;
}
private Snapshot _currentSnapshot;
/// <summary>
/// Restore form values from a snapshot
/// </summary>
/// <param name="snapshot"></param>
public void LoadFormFromSnapshot(Snapshot snapshot)
{
_currentSnapshot = snapshot;
ArrayList modifiedControls = new ArrayList();
LoadPostData(this, modifiedControls);
// Raise PostDataChanged event on all modified controls:
foreach (IPostBackDataHandler control in modifiedControls)
control.RaisePostDataChangedEvent();
}
/// <summary>
/// This method performs depth-first recursion on
/// all controls contained in the specified control,
/// calling the framework's LoadPostData on each and
/// adding those modified to the modifiedControls list.
/// </summary>
private void LoadPostData(Control control, ArrayList modifiedControls)
{
// Perform recursion of child controls:
foreach (Control childControl in control.Controls)
LoadPostData(childControl, modifiedControls);
// Load the post data for this control:
if (control is IPostBackDataHandler)
{
// Get the value of the control's name attribute,
// which is the GroupName of radio buttons,
// or the same as the UniqueID
// attribute for all other controls:
string nameAttribute = (control is RadioButton) ?
((RadioButton)control).GroupName : control.UniqueID;
if (control is CheckBoxList)
{
// CheckBoxLists also require special handling:
int i = 0;
foreach (ListItem listItem in ((ListControl)control).Items)
if (_currentSnapshot.FormData[nameAttribute + ':' + (i++)] != null)
{
listItem.Selected = true;
modifiedControls.Add(control);
}
}
else
{
// Don't process this control if its key
// isn't in the PostData, as the
// LoadPostData implementation of some controls
// throws an exception in this case.
if (_currentSnapshot.FormData[nameAttribute] == null) return;
// Call the framework's LoadPostData on this control
// using the name attribute as the post data key:
if (((IPostBackDataHandler)control).LoadPostData(
nameAttribute, _currentSnapshot.FormData))
modifiedControls.Add(control);
}
}
}
protected void Page_Load(object sender, EventArgs e)
{
if (!Page.IsPostBack)
{
string snapshotName = Request.QueryString["SnapshotName"];
if (snapshotName != null)
{
List<Snapshot> snapshots = RetrieveSnapshots();
foreach (Snapshot snapshot in snapshots)
{
if (snapshot.Text == snapshotName)
{
LoadFormFromSnapshot(snapshot);
snapshots.Remove(snapshot);
break;
}
}
}
}
}
public abstract string GetFormName();
So that is how it all works. Please note the following however:
The web is supposed to be stateless, but what the customer wants, the customer gets.
For this to work, you must set EnableEventValidation="false" in the page. This means it is less secure, and as such I do not recommend this for anything but internal applications unless you are very very cautious.