As a quick review the original behavior works like this (you can do this anywhere in Page code typically off of OnLoad):
This dynamically creates a global client side class that's accessible via script code rendered into startup script code:
The key aspect here is that class manages proper formatting for JavaScript values. Strings are properly encoded (somehting the <%= this.MyString %> does not easily provide) and non-string values are turned into proper .NET types. You can even pass complex objects this way if you choose since a full JSON serializer is used to serialize the property values.
You can specify the name of the object, so it's possible for creating multiple objects which effectively allows scoping of the generated data, which can be useful if you're building a control and you need more than one instance. If you want to read more on how the basics work you can look at the original post.
At the end of the last post I mused that there were a few more interesting things that could be done and a few suggestions and improvements were - as usual - suggested in the comments. I took a little time last night to add a few enhancements to the class that includes some of the ideas I'd been kicking around.
One of the most annoying things to deal with in client side ASP.NET applications is working with a Master Page and then having to figure out the proper client id for a control. Usually you'll end up with a funky name like Ctl00_Content_txtName or something longer if more naming containers are involved.
I offered one solution to this problem with just the base behavior above which was to simply add any control names you care about to the above script variables bag and expose it as part of the object. On the server you'd simply do:
and that works fine, but gets tedious if you have a large number of controls you need to access. As you add controls you'd need to add more keys which gets messy quickly.
So there are now two new methods that allow adding client IDs by container and its immediate children or by container and recursive children. You can do the following:
and add all ClientIDs of all controls to the above object. The properties names add an 'Id' postfix to minimize naming conflicts with other properties in the control:
This includes recursive digging into containers so this can produce a ton of controls and you'd definitely want to be careful with this. You can also call this non-recursively by leaving out the second parameter (or passing false) which only embeds client IDs for that particular Container level.
This feature is what I call a 'good enough' feature - it's not going to solve all naming problems since naming containers in ASP.NET exist for an obvious reason that containers can have multiple controls with the same name, so it's possible to end up with controls that have the same name and these controls will cause property naming conflicts. If you have duplicate names last one wins and the value is simply overwritten so only the last one found is honored.
I think it's still tremendously useful. Most of the application where I use AJAX controls that get accessed tend to at a very specific level in the page hierarchy. If there are potential naming conflicts those can be handled manually if necessary by explicitly adding the duplicate named controls as plain values with .Add().
But it might also be to create an instance on an existing object or to create an object hierarchy of which the serverVars object becomes a part of. For example, you can set up namespacing (even if you don't use MS AJAX).There are now AddScriptBefore() and AddScriptAfter() methods that let you add script before and after the rendered object.
This effectively adds the 'variable' servervars onto MyNameSpace.Custom rather than creating a new var variable.
The after script here is frivolous, but it demonstrates how alias the variable in multiple ways.
What I ended up with a is an UpdateMode property that takes values of None, Properties, Items, All. There are also two methods Get<TType> and GetItem<TType> that allows retrieving property bag items that have been deserialized from the server.
This code adds several properties to be rendered in the client object and then also allows reading the values back on PostBack. Get<TType> (key) retrieves that key and casts it to the specified type. The type is required in order for the JSON deserialization to work.
The Items collection is generated on the client as and _Items property and an .add() method on the client object. The generated code looks like this:
This code basically adds a generic handler for submitting the form variables and it hooks the OnSubmit handler of the page calling it and serializing the object data into POST variables. The process is pretty ugly - it encodes each key value pair as UrlEncoded values with the value JSON encoded. On the server the code then reads the whole string extracts the key value and deserializes the JSON into a proper typed value.
and when the page posts back you can then access the values that were set on the client maintaining the typing along the way.
Note that you can only use the Get functions in a Postback or else the values won't be set and throw.
The Update related stuff adds an additional dependency to this class on the wwScriptLibrary.js client library which is part of the West Wind Ajax Toolkit which provides the event hook up code as well as the JSON encoding on the client and the JSON serialization features on the server.
using System;
using System.Collections.Generic;
using System.Text;
using System.Web.UI;
using System.Web;
using System.Reflection;
using Westwind.Tools;
namespace Westwind.Web.Controls
{
/// <summary>
/// Provides an easy way for server code to publish strings into client script code.
/// This object basically provides a mechanism for adding key value pairs and embedding
/// those values into an object that is hosted on the client.
/// </summary>
public class wwScriptVariables
{
/// <summary>
/// Internally holds all script variables declared
/// </summary>
Dictionary<string, object> ScriptVariables = new Dictionary<string, object>();
/// <summary>
/// Internally tracked reference to the Page object
/// </summary>
Page Page = null;
/// <summary>
/// The name of the object generated in client script code
/// </summary>
public string ClientObjectName
{
get { return _ClientObjectName; }
set { _ClientObjectName = value; }
}
private string _ClientObjectName = "serverVars";
/// <summary>
/// Determines whether the output object script is rendered
/// automatically as part of Page PreRenderComplete. If false
/// you can manually call the RenderClientScript() method to
/// retrieve the script and embed it yourself.
/// </summary>
public bool AutoRenderClientScript
{
get { return _AutoRenderClientScript; }
set { _AutoRenderClientScript = value; }
}
private bool _AutoRenderClientScript = true;
/// <summary>
/// Determines how updates to the server from the client are performed.
/// If enabled changes to the client side properties post back to the
/// server on a full Postback.
///
/// Options allow for none, updating the properties only or updating
/// only the Items collection (use .add() on the client to add new items)
/// </summary>
public AllowUpdateTypes UpdateMode
{
get { return _UpdateMode; }
set { _UpdateMode = value; }
}
private AllowUpdateTypes _UpdateMode = AllowUpdateTypes.None;
/// <summary>
/// Internal string of the postback value for the field values
/// if AllowUpdates is true
/// </summary>
private string PostBackValue
{
get
{
if (this._PostBackValue == null)
this._PostBackValue = this.Page.Request.Form["__" + this.ClientObjectName];
return this._PostBackValue;
}
}
private string _PostBackValue = null;
/// <summary>
/// Internal instance of the Json Serializer used to serialize
/// the object and deserialize the updateable fields
/// </summary>
private JSONSerializer JsonSerializer = new JSONSerializer();
/// <summary>
/// Internally tracked prefix code
/// </summary>
private StringBuilder sbPrefixScriptCode = new StringBuilder();
private StringBuilder sbPostFixScriptCode = new StringBuilder();
/// <summary>
/// Internal counter for submit script embedded
/// </summary>
private int SubmitCounter = 0;
/// <summary>
/// Full constructor that receives an instance of any control object
/// and the client name of the generated script object that contains
/// the specified properties.
/// </summary>
/// <param name="control"></param>
/// <param name="clientObjectName"></param>
public wwScriptVariables(Control control, string clientObjectName)
{
if (control == null)
// Note: this will fail if called from Page Contstructor
// ie. wwScriptVariables scripVars = new wwScriptVariables();
this.Page = HttpContext.Current.Handler as Page;
else
this.Page = control.Page;
if (this.Page == null)
throw new ArgumentException("Could not retrieve a Page instance in wwScriptVariables.\r\n Either provide a Control or Page reference to the wwScriptVariables constructor or intialize the class in OnInit() of the page or later.");
if (!string.IsNullOrEmpty(clientObjectName))
this.ClientObjectName = clientObjectName;
// *** Force RenderClientScript to be called before the page renders
this.Page.PreRenderComplete += new EventHandler(Page_PreRenderComplete);
}
/// <summary>
/// This constructor only takes an instance of a Control. The name of the
/// client object will be serverVars.
/// </summary>
/// <param name="control"></param>
public wwScriptVariables(Control control)
: this(control, "serverVars")
{
}
/// <summary>
/// This constructor can only be called AFTER a page instance has been created.
/// This means OnInit() or later, but not in the constructor of the page.
///
/// The name of the client object will be serverVars.
/// </summary>
public wwScriptVariables()
: this(null, "serverVars")
{
}
private void Page_PreRenderComplete(object sender, EventArgs e)
{
this.RenderClientScript();
}
/// <summary>
/// Adds a property and value to the client side object
/// to be rendered into JavaScript code. VariableName
/// becomes a property on the object and the value will
/// be properly converted into JavaScript Compatible text.
/// </summary>
/// <param name="variableName"></param>
/// <param name="value"></param>
public void Add(string variableName, object value)
{
this.ScriptVariables.Add(variableName, value);
}
/// <summary>
/// Adds the dynamic value of a control or any object's property
/// that is picked up just before rendering.
///
/// This allows you to specify a given control or object's value to
/// added to the client object with the specified property value
/// set on the JavaScript object and have that value be picked
/// up just before rendering. This is useful so you can do all
/// client object declarations up front in one place regardless
/// of where the values are actually set.
///
/// Dynamic values are retrieved with Reflection so this method
/// is necessarily slower than direct assignment.
/// </summary>
/// <param name="variableName"></param>
/// <param name="control"></param>
/// <param name="property"></param>
public void AddDynamicValue(string variableName, object control, string property)
{
// *** Special key syntax: .varName.Property syntax to be picked up by parser
this.ScriptVariables.Add("." + variableName + "." + property, control);
}
/// <summary>
/// Adds all the client ids for a container as properties of the
/// client object. The name of the property is the ID + "Id"
/// Example: txtNameId
///
/// Note that there's no attempt made to resolve naming conflicts
/// in different naming containers. If there's a naming conflict
/// last one wins.
/// </summary>
/// <param name="control"></param>
public void AddClientIds(Control container, bool recursive)
{
foreach (Control control in container.Controls)
{
string id = control.ID + "Id";
if (!string.IsNullOrEmpty(id) && !this.ScriptVariables.ContainsKey(id + "Id"))
this.ScriptVariables.Add(id + "Id", id);
else
this.ScriptVariables[id + "Id"] = id;
// *** Drill into the hierarchy
if (recursive)
this.AddClientIds(control, true);
}
}
/// <summary>
/// Adds all the client ids for a container as properties of the
/// client object. The name of the property is the ID + "Id"
/// Example: txtNameId
/// This version only retrieves ids for the specified container
/// level - no hierarchical recursion of controls is performed.
/// </summary>
/// <param name="container"></param>
public void AddClientIds(Control container)
{
this.AddClientIds(container, false);
}
/// <summary>
/// Any custom JavaScript code that is to immediately preceed the
/// client object declaration. This allows setting up of namespaces
/// if necesary for scoping.
/// </summary>
/// <param name="scriptCode"></param>
public void AddScriptBefore(string scriptCode)
{
this.sbPrefixScriptCode.AppendLine(scriptCode);
}
/// <summary>
/// Any custom JavaScript code that is to immediately follow the
/// client object declaration. This allows setting up of namespaces
/// if necesary for scoping.
/// </summary>
/// <param name="scriptCode"></param>
public void AddScriptAfter(string scriptCode)
{
this.sbPostFixScriptCode.AppendLine(scriptCode);
}
/// <summary>
/// Returns a value that has been updated on the client
///
/// Note this method will throw if it is not called
/// during PostBack or if AllowUpdates is false.
/// </summary>
/// <typeparam name="TType"></typeparam>
/// <param name="key"></param>
/// <returns></returns>
public TType GetValue<TType>(string key)
{
if (this.UpdateMode == AllowUpdateTypes.None || this.UpdateMode == AllowUpdateTypes.ItemsOnly)
throw new InvalidOperationException("Can't get values if AllowUpdates is not set to true");
if (!this.Page.IsPostBack)
throw new InvalidOperationException("GetValue can only be called during postback");
// *** Get the postback value which is __ + ClientObjectName
string textValue = this.PostBackValue;
if (textValue == null)
return default(TType);
// *** Retrieve individual Url encoded value from the bufer
textValue = wwWebUtils.GetUrlEncodedKey(textValue, key);
if (textValue == null)
return default(TType);
// *** And deserialize as JSON
object value = this.JsonSerializer.Deserialize(textValue, typeof(TType));
return (TType) value;
}
/// <summary>
/// Returns a value from the client Items collection
/// </summary>
/// <typeparam name="TType"></typeparam>
/// <param name="key"></param>
/// <returns></returns>
public TType GetItemValue<TType>(string key)
{
if (this.UpdateMode == AllowUpdateTypes.None || this.UpdateMode == AllowUpdateTypes.PropertiesOnly)
throw new InvalidOperationException("Can't get values if AllowUpdates is not set to true");
if (!this.Page.IsPostBack)
throw new InvalidOperationException("GetValue can only be called during postback");
// *** Get the postback value which is __ + ClientObjectName
string textValue = this.PostBackValue;
if (textValue == null)
return default(TType);
// *** Retrieve individual Url encoded value from the bufer
textValue = wwWebUtils.GetUrlEncodedKey(textValue, "_Items");
if (textValue == null)
return default(TType);
textValue = wwWebUtils.GetUrlEncodedKey(textValue, key);
if (textValue == null)
return default(TType);
// *** And deserialize as JSON
object value = this.JsonSerializer.Deserialize(textValue, typeof(TType));
return (TType)value;
}
/// <summary>
/// Explicitly forces the client script to be rendered into the page.
/// This code is called automatically by the configured event handler that
/// is hooked to Page_PreRenderComplete
/// </summary>
private void RenderClientScript()
{
if (!this.AutoRenderClientScript || this.ScriptVariables.Count == 0)
return;
ClientScriptProxy scriptProxy = ClientScriptProxy.Current;
StringBuilder sb = new StringBuilder();
// *** Check for any prefix code and inject it
if (this.sbPrefixScriptCode.Length > 0)
sb.Append(sbPrefixScriptCode.ToString());
// *** If the name includes a . assignment is made to an existing
// *** object or namespaced reference - don't create var instance.
if (!this.ClientObjectName.Contains("."))
sb.Append("var ");
sb.AppendLine( this.ClientObjectName + " = {");
// *** We'll serialize single values into the client
JSONSerializer ser = new JSONSerializer();
ser.SerializeDateAsString = false; // use new Date() output
foreach(KeyValuePair<string,object> entry in this.ScriptVariables)
{
if (entry.Key.StartsWith("."))
{
// *** It's a dynamic key
string[] tokens = entry.Key.Split(new char[1] { '.' }, StringSplitOptions.RemoveEmptyEntries);
string varName = tokens[0];
string property = tokens[1];
object propertyValue = null;
if (entry.Value != null)
propertyValue = entry.Value.GetType().
GetProperty(property, BindingFlags.Instance | BindingFlags.Public).
GetValue(entry.Value, null);
sb.AppendLine("\t\"" + varName + "\": " + ser.Serialize(propertyValue) + ",");
}
else
sb.AppendLine("\t\"" + entry.Key + "\": " + ser.Serialize(entry.Value) + ",");
}
if (this.UpdateMode != AllowUpdateTypes.None)
{
sb.AppendLine("\t\"_Items\":{},");
sb.AppendLine("\t\"add\": function(key,value) { this._Items[key] = value; },");
}
// *** Strip off last comma plus CRLF
if (sb.Length > 0)
sb.Length -= 3;
sb.AppendLine("\r\n};");
if (this.UpdateMode != AllowUpdateTypes.None)
{
// *** Requires wwScritpLibrary
ControlResources.LoadwwScriptLibrary(this.Page);
scriptProxy.RegisterClientScriptBlock(this.Page, typeof(ControlResources), "submitServerVars", STR_SUBMITSCRIPT, true);
scriptProxy.RegisterHiddenField(this.Page, "__" + this.ClientObjectName, "");
string script = @"wwEvent.addEventListener(document.forms['{1}'],'submit',function() {{ __submitServerVars({0},'__{0}'); }},true);";
sb.Append(string.Format(script, this.ClientObjectName, this.Page.Form.ClientID,this.SubmitCounter++));
}
if (this.sbPostFixScriptCode.Length > 0)
sb.AppendLine(this.sbPostFixScriptCode.ToString());
if (this.Page == null)
this.Page = HttpContext.Current.Handler as Page;
// *** Use ClientScriptProxy from West Wind Ajax Toolkit to be MS Ajax compatible - otherwise use ClientScript
scriptProxy.RegisterClientScriptBlock(this.Page, typeof(ControlResources), "_ClientScriptStrings", sb.ToString(), true);
//this.Page.ClientScript.RegisterClientScriptBlock(typeof(ControlResources), "_ClientScriptStrings", sb.ToString(), true);
}
const string STR_SUBMITSCRIPT =
@"
function __submitServerVars(inst,hiddenId)
{
var output = '';
for(var prop in inst)
{
if (prop == '_Items')
{
var out = '';
for(var p in inst._Items)
out += p + '=' + encodeURIComponent(JSON.serialize(inst._Items[p]) ) + '&';
output += '_Items=' + encodeURIComponent(out) + '&';
} else
output += prop + '=' + encodeURIComponent(JSON.serialize(inst[prop])) + '&';
}
$w(hiddenId).value = output;
};
";
}
public enum AllowUpdateTypes
{
None,
ItemsOnly,
PropertiesOnly,
All
}
}
One more thought crossed my mind as I'm writing this up - it probably would be nice if there was also a way to get the serialization to work over plain AJAX callbacks. Encoding values as POST vars is one of the easiest ways to pass data back and forth because it allows enumerating over values. Pure JSON works great if you have an existing object to parse the data into on the server, but failing that JSON for complex types is difficult to deal with. The above statebag approach along with the wwScriptVariable.Get<> and GetItem<> methods is a great way to pass generic data from client to server even in AJAX without losing type information... Hmmm... I guess there'll be one more refactoring at least <g>...