[ASPNET] Editor for với List và thêm mới item dùng AJAX

Imagine you have a list of items in your ViewModel

What if you want to let the user add a new item, or edit any item in the list?

After reading this post you’ll know how

1. EditorFor Control

In the previous post you learned how to use the EditorFor and EditorForModel controls.

One limitation of those two is that they don’t create inputs for your custom classes

And for a list of items, even less so.

Displaying a list is easy — one for (or foreach) loop and you’re done

But an “Editor” for a whole list isn’t supported, so you have to build it yourself

Starting from an interesting post by Matt Lunn here, we’ll change it a little to make it easier to use and better suited to our needs

2. Class

2.1. C# code

namespace Yournamespace.Utilities
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Linq.Expressions;
    using System.Text;
    using System.Web.Mvc;
    using System.Web.Mvc.Html;
 
    public static class HtmlHelperExtensions
    {
        /// <summary>
        /// Generate appropriate control for a list of data
        /// </summary>
        /// <typeparam name="TModel">The Model contain the list</typeparam>
        /// <typeparam name="TValue">The Model of list of items</typeparam>
        /// <param name="html"></param>
        /// <param name="propertyExpression">Which property</param>
        /// <param name="indexResolverExpression">Select the property to be the index</param>
        /// <param name="isIncludeNewItem">Set to true to include a default new item</param>
        /// <param name="includeIndexField">Set to true to include Index in values sent to server</param>
        /// <returns>HTML codes of editorfor a list of items</returns>
        public static MvcHtmlString EditorForMany<TModel, TValue>(
            this HtmlHelper<TModel> html,
            Expression<Func<TModel, IEnumerable<TValue>>> propertyExpression,
            Expression<Func<TValue, string>> indexResolverExpression = null,
            bool isIncludeNewItem = false,
            bool includeIndexField = true)
            where TModel 
                : class where TValue 
                : new()
        {
            var items = propertyExpression.Compile()(html.ViewData.Model);
            var htmlBuilder = new StringBuilder();
            var htmlFieldName = ExpressionHelper.GetExpressionText(propertyExpression);
            var htmlFieldNameWithPrefix = html.ViewData.TemplateInfo.GetFullHtmlFieldName(htmlFieldName);
            var indexResolver = GetIndexResolver(indexResolverExpression);
            items = AddDefaultNewItem(isIncludeNewItem, items);
 
            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);
 
                guid = string.IsNullOrEmpty(guid) ? Guid.NewGuid().ToString() : html.AttributeEncode(guid);
                BuildHtmlString(html, indexResolverExpression, includeIndexField, htmlBuilder, htmlFieldName, htmlFieldNameWithPrefix, guid, singleItemExp);
            }
 
            return new MvcHtmlString(htmlBuilder.ToString());
        }
 
        private static void BuildHtmlString<TModel, TValue>(
            HtmlHelper<TModel> html,
            Expression<Func<TValue, string>> indexResolverExpression,
            bool includeIndexField,
            StringBuilder htmlBuilder,
            string htmlFieldName,
            string htmlFieldNameWithPrefix,
            string guid,
            Expression<Func<TModel, TValue>> singleItemExp)
            where TModel : class
            where TValue : new()
        {
            htmlBuilder.Append(@"<div>");
 
            if (includeIndexField)
            {
                htmlBuilder.Append(_EditorForManyIndexField(htmlFieldNameWithPrefix, guid, indexResolverExpression));
            }
 
            htmlBuilder.Append(html.EditorFor(singleItemExp, null, $"{htmlFieldName}[{guid}]"));
 
            htmlBuilder.Append(@"</div>");
        }
 
        private static IEnumerable<TValue> AddDefaultNewItem<TValue>(bool isIncludeNewItem, IEnumerable<TValue> items) where TValue : new()
        {
            if (isIncludeNewItem)
            {
                items = items.Concat(new[]
                {
                    new TValue()
                });
            }
 
            return items;
        }
 
        private static Func<TValue, string> GetIndexResolver<TValue>(Expression<Func<TValue, string>> indexResolverExpression) where TValue : new()
        {
            Func<TValue, string> indexResolver;
            if (indexResolverExpression == null)
            {
                indexResolver = x => null;
            }
            else
            {
                indexResolver = indexResolverExpression.Compile();
            }
 
            return indexResolver;
        }
 
        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(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());
        }
    }
}

2.2. JavaScript Code

The code below uses JQuery, but you can convert it to plain JavaScript just fine

function GenerateGuid() {
    function s4() {
        return Math.floor((1 + Math.random()) * 0x10000)
            .toString(16)
            .substring(1);
    }
 
    return s4() + s4() + "-" + s4() + "-" + s4() + "-" + s4() + "-" + s4() + s4() + s4();
}
 
function AssignAddMoreButton() {
    $(".add-more-button").click(function (event) {
        event.preventDefault();
        debugger;
        var id = "#" + $(this).data("class");
        var clone = $(id).children().last().clone();
        var guid = clone.children().first().val();
        var regex = new RegExp(guid, "g");
        var newHtml = clone.html(function (i, oldHtml) {
            return oldHtml.replace(regex, GenerateGuid());
        });
        $(id).append(newHtml);
    });
}

3. How to use it

3.1. Preparing the model

For the model you want to use with this EditorForMany control, you have to add one more property: Index

For example, if I have a class literally named Model

public class Model
{
    // Your normal, already existed properties
 
    // set to false if you don't want to generate a HTML input tag
    // for it when using with editorfor control
    [ScaffoldColumn(false)]
    public string Index { get; set; }
}

3.2. Razor code

@using(Html.BeginForm("ActionName","ControllerName",FormMethod.Post, new {@class="CssClassName"}))
{
    // the last parameter "true" is to generate a default item
    @Html.EditorForMany(x => x.Model, x => x.Index, true)
}
 
 
    // include the javascript code file above
    AssignAddMoreButton();

If you want to put all the javascript code in a .js file, remember to call the AssignAddMoreButton function after “document ready”

4. The result

The result looks like this (with some bootstrap styling added)

demo image

5. How it works

HtmlHelperExtensions is exactly where the magic happens. The Extensions keyword “registers” it as an extension of HtmlHelper

5.1. The steps

Basically it does the following steps

  1. Get the list of Items
  2. Get the Index property (if you declared an index)
  3. Generate a new default item (if you told it to)
  4. Build the HTML code
<div class="form-group">
    <!-- List of your html input tag generated by editorfor and extended templates -->
</div>

5.2. Why do we need the Index

There are 2 ways to send a list of data to the controller

  • use a numbered array

    • deleting 1 item messes up the whole list; the controller only accepts a contiguous array
    • adding 1 new item requires knowing what the last index is
<input type="text" name="YourList[0].Data"/>
<input type="text" name="YourList[1].Data"/>
  • use an array with string indexes
    • requires an extra hidden input tag to hold the index
    • easy to add, delete and edit items
<input type="hidden" name="YourList.Index" value="radomGuid1"/>
<input type="text" name="YourList[randomGuid1].Data"/>
 
<input type="hidden" name="YourList.Index" value="anotherGuid2"/>
<input type="text" name="YourList[anotherGuid2].Data"/>

As you can see, the hidden input tag’s value can be anything, as long as the value inside the square brackets matches it.

Going one step further, the javascript code above generates GUID-style indexes, so you never have to worry about duplicate indexes. That said, it isn’t a real GUID, because generating a real GUID is a bit complex and makes the app heavier — like using a rocket to kill a fly — so for simplicity that code only produces a “fake” GUID, but the way it’s written collisions are unlikely

Do you have a way to improve the code above?