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
});
}