Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I add rows to a collection list in my Model?

For the sake of simplicity, let's say I have a User model that has a List<Email> as one of its properties.

public class UserModel
{
    public string UserName { get; set; }
    public List<Email> Emails = new List<Email>();
}

public class Email
{
    public string Address { get; set; }
}

In my view, I have a list of the emails:

<table>
@foreach(Email email in Model.Emails)
{
    <tr>
        <td>@Html.EditorFor(modelItem => email.Address)</td>
    </tr>
}
</table>

Now let's say I want the user to be able to click a button that adds a new row to the table so that the user can add a new Email to the List that is bound to their User. How do I do this? Do I need to add the new row via javascript in a certain way so that it gets bound to the model when the page is posted? I have no idea how to approach this as I'm relatively new to MVC coming from WebForms.

like image 675
Bryan Denny Avatar asked Mar 28 '12 21:03

Bryan Denny


3 Answers

After some researching, I found this blog post by Steven Anderson http://blog.stevensanderson.com/2010/01/28/editing-a-variable-length-list-aspnet-mvc-2-style/

It appears to be doing exactly what I want (except it is written in MVC2).

like image 144
Bryan Denny Avatar answered Oct 29 '22 05:10

Bryan Denny


This is one of those places where MVC and WebForms dramatically diverge.

If I were doing this, I'd use AJAX to submit the new email address and return either a JSON object or the table of emails rendered as a Partial View. That way you don't have to reload the whole page. Here's and example that would return the HTML from the AJAX call, using jQuery because I'm not a fan of MVC's native AJAX functionality.

Original View:

@*HTML/Razor*@
@Html.Partial("EmailTable", Model.Emails)
@*HTML/Razor*@

Partial View: EmailTable

@model List<Email>
<table id='UserEmails'>
@foreach(var email in Model)
{
    <tr>
        <td>@Html.EditorFor(modelItem => email.Address)</td>
    </tr>
}
</table>

Controller Action: AddEmail

public ActionResult AddEmail(string email, object someUserIdentifier){
    //if email is valid
        //add email to user's data store
    //get new UserModel, user
    return PartialView("EmailTable", user.Emails);
}

jQuery to handle the button click

function AddEmail(e){
    var newEmailForm = $("<form />").attr("action", urlToController + "/AddEmail/").submit(SaveEmail);
    $("<input/>").attr({type: "text", id="NewEmailAddress"}).appendTo(newEmailForm);
    $("<input/>").attr("type", "submit").click(SaveEmail).appendTo(newEmailForm);
    newEmailForm = $("<td />").append(newEmailForm);
    newEmailForm = $("<tr />").append(newEmailForm);
    $('#UserEmails').append(newEmailForm);
}
function SaveEmail(e){
    var newEmail = $("#NewEmailAddress").val();
    if (/*newEmail is valid*/){
        $.ajax({
            url: urlToController + "/AddEmail/",
            data: { email: newEmail, someUserIdentifer: null/*or something useful*/ },
            success: function(newTable){
                $('#UserEmails').replaceWith(newTable);
            },
            error: function(xhr, status, error){
                //display error
            }
        });
    }
    else{
        //tell user what a valid email address looks like
    }
    return false;
}
like image 39
devstruck Avatar answered Oct 29 '22 06:10

devstruck


I would use an extension method instead that you can use in other cases as well:

Extension:

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Text;
using System.Web.Mvc;
using System.Web.Mvc.Html;

public static class HtmlHelperExtensions
{
    /// <summary>
    /// Generates a GUID-based editor template, rather than the index-based template generated by Html.EditorFor()
    /// </summary>
    /// <typeparam name="TModel"></typeparam>
    /// <typeparam name="TValue"></typeparam>
    /// <param name="html"></param>
    /// <param name="propertyExpression">An expression which points to the property on the model you wish to generate the editor for</param>
    /// <param name="indexResolverExpression">An expression which points to the property on the model which holds the GUID index (optional, but required to make Validation* methods to work on post-back)</param>
    /// <param name="includeIndexField">
    /// True if you want this helper to render the hidden &lt;input /&gt; for you (default). False if you do not want this behaviour, and are instead going to call Html.EditorForManyIndexField() within the Editor view. 
    /// The latter behaviour is desired in situations where the Editor is being rendered inside lists or tables, where the &lt;input /&gt; would be invalid.
    /// </param>
    /// <returns>Generated HTML</returns>
    public static MvcHtmlString EditorForMany<TModel, TValue>(this HtmlHelper<TModel> html, Expression<Func<TModel, IEnumerable<TValue>>> propertyExpression, Expression<Func<TValue, string>> indexResolverExpression = null, bool includeIndexField = true) where TModel : class
    {
        var items = propertyExpression.Compile()(html.ViewData.Model);
        var htmlBuilder = new StringBuilder();
        var htmlFieldName = ExpressionHelper.GetExpressionText(propertyExpression);
        var htmlFieldNameWithPrefix = html.ViewData.TemplateInfo.GetFullHtmlFieldName(htmlFieldName);
        Func<TValue, string> indexResolver = null;

        if (indexResolverExpression == null)
        {
            indexResolver = x => null;
        }
        else
        {
            indexResolver = indexResolverExpression.Compile();
        }

        foreach (var item in items)
        {
            var dummy = new { Item = item };
            var guid = indexResolver(item);
            var memberExp = Expression.MakeMemberAccess(Expression.Constant(dummy), dummy.GetType().GetProperty("Item"));
            var singleItemExp = Expression.Lambda<Func<TModel, TValue>>(memberExp, propertyExpression.Parameters);

            if (String.IsNullOrEmpty(guid))
            {
                guid = Guid.NewGuid().ToString();
            }
            else
            {
                guid = html.AttributeEncode(guid);
            }

            if (includeIndexField)
            {
                htmlBuilder.Append(_EditorForManyIndexField<TValue>(htmlFieldNameWithPrefix, guid, indexResolverExpression));
            }

            htmlBuilder.Append(html.EditorFor(singleItemExp, null, String.Format("{0}[{1}]", htmlFieldName, guid)));
        }

        return new MvcHtmlString(htmlBuilder.ToString());
    }

