[ASPNET] EditorFor with List and add more item to list with AJAX

Imagine you have a list of items in ViewModel

What if you want to let user add 1 more item, or let user edit any item in that list?

In this blog post, I will show you how to do just that

The EditorFor Control

In the last post, you’ve learn how to use EditorFor and EditorForModel control.

One limitation of them is it cannot generate input for custom class

For a list, things get worst.

Display the list is easy, a simple for loop (or foreach) will do

But an “Editor” for an entire list is not naturally supported, so you need to create one for yourself

I’ve found a great post from Matt Lunn here and tweak it a little bit for easier to use

The class

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

JavaScript Code

I’m using JQuery, but the code below can be converted to pure JavaScript

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

Usage

Prepare the model

For the model you want to use with this shiny EditorForMany control, you need to add an Index Property

For example, if I have a class call 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; }
}

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

// in your script tag
// include the javascript code file above
// Call the method to assign event
AssignAddMoreButton();

If you want to put all the javascript code inside a single .js file, remember to call AssignAddMoreButton after document ready

The result

The result is something like this (of course with more styling)
demo image

How it’s work

The real magic happen in HtmlHelperExtensions class. Keyword Extensions make it an extension for HtmlHelper.

Steps

Basically, it do the following

  1. Get the list of items
  2. Get the Index property (if you indicate an index property, which is recommended)
  3. Generate a new item (the default new item, if only you make it do so)
  4. Build a HTML string
<div class="form-group">
    // List of your html input tag generated by editorfor and extended templates
</div>

The need of Index

There are 2 way to send a list to controller

  • using numbered index
    • deleting 1 item will mess up the whole list, the controller will only receive continuous index number
    • when dynamically add new item, we need to know the last index
<input type="text" name="YourList[0].Data"/>
<input type="text" name="YourList[1].Data"/>
  • using string index
    • required an extra field to store the non-continuous index
    • easy to add new, delete, modify
<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="hidden" name="YourList.[anotherGuid2].Data"/>

As you can see, the value of hidden input tag could be anything, as long as the value between square bracket is the same.

One step further, I use GUID for the index value, which mean it’s hardly to be duplicate (for simplicity, the ‘GenerateGuid’ javascript functions is not generating a real GUID, which still can be duplicated in theory)

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s