I created a custom control (called BoostrapDropDown) that essentially wraps a bunch of boostrap markup around a asp.net DropDownList. The resulting control hierarchy will look basically like the following with everything being a HtmlGenericControl except for the DropDownList:
<div class="form-group viInputID">
<label for="iInputID" class="control-label liInputID"></label>
<a style="display: none;" class="vhiInputID" role="button" tabindex="0" data-toggle="popover" data-trigger="click" data-content-selector=".hiInputID" data-placement="top">
<span class="glyphicon glyphicon-info-sign help-icon"></span>
</a>
<a style="display: none;" class="vsiInputID" role="button" tabindex="0">
<span class="glyphicon glyphicon-volume-up"></span>
</a>
<div class="validator-container">
<asp:DropDownList CssClass="form-control selectpicker show-tick iInputID" data-size="15" ID="iInputID" runat="server" DataSource='<%# DataSource %>' DataTextField="name" DataValueField="key"/>
<span class="error-msg" data-toggle="tooltip" data-placement="top"></span>
</div>
<div class="hiInputIDTitle" style="display: none;"></div>
<div class="hiInputID" style="display: none;"></div>
</div>
I was 'passing through' a DataSource property from my control to the nested DropDownList but upon postback, I was losing all my values.
Here's the embarrassing part. A month ago, I searched the web and was able to create a solution, but I didn't document it well. And now I can't find the page(s) I used to create the solution. I have no idea how it is working and I'm hoping someone can shed some light. Below is the relevant source code.
UPDATE: Full Code
// Preventing the EventValidation for dropdown lists b/c they could be populated *only* on the client side;
// https://stackoverflow.com/a/8581311/166231
public class DynamicDropDownList : DropDownList { }
public class DynamicListBox : ListBox { }
public class HtmlGenericControlWithCss : HtmlGenericControl
{
public HtmlGenericControlWithCss(string tag) : base(tag) { }
public HtmlGenericControlWithCss(string tag, string css) : this(tag)
{
Attributes["class"] = css;
}
public HtmlGenericControlWithCss(string tag, string css, string style) : this(tag, css)
{
Attributes["style"] = style;
}
}
public class HtmlAnchorWithCss : HtmlAnchor
{
public HtmlAnchorWithCss(string css) : base()
{
Attributes["class"] = css;
}
public HtmlAnchorWithCss(string css, string style) : this(css)
{
Attributes["style"] = style;
}
}
public abstract class BootstrapInputBase : WebControl, INamingContainer
{
protected HtmlGenericControl formGroup;
protected bool isBootstrap4;
public string HelpPlacement
{
get => (string)ViewState["HelpPlacement"] ?? "top";
set => ViewState["HelpPlacement"] = value;
}
public string Label
{
get => (string)ViewState[nameof(Label)];
set => ViewState[nameof(Label)] = value;
}
public string LabelCss
{
get => (string)ViewState[nameof(LabelCss)];
set => ViewState[nameof(LabelCss)] = value;
}
public string HelpContent
{
get => (string)ViewState[nameof(HelpContent)];
set => ViewState[nameof(HelpContent)] = value;
}
public override void RenderControl(HtmlTextWriter writer)
{
using (var sw = new StringWriter())
using (var hw = new HtmlTextWriter(sw))
{
base.RenderControl(hw);
// need formatted so browser renders it nice (otherwise wierd spacing issues if some of the whitespace is removed)
var html = XElement.Parse(sw.ToString());
writer.Write(html.ToString());
}
}
public void AddControl(Control control)
{
EnsureChildControls();
formGroup.Controls.Add(control);
}
protected override void CreateChildControls()
{
isBootstrap4 = true;
/*
<div class="form-group viInputID">
<label for="iInputID" class="control-label liInputID"></label>
<a style="display: none;" class="vhiInputID" role="button" tabindex="0" data-toggle="popover" data-trigger="click" data-content-selector=".hiInputID" data-placement="top">
<span class="glyphicon glyphicon-info-sign help-icon"></span>
</a>
<a style="display: none;" class="vsiInputID" role="button" tabindex="0">
<span class="glyphicon glyphicon-volume-up"></span>
</a>
<div class="validator-container"> [abstract] </div>
<div class="hiInputIDTitle" style="display: none;"></div>
<div class="hiInputID" style="display: none;"></div>
</div>
*/
formGroup = new HtmlGenericControlWithCss("div", "form-group v" + ID);
Controls.Add(formGroup);
formGroup.Controls.Add(CreateLabel());
var help = new HtmlAnchorWithCss("vh" + ID, string.IsNullOrEmpty(HelpContent) ? "display: none;" : null);
help.Attributes["role"] = "button";
help.Attributes["tabindex"] = "0";
help.Attributes["data-toggle"] = "popover";
help.Attributes["data-trigger"] = "click";
help.Attributes["data-content-selector"] = ".h" + ID;
help.Attributes["data-placement"] = HelpPlacement;
// Couldn't use server controls b/c it put <a><span .../></a> with no space, if newline before span, then HTML rendered a little break after the label
// help.InnerHtml = Environment.NewLine + "<span class='glyphicon glyphicon-info-sign help-icon'></span>";
formGroup.Controls.Add(help);
help.Controls.Add(new HtmlGenericControlWithCss("span", isBootstrap4 ? "fal fa-question-circle help-icon" : "glyphicon glyphicon-info-sign help-icon"));
var voice = new HtmlAnchorWithCss("vs" + ID, "display: none;");
voice.Attributes["role"] = "button";
voice.Attributes["tabindex"] = "0";
// Couldn't use server controls b/c it put <a><span .../></a> with no space, if newline before span, then HTML rendered a little break after the label
// voice.InnerHtml = Environment.NewLine + "<span class='glyphicon glyphicon-volume-up'></span>";
formGroup.Controls.Add(voice);
voice.Controls.Add(new HtmlGenericControlWithCss("span", isBootstrap4 ? "fal fa-volume-up" : "glyphicon glyphicon-volume-up"));
formGroup.Controls.Add(CreateValidatorContainer());
formGroup.Controls.Add(new HtmlGenericControlWithCss("div", "h" + ID, "display: none;") { InnerHtml = HelpContent });
formGroup.Controls.Add(new HtmlGenericControlWithCss("div", "h" + ID + "Title", "display: none;"));
}
protected abstract HtmlGenericControl CreateValidatorContainer();
public abstract string Value { get; set; }
protected virtual HtmlGenericControl CreateLabel()
{
var label = new HtmlGenericControlWithCss("label", "control-label l" + ID + (!string.IsNullOrEmpty(LabelCss) ? " " + LabelCss : "")) { InnerHtml = Label, EnableViewState = true };
label.Attributes["for"] = ID;
return label;
}
protected virtual HtmlGenericControl CreateErrorMessage()
{
var errorMessage = new HtmlGenericControlWithCss("span", "error-msg");
errorMessage.Attributes["data-toggle"] = "tooltip";
errorMessage.Attributes["data-placement"] = "top auto";
return errorMessage;
}
}
public class BootstrapDropDown : BootstrapInputBase
{
private ListControl inputControl;
// If this is false and the client wants to postback to the server for processing,
// I would need to try to grab values via Request.Form[ UniqueID + ":" + ID ].
// But the CalcEngine would *have* to validate the item is inside a known list and
// no malicious values were posted back to server.
public bool SupportEventValidation
{
get => (bool?)ViewState[nameof(SupportEventValidation)] ?? true;
set => ViewState[nameof(SupportEventValidation)] = value;
}
public bool AllowMultiSelect
{
get => (bool?)ViewState[nameof(AllowMultiSelect)] ?? false;
set => ViewState[nameof(AllowMultiSelect)] = value;
}
public string DataTextField
{
get => (string)ViewState[nameof(DataTextField)];
set => ViewState[nameof(DataTextField)] = value;
}
public string DataValueField
{
get => (string)ViewState[nameof(DataValueField)];
set => ViewState[nameof(DataValueField)] = value;
}
public object DataSource { get; set; }
ListItemCollection items;
public virtual ListItemCollection Items
{
get
{
if (items == null)
{
items = new ListItemCollection();
if (IsTrackingViewState)
{
((IStateManager)items).TrackViewState();
}
}
return items;
}
}
public ListControl ListControl
{
get
{
// Don't want this, would like to just use Items property
// to clear/add items but wasn't working and I still don't understand
// how my dropdown list is retaining view state. SO Question:
// https://stackoverflow.com/questions/56299350/saving-viewstate-in-nested-dropdownlist-in-a-custom-control
EnsureChildControls();
return inputControl;
}
}
protected override void LoadViewState(object savedState)
{
var allState = (object[])savedState;
HelpContent = (string)allState[4];
Label = (string)allState[3];
Value = (string)allState[2];
((IStateManager)Items).LoadViewState(allState[1]);
base.LoadViewState(allState[0]);
}
protected override object SaveViewState()
{
var allState = new object[5];
allState[0] = base.SaveViewState();
allState[1] = ((IStateManager)Items).SaveViewState();
allState[2] = Value;
allState[3] = Label;
allState[4] = HelpContent;
return allState;
}
public override string Value
{
get
{
EnsureChildControls();
return inputControl.SelectedValue;
}
set
{
EnsureChildControls();
inputControl.SelectedValue = value;
}
}
public string SelectedValue => Value;
public virtual string Text
{
get
{
EnsureChildControls();
return inputControl.SelectedItem?.Text;
}
}
protected override HtmlGenericControl CreateValidatorContainer()
{
/*
<div class="validator-container">
<asp:DropDownList CssClass="form-control selectpicker show-tick iInputID" data-size="15" ID="iInputID" runat="server" DataSource='<%# xDSHelper.GetDataTable( "TableTaxStatus" ) %>' DataTextField="name" DataValueField="key"/>
<span class="error-msg" data-toggle="tooltip" data-placement="top"></span>
</div>
*/
var validatorContainer = new HtmlGenericControlWithCss("div", "validator-container");
inputControl = SupportEventValidation
? AllowMultiSelect
? new ListBox() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource, SelectionMode = ListSelectionMode.Multiple } as ListControl
: new DropDownList() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource } as ListControl
: AllowMultiSelect
? new DynamicListBox() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource, SelectionMode = ListSelectionMode.Multiple } as ListControl
: new DynamicDropDownList() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource } as ListControl;
inputControl.Attributes["data-size"] = "15";
if (AllowMultiSelect)
{
inputControl.Attributes["data-selected-text-format"] = "count > 2";
}
else
{
inputControl.Attributes["data-live-search"] = "true";
}
validatorContainer.Controls.Add(inputControl);
if (DataSource != null)
{
inputControl.DataBind();
Items.AddRange(inputControl.Items.Cast<ListItem>().ToArray());
}
validatorContainer.Controls.Add(CreateErrorMessage());
return validatorContainer;
}
}
And the control is used in markup via the following:
<mh:BootstrapDropDown runat="server" ID="iGroup" Label="Select Group Name" EnableViewState="true" DataSource='<%# Groups %>' DataTextField="Text" DataValueField="Value" />
Then in code behind, have the following:
protected System.Collections.ArrayList Groups
{
get
{
var al = new System.Collections.ArrayList();
al.Add(new ListItem("[Select a Group]", ""));
al.Add(new ListItem("Group A", "A"));
al.Add(new ListItem("Group B", "B"));
return al;
}
}
So here is my confusion...
- During
CreateChildControls,DataSourceis only going to be there on the original rendering. So I callDataBindon the nested DropDownList to get it to populate the first time, and then I store all the controls Items back to anItemsproperty. - I am pretty sure I understand how
Itemsis persisted to/loaded from ViewState. - Where I am lost, is how is my
Itemsproperty then getting used to re-populate the DropDownList? I was thinking it was possibly the fact that I addedLoad\SaveViewState(which called thebase.Load\SaveViewState) was what really fixed my issue, but when I commented out all references to myItemsproperty, I was losing the drop down list values again.
How in the world is Items repopulating inputControl.Items on postback?!
I understand that the ultimate question is:
Nevertheless, I believe it's a question that doesn't need to (or shouldn't) be answered for two reasons:
Your initial requirements statement:
The fact that your code (and I am referring to the original version of your code which is good and long enough for our discussion) incorporates many techniques that have to do with persisting custom control properties of complex type in the ViewState (
LoadViewState,SaveViewState,Triplet,IStateManageretc) but most of which are not needed in your case because (and at this point your requirements statement becomes of paramount importance):BootstrapDropDownis just a composite custom control that embed aDropDownListand can (and should) delegate all work to it!In fact, you've nicely done that for the
TextandValueproperties. Why not do it for theItemsproperty, too? Your control works by composition. It does not need to maintain aListItemCollectionof its own let alone passing it in ViewState.Last but not least, it is very important to remember that embedded server controls will automatically manage their own ViewState. In other words, there's nothing you need to do to manually manage the ViewState of
inputControl.Having said that, here's a sample based on your (original) code that works without black magic:
ASPX:
Code behind:
There's one last thing worth mentioning. Pay attention to the
inputControlbeing data bound after it's added to theControlscollection. That's important since adding a control to the collection also is the point where the control starts tracking its ViewState. You can read more (or all) about it in this excellent article:https://weblogs.asp.net/infinitiesloop/Truly-Understanding-Viewstate
Also, I found a reference to the mechanism of
IStateManagerin this article by Dino Esposito:https://www.itprotoday.com/web-application-management/inside-aspnet-control-properties