Tuesday, September 1, 2009

Dynamically adding / removing rows in ASP.NET Repeater (or other control) - using custom collections

Sometimes we need a row-based control that can dynamically add/remove rows on the fly, without being bound to a database. For example, we give the user a row of text-boxes to enter a car Year, Make, and Model. If they need more than one row to enter multiple cars, they click an Add button for each extra car they need. Each row also has a Remove button in case the user needs to remove a car. We'll take this even one step further by adding Move Up / Move Down buttons on each row; although that might not be useful in this particular example, there are many occasions where it is convenient to be able to reorder rows.

To achieve the desired effect, we could resort to Javascript and dynamically add/remove table rows via the DOM. But instead let's stick with VB.NET. I've come across this example by Rana Faisal which allows you to create your own datatable, and on each postback it saves the rows back into the datatable to reload it. But as the author says, this is not a best practice method, and I've had considerable issues with it in a complex application. There's also this one by Vinz, but it contains a lot of manual design work, and the additional overhead of storing the DataTable in ViewState. We should be able to do far better...

Instead, we are going to utilize the power of classes and collections, build our own class which inherits the CollectionBase class, and simply bind a basic Repeater control to our custom class instead! Our class will have methods to take care of Adds and Removes, and the repeater will simply keep up as it would if bound to a typical data source. It's just as easy as it sounds! And we aren't forced to use a Repeater; the concept works the same with any of the row-based databound controls (GridView, DataList, etc.).

So sticking with our Car / Make / Model example, make yourself a new class, "Cars", that inherits the CollectionBase class. Give it some default operations for adding, removing, and accessing it using the indexor (Cars(0) etc.):

Public Class Cars
    Inherits CollectionBase

    Public Function Add(ByVal car As Car) As Integer
        Return List.Add(car)
    End Function

    Public Sub Remove(ByVal car As Car)
    End Sub

    Public Sub InsertAt(ByVal car As Car, ByVal index As Integer)
        List.Insert(index, car)
    End Sub

    Default Public Property Item(ByVal index As Integer) As Car
            Return DirectCast(List(index), Car)
        End Get
        Set(ByVal value As Car)
            List(index) = value
        End Set
    End Property

End Class

Now nested within this class, create another class, "Car", which will define the individual properties per car. Give the class Property accessors to expose these properties:
Public Class Car

    Private _year As String
    Private _make As String
    Private _model As String

    'property accessors for private variables
    Public Property year() As String
            Return TryCast(_year, String)
        End Get
        Set(ByVal value As String)
            _year = value
        End Set
    End Property

    Public Property make() As String
            Return TryCast(_make, String)
        End Get
        Set(ByVal value As String)
            _make = value
        End Set
    End Property

    Public Property model() As String
            Return TryCast(_model, String)
        End Get
        Set(ByVal value As String)
            _model = value
        End Set
    End Property

    Public Sub New()
        'default constructor, needed if overriding (below)
    End Sub

    'extra constructor that takes all car info as parameters and intializes itself
    Public Sub New(ByVal year As String, _
                ByVal make As String, _
                ByVal model As String)
        _year = year
        _make = make
        _model = model
    End Sub

End Class

That's it for the class. Now in the Page Load event of your ASPX page, we are going to create an instance of the Cars class, and bind it to the repeater. We could pull some initial data from a database, XML file, or whatever you would like. For the sake of this example, I'm just going to add a few cars manually:
Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load

    If Not Page.IsPostBack Then

        'add some cars manually
        'alternatively, you would load your values from a database or other data source here
        'we must do a DataBind on the repeater here to force the repeater to at least show the header/footer
        Dim cars As New Cars()
        Dim car1 As New Cars.Car("2004", "Dodge", "Neon")
        Dim car2 As New Cars.Car("2009", "BMW", "135i")

        repeater1.DataSource = cars
    End If

End Sub

And this is what our repeater looks like (excuse the poor formatting). If you're using AJAX, feel free to wrap the Repeater within an Update Panel:

TIP: If using a GridView in place of the Repeater, use Container.DataItemIndex instead of Container.ItemIndex in the button CommandArguments above.

Go ahead and run your project so far. You should see a few rows with the car year/make/model we manually added in the page load event.

