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.
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).
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;
}
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 <input /> 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 <input /> 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 <input />. 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 <input /></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
First, your model definition needs some tweaking:
public class UserModel
{
public string UserName { get; set; }//not sure where to use this
//for listing
public List<Email> Emails { get; set; }
//for adding
public Email Email { get; set; }
public UserModel()
{
this.Emails = new List<Email>();//remember to populate in the controller
}
}
Next, what you can do is (not sure on your table implementation) display a list of the current emails, and then have a form section that can post a new email to add:
@model namespace.UserModel
@foreach(var email in Emails)
{
<div>@email.Address</div>
}
<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>
@using (Html.BeginForm()) {
@Html.ValidationSummary(true)
<fieldset>
<legend>New Email Details</legend>
<div class="editor-label">
@Html.LabelFor(model => model.Email.Address)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Email.Address)
@Html.ValidationMessageFor(model => model.Email.Address)
</div>
<p>
<input type="submit" value="Add Email" />
</p>
</fieldset>
}
Have you considered using a third party tools for this?
I have found this on CodeProject and it appears to meet your requirements. Yes it'll require a bit of tweaking, but it should do the job
http://www.codeproject.com/Articles/277576/AJAX-based-CRUD-tables-using-ASP-NET-MVC-3-and-jTa
Alternatively you can spend hours on implementing similar functionality in JavaScript/jQuery.