I’m currently between assignments and in the meantime I’m helping fellow colleagues with a Web Forms system. They needed a Combobox for a requirement and after some googling I found out that there is none for free which suits their needs. I have created a Knockout enabled combo called Knockout.Combobox. I decided to take this client side combobox and wrap it in a Web Forms Control.
There are a few ways of creating a custom control in Web Forms, one is to inherit an existing control and extend it. Or like in this case inherit from CompositeControl. The shell for the Control looks like this.
public class Combobox : CompositeControl, IScriptControl, ICallbackEventHandler { }
IScriptControl enables us to inject client side script files on the page, this is a powerful way of letting the control handle its own script dependencies.
ICallbackEventHandler enables us to receive ajax requests.
CompositeControl
First we need to add the child controls that are needed.
public Combobox() { content = new HtmlGenericControl("div"); selectedField = new HiddenField(); } protected override void CreateChildControls() { content.Attributes["class"] = "combobox"; content.Attributes["data-bind"] = "combobox: options, comboboxValue: selected"; content.ID = "content"; selectedField.ID = "selected"; Controls.Add(content); Controls.Add(selectedField); }
We add a div that will be the content control for the client side enabled content. It needs the data-bind attribute so that Knockout can bind to it correctly client side.
We also add a HiddenField control that will hold the selected item data so that it can be postbacked to the server.
IScriptControl
Next we need to register client side scripts. The IScriptControl interface forces us to implement two methods.
IEnumerable<ScriptReference> IScriptControl.GetScriptReferences() { var assembly = "WebForms.Combobox.Controls"; return new[] { new ScriptReference("WebForms.Combobox.Controls.Scripts.knockout.combobox-1.0.71.0.min.js", assembly), new ScriptReference("WebForms.Combobox.Controls.JS.combobox.js", assembly) }; } IEnumerable<ScriptDescriptor> IScriptControl.GetScriptDescriptors() { return new ScriptDescriptor[] { new ScriptControlDescriptor("WebForms.Combobox.Controls.Combobox", ClientID) }; }
GetScriptReferences returns a list of script references, they can either point to a URL or to a embedded resource. The later is preferred if you aim to deliver a self sustained Web Control. For that to work you need to mark your script files as Embedded resource and also add them under assembly info like
[assembly: WebResource("WebForms.Combobox.Controls.Scripts.knockout.combobox-1.0.71.0.min.js", "text/javascript")] [assembly: WebResource("WebForms.Combobox.Controls.JS.combobox.js", "text/javascript")]
Now the script references will be rendered to the page whenever a Combobox control is present on the page.
OnPreRender
We also need to render a one time startup client side script that fires the first time a control is added to the page. This script is used to hook up the server side rendered controls with the client side code. The Web Forms ScriptManagers requires this to be done from the OnPreRender method like.
protected override void OnPreRender(EventArgs e) { InitScripts(); AddCss(); base.OnPreRender(e); } private void InitScripts() { ScriptManager.GetCurrent(Page).RegisterScriptControl(this); ScriptManager.RegisterStartupScript(this, typeof(Combobox), "init" + UniqueID, string.Format( @"$(document).ready(function() {{ new ComboboxWrapper('{0}', '{1}', '{2}'); }});", UniqueID, content.ClientID, selectedField.ClientID), true); Page.ClientScript.GetCallbackEventReference( this, "", "this.callBack", null, null, false); }
First we register our control with the ScriptManager using the RegisterScriptControl method (This requires the control to implement IScriptControl. Then we add our init script using RegisterStartupScript method. We call our client side javascript class called ComboboxWrapper and pass along all the server side dependencies needed like UniqueID for postback and client side ID’s of the elements. We also register our control so that it can receive Ajax Callbacks using GetCallbackEventReference.
CSS
We need to inject our CSS since we aim at a self sustained control. First we mark the CSS file as embedded resource and add it to the assembly info like.
[assembly: WebResource("WebForms.Combobox.Controls.Content.knockout.combobox-1.0.71.0.min.css", "text/css")]
Then we inject the CSS from the OnPreRender method like.
private void AddCss() { var id = "comboboxCss"; if (Page.Header.FindControl(id) == null) { var cssMetaData = new HtmlGenericControl("link"); cssMetaData.ID = id; cssMetaData.Attributes.Add("rel", "stylesheet"); cssMetaData.Attributes.Add("href", Page.ClientScript.GetWebResourceUrl(typeof(Combobox), "WebForms.Combobox.Controls.Content.knockout.combobox-1.0.71.0.min.css")); cssMetaData.Attributes.Add("type", "text/css"); cssMetaData.Attributes.Add("media", "screen"); Page.Header.Controls.Add(cssMetaData); } }
We use the Page.ClientScript.GetWebResourceUrl method to retrieve the URL for the embedded resource and then render a link elment to the page if its not already been added by another combobox control (If you have multiple on the page). This method is not 100%, if the control is not added at page load but rather at a partial postback (UpdatePanel) the CSS wont be added. If you want this functionally you need to register the CSS with a script block.
ICallbackEventHandler
We need a way of requesting search results for the combobox with ajax callbacks. We use ICallbackEventHandler for this combined with GetCallbackEventReference covered above. The interface requires us to implement two methods.
public void RaiseCallbackEvent(string eventArgument) { var args = JsonConvert.DeserializeObject<ItemsRequestedEventArgs>(eventArgument); OnItemsRequested(this, args); result = args; } public string GetCallbackResult() { return JsonConvert.SerializeObject(new { total = result.Total, data = result.Result }); }
RaiseCallbackEvent is called when the client requests data, we deserialize the JSON data using JSON.NET and then fire the server side event that populates the result. GetCallbackResult is called just before the response is rendered to the client, here we just serialize the result.
Selected item
The selected item is stored in a hidden field, this way it will be postbacked to the server. We abstract the hidden field with a Selected property on the control
public Selected Selected { get { return selected = selected ?? GetSelected(); } set { if (value == null) selectedField.Value = null; else { selected = value; selectedField.Value = JsonConvert.SerializeObject(value); } } } private Selected GetSelected() { if (!string.IsNullOrEmpty(selectedField.Value)) { return JObject.Parse(selectedField.Value).ToObject<Selected>(); } return null; }
Client side wrapper
Last we need a client side wrapper that acts as a proxy between Web.Forms and my Knockout combo.
ComboboxWrapper = function (id, contentId, selectedId) { this.id = id; this.$selected = $("#" + selectedId); var selected = this.$selected.val(); this.selected = ko.observable(selected != "" ? $.parseJSON(selected) : null); this.selected.subscribe(this.onSelected, this); this.options = { valueMember: "Name", dataSource: this.getData.bind(this) }; ko.applyBindings(this, $("#" + contentId)[0]); }; ComboboxWrapper.prototype = { getData: function (options) { WebForm_DoCallback(this.id, ko.toJSON(options), function (data) { options.callback($.parseJSON(data)); }.bind(this), null, null, false); }, onSelected: function (value) { this.$selected.val(ko.toJSON(value)); } };
We first store the UniqueID that is later used to invoke the correct control when items are requested. We also store the hidden field that will be used to postback the selected item to the server. We create a selected observable and hook up a subscriber, onSelected, to it. Last we create a options object literal that is used to bind to the combobox. Last we apply knockout on the content div using ko.applyBindings.
The getData function is called whenever the combobox requests items. In this function we use the MS Ajax function called WebForm_DoCallback to raise a Web Forms ajax callback. We serialize the search parameters and send them with the callback as JSON. When the callback is performed we return the result to the combobox callback.
The onSelected function is called whenever the user selects a item in the dropdown, we serialize the item to JSON and store it in the hidden field.
Source code
Full source code can be found here