Add related entities with ASP.NET MVC and Razor?

2019-09-17 04:42发布

问题:

I have a Person class that has a navigation property ICollection<Address> Addresses. The Address entity has a property City.

I want to give the user an option to create/update a Person along with its addresses, including adding and removing ones.

So for displaying the addresses I simply call @Html.EditorFor(m => m.Addresses), and the razor engine takes care of the collection using my custom template that resides in the EditorTemplates folder, generating foreach field a matching signature like id="Addresses_0__City" name="Addresses[0].City".
So far so good.

The problem is adding new Addresses.
I created a button that when clicked, a jQuery function calls an action (that's rendered via the custom EditorTemplate), but its fields don't have that signature as above, but just id="City" name="City", and thus, isn't recognized in the post action as part of the Person entity.

How do I have those id and name fields generated with the correct signature?

I've read this article and many others, but found none that address the ids and names issue.

回答1:

I ended up using the following as per the suggestions on comments.

Anyway it bothered me that I have to wrap the new item as collection, and that the hidden field is just appended after the collection item, rather than being injected to (because at removal it stays there).

So I ended up adding the following extensions to be used both on the Razor cshtml files, and on the action that's called when adding a new item to the collection:

Here are the extensions (there are some more overloads, please see the full code here):

private static string EditorForManyInternal<TModel, TValue>(HtmlHelper<TModel> html, Expression<Func<TModel, IEnumerable<TValue>>> expression, IEnumerable<TValue> collection, string templateName)
{
  var sb = new StringBuilder();

  var prefix = html.ViewContext.ViewData.TemplateInfo.HtmlFieldPrefix;
  var htmlFieldName = (prefix.Length > 0 ? (prefix + ".") : String.Empty) + ExpressionHelper.GetExpressionText(expression);

  var items = collection ?? expression.Compile()(html.ViewData.Model);
  foreach (var item in items)
  {
    var guid = Guid.NewGuid().ToString();

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

    var editor = html.EditorFor(singleItemExp, templateName, string.Format("{0}[{1}]", htmlFieldName, guid));
    var hidden = String.Format(@"<input type='hidden' name='{0}.Index' value='{1}' />", htmlFieldName, guid);

    var eNode = HtmlNode.CreateNode(editor.ToHtmlString().Trim());
    if (eNode is HtmlTextNode)
      throw new InvalidOperationException("Unsuported element.");

    if (eNode.GetAttributeValue("id", "") == "")
      eNode.SetAttributeValue("id", guid);

    var hNode = HtmlNode.CreateNode(hidden);
    eNode.AppendChild(hNode);
    sb.Append(eNode.OuterHtml);
  }

  return sb.ToString();
}

public static MvcHtmlString EditorForMany<TModel, TValue>(this HtmlHelper<TModel> html, Expression<Func<TModel, IEnumerable<TValue>>> expression, string templateName)
{
  var value = EditorForManyInternal(html, expression, null, templateName);
  return new MvcHtmlString(value);
}

Usage in view:

<div>
  <h4>@Resources.Person.Children</h4>
  <ul id="patientChildren" class="list-group ajax-collection">
    @Html.EditorForMany(m => m.Children)
  </ul>

  @Ajax.ActionLink("Create Child", "CreateChild", new { patientId = Model.Id, lastName = Model.LastName }, new AjaxOptions { UpdateTargetId = "patientChildren", InsertionMode = InsertionMode.InsertAfter, OnSuccess = CommonData.AjaxOnSuccessJsFuncName }, new { @class = "button btn-default" })
</div>

Here's the ajax function being called (it's important to have the generated items classed with ajax-collection-item and have a remove button classed btn remove):

//#region Ajax add and remove
var ajaxCollectionItemSelector = '.ajax-collection-item';
function attachAjaxRemoveHandlers(id) {
  var context = $(id ? '#' + id : ajaxCollectionItemSelector);

  var removeButton = context.find('.btn.remove');

  removeButton.click(function () {
    var button = $(this);
    var collectionItem = button.closest(ajaxCollectionItemSelector);
    collectionItem.remove();
  });
};

function ajaxOnSuccess(ajaxContext) {
  var collectionItem = $(ajaxContext);
  var id = collectionItem.prop('id');
  attachAjaxRemoveHandlers(id);
  //TODO: following line doesn't work
  collectionItem.find(':text:first-of-type').focus();
};

function runCommonScripts() {
  attachAjaxRemoveHandlers();
};
//#endregion Ajax add and remove

The new item action (CreateChild) looks like the following (the EditorForSingle extension is on the same place:

public ContentResult CreateChild(int patientId, string lastName)
{
  return this.EditorForSingle((Patient p) => p.Children, 
    new PatientChild
    { 
      PatientId = patientId, 
      LastName = lastName 
    });
}