2007-07-31

Dude, where's my DataItems?

ASP.Net coding problem of the day: I have a List of objects, and I have a web page that needs to display them all, allow additions and edits, and save all changes in a batch. Each object has just over a dozen properties, some of which are dependent on others. Also, this is code I inherited from a developer who has since moved on to other things.

What was coded so far was fairly straightforward. There was a user control created with all the TextBoxes and DropDowns, and a page that contained a Repeater control with the user control in it (plus a header and an AJAX CollapsiblePanelExtender to make it look nice and be usable). Most of the code was in place to retrieve the values for the DropDowns, retrieve the object List, and bind the object List to the Repeater. The only thing missing was the actual binding of an object to the user control.

I researched databinding syntax a bit and discovered that the Bind function allows for a two-way binding. I used that in the user control, but I discovered a small problem when trying to bind in the Repeater. Bind attempts to bind a property on the current DataItem, but in the Repeater, what I wanted to do was bind the DataItem (the object) itself to the control. I'm not sure, if I were able to accomplish this, if it would have solved my subsequent problems, but since it wasn't possible, I suppose that question is moot. My only choice was to, in the Repeater, set a property on the user control to <%# Container.DataItem %>.

As I was working with the user control, I found it advantageous to override the SaveViewState and LoadViewState methods, so that each user control saved its associated object in ViewState. (This web app is designed for use on an intranet, so I could afford the extra data transfer.) This would turn out to be my salvation later.

I ran into my first problem when I tried to implement the "Add" button on the page. The Repeater's DataSource is not automatically saved for PostBack, so attempting to add a new object to the List didn't work (the DataSource, and therefore the List, didn't exist). The simple solution was to save the List in the page's ViewState, and in Add_Click, get the List, Add a new object, and re-bind it to the Repeater. The problem was, when I changed some data in the TextBoxes and DropDowns of existing controls, clicking the Add button reverted all values back to their originals.

After much searching (finding many people running into issues with the non-persistence of DataSources and RepeaterItem DataItems), overriding various page- and control-level events, I finally worked out the answer.

The alleged two-way binding doesn't work in this case. Fortunately, the previous developer had already written (most of) a method to populate an object with the values from the on-screen controls, because I would need it. I found that, on PostBack, the Repeater control still contains the controls from the last page load, even if the DataItem itself is lost. So, what I did in the page's Load event, was to have it reconstruct the List based on the controls' copies of the objects (which, remember, were saving them in their own ViewStates). Fortunately, at this stage in the lifecycle, the controls' TextBoxes and DropDowns had already been updated with the changes typed in, so in the control's get accessor for the bound property, I had the control update its object with the values from the TextBoxes before returning it.

In short, the key parts of the code look like this:

MyUserControl.ascx.cs

private MyObject _myObject;

protected override object SaveViewState() {
    object[] vs = new object[] { _myObject, base.SaveViewState() };
    return vs;
}
protected override void LoadViewState(object savedState) {
    object vs;
    if (savedState is object[] && ((object[])savedState).Length == 2) {
        object[] myVS = (object[])savedState;
        _myObject = (MyObject)(myVS[0]);
        vs = myVS[1];
    } else {
        vs = savedState;
    }
    base.LoadViewState(vs);
}
public MyObject MyObject {
    get {
        SaveControlsToObject(_myObject);
        return _myObject;
    }
    set {
        if (_myObject == null) _myObject = value;
    }
}

WebPage.aspx

<asp:Repeater ID='repeater1' runat='server'>
    <ItemTemplate>
        <uc:MyUserControl ID='userControl1' runat='server' MyObject='<%# Container.DataItem %>' />
    </ItemTemplate>
</asp:Repeater>

WebPage.aspx.cs

protected void Page_Load(object sender, EventArgs e)
    if (!IsPostBack) {
        repeater1.DataSource = GetMyObjectList();
        repeater1.DataBind();
    } else {
        //rebuild the data source from the controls
        List<MyObject> myObjects = new List<MyObject>();
        foreach (RepeaterItem repeaterItem in repeater1.Items) {
            MyUserControl myControl = (MyUserControl)repeaterItem.FindControl("userControl1");
            myObjects.Add(myControl.MyObject);
        }
        repeater1.DataSource = myObjects;
    }
}
protected void Add_Click(object sender, EventArgs e) {
    MyObject newObject = new MyObject();
    newObject.someProperty = "initial value";
    ((List<MyObject>)repeater1.DataSource).Add(newObject);
    repeater1.DataBind();
}

Implementation of the Save function is left as an exercise for the user. ;)

No comments: