Question

I have registration wizard on my asp.net mvc 2 application with multiple pages. On the first page I should have basic data form of persons involved in the process. I should have 3 text boxes labeled as First Name, Last Name and Address, and 1 check box with text "Add another person". When user clicks on radio button, new text boxes will appear with new radio button, so we can add multiple persons in the same form. Theoretically, we should be able to insert as many persons as possible. All fields are mandatory, so in the validation summary on the top of the page I should have something like, "Please enter first name of the second person" or something like that. I have DTO class:

public class Person
{
     public string FullName { get; set; }
     public string LastName { get; set; }
     public string Address{ get; set; }
}

and I suppose that my model for this page should be List<Person> and I would append html for new persons with javascript/jQuery. Please help me here, how should I validate this dynamic page? I can go through this wizard with Save and Back buttons and also, we should be able to unclick any radio button on the page and that particular person should disappear and validator should not catch it anymore. My whole wizard is using server side validation (DataAnnotations) and I don't want to use client validation. Thanks in advance.

UPDATE:

I need some more help. I want to extend Person class with new property:

public int Percent { get; set; } 

and I want server validation upon submit if sum of all Percents in each of Persons in the IEnumerable<Person> is equal to 100. Can I create custom attribute for this and how? My model is generic List, I can't apply [CustomAttribute] on it, right?
Also, I should have validation summary on the top of the page, not right after each of the inputs. I have put : <%:Html.ValidationSummary(false, "Please correct the following and resubmit the page:")%> Is there a way to set different validation message for each of the Persons? Thanks

Was it helpful?

Solution

Before getting into implementation of this task I would very strongly recommend you reading the Editing a variable length list, ASP.NET MVC 2-style from Steven Sanderson.

Ready?

OK, now we could get into the implementation.

The first thing is to define our view model for the task. You've already had it, just define the corresponding validation rules on it:

public class Person
{
    [Required]
    public string FullName { get; set; }

    [Required]
    public string LastName { get; set; }

    [Required]
    public string Address{ get; set; }
}

and I suppose that my model for this page should be List

Yeas, absolutely.

So let's go ahead and create our PersonsController:

public class PersonsController : Controller
{
    public ActionResult Index()
    {
        var model = new[] 
        {
            new Person()
        };
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(IEnumerable<Person> persons)
    {
        if (!ModelState.IsValid)
        {
            return View(persons);
        }

        // To do: do whatever you want with the data
        // In this example I am simply dumping it to the output
        // but normally here you would update your database or whatever
        // and redirect to the next step of the wizard
        return Content(string.Join(Environment.NewLine, persons.Select(p => string.Format("name: {0} address: {1}", p.FullName, p.Address))));
    }

    public ActionResult BlankEditorRow()
    {
        return PartialView("_PersonEditorRow", new Person());
    }
}

And now let's define the view (~/Views/Persons/Index.cshtml):

@model IEnumerable<Person>

@using (Html.BeginForm())
{
    <div id="editorRows">
        @foreach (var item in Model)
        {
            Html.RenderPartial("_PersonEditorRow", item);
        }
    </div>    

    @Html.ActionLink(
        "Add another person", 
        "BlankEditorRow", 
        null, 
        new { id = "addItem" }
    )

    <p>
        <button type="submit">Next step</button>
    </p>
}

<script type="text/javascript">
    $('#addItem').click(function () {
        $.ajax({
            url: this.href,
            cache: false,
            success: function (html) { $('#editorRows').append(html); }
        });
        return false;
    });

    $(document).delegate('a.deleteRow', 'click', function () {
        $(this).parents('div.editorRow:first').remove();
        return false;
    });
</script>

and the corresponding partial view (~/Views/Persons/_PersonEditorRow.cshtml):

@model Person

<div class="editorRow">
    @using(Html.BeginCollectionItem("persons")) 
    {
        <div>
            @Html.LabelFor(x => x.FullName)
            @Html.EditorFor(x => x.FullName)
            @Html.ValidationMessageFor(x => x.FullName)
        </div>
        <div>
            @Html.LabelFor(x => x.LastName)
            @Html.EditorFor(x => x.LastName)
            @Html.ValidationMessageFor(x => x.LastName)
        </div>
        <div>
            @Html.LabelFor(x => x.Address)
            @Html.EditorFor(x => x.Address)
            @Html.ValidationMessageFor(x => x.Address)
        </div>

        <a href="#" class="deleteRow">delete</a>
    }
</div>

Remark: The Html.BeginCollectionItem helper used here is taken from Steven Sanderson's blog post that I have linked to previously in my answer and which you have already read and are familiar with. Here's the source code for completeness:

public static class HtmlPrefixScopeExtensions
{
    private const string idsToReuseKey = "__htmlPrefixScopeExtensions_IdsToReuse_";