    /// <summary>
    /// Used to manually generate the hidden &lt;input /&gt;. To be used in conjunction with EditorForMany(), when "false" was passed for includeIndexField. 
    /// </summary>
    /// <typeparam name="TModel"></typeparam>
    /// <param name="html"></param>
    /// <param name="indexResolverExpression">An expression which points to the property on the model which holds the GUID index (optional, but required to make Validation* methods to work on post-back)</param>
    /// <returns>Generated HTML for hidden &lt;input /&gt;</returns>
    public static MvcHtmlString EditorForManyIndexField<TModel>(this HtmlHelper<TModel> html, Expression<Func<TModel, string>> indexResolverExpression = null)
    {
        var htmlPrefix = html.ViewData.TemplateInfo.HtmlFieldPrefix;
        var first = htmlPrefix.LastIndexOf('[');
        var last = htmlPrefix.IndexOf(']', first + 1);

        if (first == -1 || last == -1)
        {
            throw new InvalidOperationException("EditorForManyIndexField called when not in a EditorForMany context");
        }

        var htmlFieldNameWithPrefix = htmlPrefix.Substring(0, first);
        var guid = htmlPrefix.Substring(first + 1, last - first - 1);

        return _EditorForManyIndexField<TModel>(htmlFieldNameWithPrefix, guid, indexResolverExpression);
    }

    private static MvcHtmlString _EditorForManyIndexField<TModel>(string htmlFieldNameWithPrefix, string guid, Expression<Func<TModel, string>> indexResolverExpression)
    {
        var htmlBuilder = new StringBuilder();
        htmlBuilder.AppendFormat(@"<input type=""hidden"" name=""{0}.Index"" value=""{1}"" />", htmlFieldNameWithPrefix, guid);

        if (indexResolverExpression != null)
        {
            htmlBuilder.AppendFormat(@"<input type=""hidden"" name=""{0}[{1}].{2}"" value=""{1}"" />", htmlFieldNameWithPrefix, guid, ExpressionHelper.GetExpressionText(indexResolverExpression));
        }

        return new MvcHtmlString(htmlBuilder.ToString());
    }
}

Add a property to the model, which the EditorForMany helper will store the generated index in. Without this, the Html.Validation* methods will not work (see here for a deep-dive into “why” for the curious).

public class UserModel
{
    public string UserName { get; set; }
    public List<Email> Emails = new List<Email>();      
}

public class Email
{
    public string Address { get; set; }
    public string Index { get; set; }
}

Substitute @Html.EditorFor(modelItem => email.Address) with:

@Html.EditorForMany(x => x.Emails, x => x.Index, false);
@Html.EditorForManyIndexField(x => x.Index)

(Note: If you are not in a <tr>, <tbody> or <ul> or similar the code would be @Html.EditorForMany(x => x.Emails, x => x.Index) and you would not need @Html.EditorForManyIndexField(x => x.Emails, x => x.Index) or @Html.EditorForManyIndexField(x => x.Index). Without setting Indexfield yourself your table would be badly formatted and therefore we do it like this.)

Now all of our problems are solved! You’ll see that Html.EditorForMany() uses GUIDs rather than numbers for indexes. This removes the need for us to tell our AJAX endpoint which indexes as been used; as our AJAX endpoint will instead just generate a new GUID. Html.EditorForMany() also takes care of seamlessly producing the .Index field for us as well.

All that’s left to do is to get our AJAX endpoint up and running. To do this, I define a new action in my Controller.

[OutputCache(NoStore = true, Duration = 0, VaryByParam = "*")]
public ActionResult AddEmail()
{
    var user = new UserModel();
    user.Emails.Add(new Email()); 
    return View(user);
}

Create a new view Views\Shared\AddEmail.cshml;

@model DynamicListBinding.Models.UserModel
@{
    Layout = null;
}
@Html.EditorForMany(x => x.Emails, x => x.Index, false);

Kudos to Matt for original article

like image 34
Ogglas Avatar answered Oct 29 '22 05:10

Ogglas