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)
        List.Remove(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
        Get
            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
        Get
            Return TryCast(_year, String)
        End Get
        Set(ByVal value As String)
            _year = value
        End Set
    End Property

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

    Public Property model() As String
        Get
            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")
        cars.Add(car1)
        cars.Add(car2)

        repeater1.DataSource = cars
        repeater1.DataBind()
    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
                cars.Add(car)
            End If

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

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

        repeater1.DataSource = cars
        repeater1.DataBind()
    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
    Else
        '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

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

22 comments:

  1. You rock. I have been searching and searching, scratching my head, pulling out my hair, and loosing my sanity for this information that you have posted. The crazy thing is that it actually works. Thanks

    ReplyDelete
  2. I need a little help on completing my project. I have saved the items to sql database, but need some assistance on populating the list with records from database and continuing to use the same features of adding removing from list then re-saving items to database.

    ReplyDelete
  3. I'm not sure how you access your database, but you'd want to do something akin to this in your Page_Load procedure:

    If Not Page.IsPostBack Then

    'declare a cars array
    Dim cars As New Cars()

    'perform query to get your needed info
    Dim conn As SqlConnection = New SqlConnection(ConfigurationManager.ConnectionStrings("MyConnectionString").ConnectionString)
    Dim cmd As SqlCommand = New SqlCommand("SELECT * FROM UserCars " & _
    "INNER JOIN Cars ON Cars.CarKey = UserCars.CarKey " & _
    "WHERE UserCars.UserKey = @UserKey", conn)
    cmd.Parameters.Add("@UserKey", SqlDbType.VarChar, 255).Value = _userKey

    Dim reader As SqlDataReader = Nothing

    Try
    conn.Open()

    reader = cmd.ExecuteReader()

    If reader.HasRows Then
    Do While reader.Read()

    'this is the important part: loop through each retrieved database row,
    'and create a car object per row, filling in all the car attributes from the row...
    Dim car As Cars.Car
    car.make = reader.Item("Make").ToString
    car.model = reader.Item("Model").ToString
    car.year = reader.Item("Year").ToString

    'then add it to the cars array we
    'created at the beginning of this proc.
    cars.Add(car)

    Loop
    Else
    Console.WriteLine("No rows found.")
    End If
    Catch ex As SqlException
    Console.WriteLine("SQL Error occurred.")
    Finally
    If Not reader Is Nothing Then reader.Close()

    conn.Close()
    End Try

    'now just bind it to the repeater
    repeater1.DataSource = cars
    repeater1.DataBind()
    End If

    ReplyDelete
  4. The above will load the cars from the database, but after the initial page load, it stays in memory as you Add or Remove cars onscreen. Once you wish to Save the cars back to the database, just do a For Each loop through the Repeater, grabbing the textbox values you need. (Note that you'll probably want to delete any existing ones from the database before you start looping, otherwise ones you've deleted onscreen will still remain in the database)

    ReplyDelete
  5. Whoops, that should be:
    Dim car As New Cars.Car()
    within the WHILE loop of the above-posted code.
    Or you can actually put it just above the start of the WHILE loop to gain a bit of efficiency.

    ReplyDelete
  6. Is it possiable to add or remove a column from repeater control using codeing..

    ReplyDelete
  7. There are numerous ways that you could do that, but I'm not sure it's in the same scope as what this example is doing. I'm thinking you might want a data-control which allows displaying data horizontally instead of vertically (DataList?), and then you can adapt it like this example. If you also need rows, nest it within a repeater. It might be tough to start off with, but it can be done. Good luck!

    ReplyDelete
  8. Thanks. It works great. I have a problem. I have a parent element and a child element. How to repeatedly add parent and its corresponding child.

    Thanks
    Manju

    ReplyDelete
  9. Is there always one child per parent? Can you maybe give a more specific example so I can better conceptualize what you're looking for?

    ReplyDelete
  10. Thanks a lot... was of great help. Keep up the good work :-)

    ReplyDelete
  11. Hi, i seem to encounter problems when i tried to implement the codes you posted, it seems that it doesnt fire up the repeater1_ItemCommand event

    p.s im using ajax so i added an update panel, dunno if it is the cause of the problem, but it still doesnt work without the update panel. thanks!

    ReplyDelete
  12. Awesome tutorial.. thanks
    - Pawan

    ReplyDelete
  13. can you please give us example with dropdownlist and textbox in repeater

    ReplyDelete
  14. Thank you very much for this post, it helped me a lot, works perfect.

    ReplyDelete
  15. Great job! Been looking for this for several month..recommended to others..
    Thanks heaps..

    ReplyDelete
  16. I am unable to download the cars.zip

    ReplyDelete
  17. can you do the code in c#.

    ReplyDelete
  18. Xaeryan,
    Do you have an alternate link to get the cars.zip file? The link above is broken.

    ReplyDelete
  19. please provide this code in c#

    ReplyDelete
  20. Excelent Post. Very nice explained. Thank you very much, very helpful.

    ReplyDelete
  21. You Rock!!! man awesome!!!

    ReplyDelete
  22. Does this have a memory leak because you are abandoning the earlier version of the cars collection (creating a new one, populating it and then repointing the repeater's datasource to the new collection) or am I not understanding something? I am looking to implement a true remove of an item from the collection instead of just rebuilding it and skipping over the item I don't want to keep.

    ReplyDelete