    public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName)
    {
        var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
        string itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().ToString();

        // autocomplete="off" is needed to work around a very annoying Chrome behaviour whereby it reuses old values after the user clicks "Back", which causes the xyz.index and xyz[...] values to get out of sync.
        html.ViewContext.Writer.WriteLine(string.Format("<input type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\" value=\"{1}\" />", collectionName, html.Encode(itemIndex)));

        return BeginHtmlFieldPrefixScope(html, string.Format("{0}[{1}]", collectionName, itemIndex));
    }

    public static IDisposable BeginHtmlFieldPrefixScope(this HtmlHelper html, string htmlFieldPrefix)
    {
        return new HtmlFieldPrefixScope(html.ViewData.TemplateInfo, htmlFieldPrefix);
    }

    private static Queue<string> GetIdsToReuse(HttpContextBase httpContext, string collectionName)
    {
        // We need to use the same sequence of IDs following a server-side validation failure,  
        // otherwise the framework won't render the validation error messages next to each item.
        string key = idsToReuseKey + collectionName;
        var queue = (Queue<string>)httpContext.Items[key];
        if (queue == null)
        {
            httpContext.Items[key] = queue = new Queue<string>();
            var previouslyUsedIds = httpContext.Request[collectionName + ".index"];
            if (!string.IsNullOrEmpty(previouslyUsedIds))
                foreach (string previouslyUsedId in previouslyUsedIds.Split(','))
                    queue.Enqueue(previouslyUsedId);
        }
        return queue;
    }

    private class HtmlFieldPrefixScope : IDisposable
    {
        private readonly TemplateInfo templateInfo;
        private readonly string previousHtmlFieldPrefix;

        public HtmlFieldPrefixScope(TemplateInfo templateInfo, string htmlFieldPrefix)
        {
            this.templateInfo = templateInfo;

            previousHtmlFieldPrefix = templateInfo.HtmlFieldPrefix;
            templateInfo.HtmlFieldPrefix = htmlFieldPrefix;
        }

        public void Dispose()
        {
            templateInfo.HtmlFieldPrefix = previousHtmlFieldPrefix;
        }
    }
}

UPDATE:

My bad, I've just noticed that your question is tagged with asp.net-mvc-2. So I guess my Razor views do not apply to your case. Still, everything else should work the same. All you need to do is update the views so that they use the WebForms view engine:

Here's the ~/Views/Persons/Index.aspx:

<%@ Page Language="C#" Inherits="System.Web.Mvc.ViewPage<IEnumerable<Person>>" %>
<% using (Html.BeginForm()) { %>
    <div id="editorRows">
        <% foreach (var item in Model) { %>
            <% Html.RenderPartial("_PersonEditorRow", item); %>
        <% } %>
    </div>    

    <%= Html.ActionLink(
        "Add another person", 
        "BlankEditorRow", 
        null, 
        new { id = "addItem" }
    ) %>

    <p>
        <button type="submit">Next step</button>
    </p>
<% } %>

<script type="text/javascript">
    $('#addItem').click(function () {
        $.ajax({
            url: this.href,
            cache: false,
            success: function (html) { $('#editorRows').append(html); }
        });
        return false;
    });

    $(document).delegate('a.deleteRow', 'click', function () {
        $(this).parents('div.editorRow:first').remove();
        return false;
    });
</script>

and finally the (~/Views/Persons/_PersonEditorRow.ascx) partial:

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<Person>" %>
<div class="editorRow">
    <% using(Html.BeginCollectionItem("persons")) { %>
        <div>
            <%= Html.LabelFor(x => x.FullName) %>
            <%= Html.EditorFor(x => x.FullName) %>
            <%= Html.ValidationMessageFor(x => x.FullName) %>
        </div>
        <div>
            <%= Html.LabelFor(x => x.LastName) %>
            <%= Html.EditorFor(x => x.LastName) %>
            <%= Html.ValidationMessageFor(x => x.LastName) %>
        </div>
        <div>
            <%= Html.LabelFor(x => x.Address) %>
            <%= Html.EditorFor(x => x.Address) %>
            <%= Html.ValidationMessageFor(x => x.Address) %>
        </div>

        <a href="#" class="deleteRow">delete</a>
    <% } %>
</div>
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top