If we needed to do any conditional formatting, we could easily do so in the Repeater's ItemDataBound event:
Protected Sub repeater1_ItemDataBound(ByVal sender As Object, ByVal e As System.Web.UI.WebControls.RepeaterItemEventArgs) Handles repeater1.ItemDataBound

    Dim car As Cars.Car = DirectCast(e.Item.DataItem, Cars.Car)
    If car IsNot Nothing Then
        '(optional) do any data manipulation or conditional formatting here
    End If

End Sub

To this point, we've simply bound a Repeater to our custom collection class. Now we need to handle events like Add, Remove, Move Up, and Move Down. We will do so inside the ItemCommand event of our Repeater, which is triggered each time a button is clicked from within the Repeater:
Protected Sub repeater1_ItemCommand(ByVal source As Object, ByVal e As System.Web.UI.WebControls.RepeaterCommandEventArgs) Handles repeater1.ItemCommand

    'the magic happens here for adding/removing/reordering rows
    If e.CommandName = "Add" Or e.CommandName = "Remove" Or e.CommandName = "Up" Or e.CommandName = "Down" Then

        Dim carLastEntered As New Cars.Car() 'this will hold the last entered values

        Dim cars As New Cars
        For Each item As RepeaterItem In repeater1.Items 'loop through the repeater
            'putting the entered values into a car object
            Dim car As New Cars.Car
            car.year = DirectCast(item.FindControl("txtYear"), TextBox).Text
            car.make = DirectCast(item.FindControl("txtMake"), TextBox).Text
            car.model = DirectCast(item.FindControl("txtModel"), TextBox).Text

            'Add the existing row, or move it to new position, or skip it if it's the row Remove was clicked on
            If (e.CommandName = "Up" Or e.CommandName = "Down") AndAlso item.ItemIndex = CInt(e.CommandArgument) Then
                cars.InsertAt(car, item.ItemIndex - 1)
            ElseIf Not (e.CommandName = "Remove" AndAlso item.ItemIndex = CInt(e.CommandArgument)) Then
            End If

            carLastEntered = car 'save last entered values to use as default for new row if needed

        'Add new row with some defaults equal to the last car we added
        If e.CommandName = "Add" Then cars.Add(carLastEntered)

        repeater1.DataSource = cars
    End If

End Sub
NOTE: If, for whatever reason, you need this example to work with a paging control, replace all occurances above of CInt(e.CommandArgument) with CInt(e.CommandArgument) - (YourControl1.PageSize * YourControl1.PageIndex)

All this routine is doing is looping through the repeater and saving each item as a Car object into our Cars collection class. If the user clicked Remove, skip saving that row. If the user clicked Move Up or Move Down, we use the InsertAt(...) function of our Cars class to move the row up/down inside the collection. And when the Add button is clicked, we simply add a new row. As a bit of flair, we save the last values used and put them into the new row as defaults. Again, this is probably not useful for cars (how many people have two of the same car?), but there are many other times when this comes in handy. (If you want a blank row instead, just add a new Car object with empty strings for the Year, Make, and Model.)
Once our Cars collection class has all the Car objects in it, we bind our Repeater to the newly updated Cars object.

For fine tuning, let's hide the header row ("Year", "Make", "Model") when there are no items, and disable the Move Up / Move Down buttons when they shouldn't be available (because we can't move the first item up or the last item down!)
Protected Sub repeater1_PreRender(ByVal sender As Object, ByVal e As System.EventArgs) Handles repeater1.PreRender
    'hide the header text when there are no items - just show the Add button
    If repeater1.Items.Count = 0 Then
        Dim trHeader As HtmlTableRow = DirectCast(repeater1.Controls(0).FindControl("trHeader"), HtmlTableRow)
        trHeader.Visible = False
        'disable move up on first item, disable move down on last item
        DirectCast(repeater1.Items(0).FindControl("btnMoveUp"), Button).Enabled = False
        DirectCast(repeater1.Items(repeater1.Items.Count - 1).FindControl("btnMoveDown"), Button).Enabled = False
    End If
End Sub
Putting it all together, we end up with a nice "control" that can dynamically resize without the need for a database. Our rows are object oriented (the Cars and Car classes), and through the power of inheritence we are able to bind our rows to the repeater using native techniques (i.e. without any trickery).

The final step would be to have a "Submit" button, which simply loops through the Repeater, grabs all the values, and saves them to the data source of choice. I'll leave that exercise to you. Enjoy!

The complete source code can be downloaded here: Cars.zip

Dynamically adding / removing textboxes in ASP.NET Repeater
Implementing Custom Data Bindable Classes: CollectionBase
Autonumbering ASP.NET grid controls.