Search code examples
c#asp.netcustom-controlsviewstatecomposite-controls

Saving ViewState in Nested DropDownList in a Custom Control


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...

  1. During CreateChildControls, DataSource is only going to be there on the original rendering. So I call DataBind on the nested DropDownList to get it to populate the first time, and then I store all the controls Items back to an Items property.
  2. I am pretty sure I understand how Items is persisted to/loaded from ViewState.
  3. Where I am lost, is how is my Items property then getting used to re-populate the DropDownList? I was thinking it was possibly the fact that I added Load\SaveViewState (which called the base.Load\SaveViewState) was what really fixed my issue, but when I commented out all references to my Items property, I was losing the drop down list values again.

How in the world is Items repopulating inputControl.Items on postback?!


Solution

  • I understand that the ultimate question is:

    How in the world is Items repopulating inputControl.Items on postback?!

    Nevertheless, I believe it's a question that doesn't need to (or shouldn't) be answered for two reasons:

    1. Your initial requirements statement:

      I created a custom control that essentially wraps a bunch of boostrap markup around a asp.net DropDownList.

    2. 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, IStateManager etc) but most of which are not needed in your case because (and at this point your requirements statement becomes of paramount importance):

      BootstrapDropDown is just a composite custom control that embed a DropDownList and can (and should) delegate all work to it!

    In fact, you've nicely done that for the Text and Value properties. Why not do it for the Items property, too? Your control works by composition. It does not need to maintain a ListItemCollection of 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:

    public class BootstrapDropDown : WebControl, INamingContainer
    {
        private DropDownList inputControl;
    
        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 IEnumerable DataSource { get; set; }
    
        public virtual ListItemCollection Items
        {
            get
            {
                EnsureChildControls();
                return inputControl.Items;
            }
        }
    
        public virtual string Value
        {
            get
            {
                EnsureChildControls();
                return inputControl.SelectedValue;
            }
            set
            {
                EnsureChildControls();
                inputControl.SelectedValue = value;
            }
        }
    
        public virtual string Text
        {
            get
            {
                EnsureChildControls();
                return inputControl.SelectedItem?.Text;
            }
        }
    
        protected override void CreateChildControls()
        {
            /* Added other html markup controls described above */
    
            var validatorContainer = new HtmlGenericControl("div");
            validatorContainer.Attributes["class"] = "validator-container";
    
            inputControl = new DropDownList() {
                CssClass = "form-control selectpicker show-tick " + ID,
                ID = ID,
                DataValueField = DataValueField,
                DataTextField = DataTextField,
                DataSource = DataSource
            };
    
            inputControl.Attributes["data-size"] = "15";
            inputControl.Attributes["data-live-search"] = "true";
    
            validatorContainer.Controls.Add(inputControl);
    
            Controls.Add(validatorContainer);
    
            if (DataSource != null)
            {
                inputControl.DataBind();
            }
    
            /* Added other html markup controls described */
        }
    }
    

    ASPX:

    <mh:BootstrapDropDown 
        runat="server" 
        ID="iGroup" 
        Label="Select Group Name" 
        DataSource='<%# Groups %>' 
        DataTextField="Text" 
        DataValueField="Value" />
    <asp:Button ID="Button1" runat="server" Text="Button" OnClick="Button1_Click" /><br />
    <asp:Label ID="Label1" runat="server" Text=""></asp:Label><br />
    <asp:Label ID="Label2" runat="server" Text=""></asp:Label>
    

    Code behind:

    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;
        }
    }
    
    protected void Page_Load(object sender, EventArgs e)
    {
        if (!IsPostBack)
        {
            DataBind();
        }
    }
    
    protected void Button1_Click(object sender, EventArgs e)
    {
        Label1.Text = iGroup.Text;
        Label2.Text = iGroup.Value;
    }
    

    There's one last thing worth mentioning. Pay attention to the inputControl being data bound after it's added to the Controls collection. 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 IStateManager in this article by Dino Esposito:

    https://www.itprotoday.com/web-application-management/inside-aspnet-control-properties