Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
268 views
in Technique[技术] by (71.8m points)

html - Binding arrays in ASP.NET MVC without index?

I have an HTML that looks like this:

<input type="text" name="data[]" value="George"/>
<input type="text" name="data[]" value="John"/>
<input type="text" name="data[]" value="Paul"/>
<input type="text" name="data[]" value="Ringo"/>

In PHP, I can receive this array like:

$array = $_POST['name'];
// $array[0] == "George"

In ASP.NET MVC the model binder conventions forces me to put indexes in the HTML, so the controller can receive the array.

<!-- HTML for the ASP.NET MVC Version -->
<input type="text" name="data[0]" value="George"/>
<input type="text" name="data[1]" value="John"/>
<input type="text" name="data[2]" value="Paul"/>
<input type="text" name="data[3]" value="Ringo"/>

// C# Controller
public ActionResult SomeAction(string[] data)
{
    // Do stuff
}

If I send the first HTML, data will be null in the Action.

Well, I think this sucks.

If I use client side code to remove or add items to the array, I have to write code to re-index the HTML array.

Is there a way to extend the ASP.NET MVC ModelBinder for binding arrays with no indexes or a workaround to deal with this?

EDIT

After trying your answers I arrive to the conclusion that the example I've posted is not useful to my purposes. My real situation is this:

View

<table>
@for (var i = 0; i < Model.Sections.Count; ++i)
{
    <tr>
        <td><a href="#" class="edit-section"><span class="glyphicon glyphicon-question-sign"></span></a></td>
        <td>@Html.TextBoxFor(m => Model.Sections[i].SectionOrder, new { @class = "form-control" })</td>
        <td>@Html.TextBoxFor(m => Model.Sections[i].Title, new { @class = "form-control" })</td>
        <td>@Html.TextBoxFor(m => Model.Sections[i].SubTitle, new { @class = "form-control" })</td>
        <td>
            @Html.HiddenFor(m => Model.Sections[i].Id)
            <a href="#" class="delete-section"><span class="glyphicon glyphicon-remove"></span></a>
        </td>
    </tr>
}
</table>

Action

public ActionResult SaveSections(ICollection<SectionModel> sections)
{
    // DO STUFF
}

I've tried making the HTML inputs by hand, like:

@for (var i = 0; i < Model.Sections.Count; ++i)
{
    <tr>
        <td><a href="#" class="edit-section"><span class="glyphicon glyphicon-question-sign"></span></a></td>
        <td>@Html.TextBox("Sections.SectionOrder", Model.Sections[i].SectionOrder, new { @class = "form-control" })</td>
        <td>@Html.TextBox("Sections.Title", Model.Sections[i].Title, new { @class = "form-control" })</td>
        <td>@Html.TextBox("Sections.SubTitle", Model.Sections[i].SubTitle, new { @class = "form-control" })</td>
        <td>
            @Html.Hidden("Sections.SubTitle", Model.Sections[i].Id)
            <a href="#" class="delete-section"><span class="glyphicon glyphicon-remove"></span></a>
        </td>
    </tr>
}

But it didn't work...

See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Reply

0 votes
by (71.8m points)

You don't need to explicitly index flat data. If in your view you have

<input type='text' name='data' value='George' />
<input type='text' name='data' value='John' />
<input type='text' name='data' value='Paul' />
<input type='text' name='data' value='Ringo' />

Then in your controller you can use

public ActionResult Create(string[] data)
{
    //data should now be a string array of 4 elements
    return RedirectToAction("Index");
}

To understand the binder, basically work backward. When you submit your form, assuming it is posting to your Create method, the model binder examines the method parameters. It will see you have an array of strings as a parameter and it's called data. It likes strings because form data is submitted as strings. It doesn't have to do any real work here except look in the form collection for elements with a key of data. All items that match are added to an array and assigned to your parameter.

This works because the parameter has the same name as the form elements. If the names didn't match, you'd get null because nothing was found with that key name.

If you use strong views (views with an explicit model) then you can use the MVC helpers to generate these for your and the input elements will be assigned the proper name to map back to your object.

For instance, if you had a model:

public class BandMembers
{
    public string[] data {get; set;}
}

And in your view you specified this as your model and used the appropriate HTML helpers, your action method could instead be like the following:

public ActionResult Create(BandMembers band)
{
    //band now has a property called 'data' with 4 elements
    return RedirectToAction("Index");
}

This should result in an instantiated object called band which has a property names with 4 values. This works because the model binder sees a parameter called band that doesn't match any known keys from the form collection, realizes it's a complex object (not a string, int, string[], int[], etc.) and examines its members. It sees this object has a string array called data and there are keys in the form collection with that name. It gathers up the values, assigns them to the data property and assigns that object to your band parameter.

Now you understand view models!

*Be warned if you'd used BandMembers class in your controller but called it data you'd get a null. This is because the model binder finds items in the form collection with the key data but can't figure out how to cast them from strings to a BandMembers object.

EDIT

With regard to your edit with a deeper example, here's what I've come up with.

First, my model just so we're on the same page. I created a FormData object that contains a List of Section to act as the collection of objects.

public class FormData
{
    public List<Section> Sections { get; set; }

    public FormData()
    {
    }
}

And my Section.cs class:

public class Section
{
    public bool IsDeleted { get; set; }
    public bool IsNew { get; set; }
    public int Id { get; set; }
    public int SectionOrder { get; set; }
    public string Title { get; set; }
    public string SubTitle { get; set; }

    public Section()
    {
    }
}

Using an EditorTemplate on your Section makes it easy to render out the content with indexes generated for you. I've mocked the project up on my own and verified this is working. Unfortunately as you've seen, once you remove an item, your indexes will be out of order. So how do you fix this? Sure you can go and read the indexes and rewrite them, OR - just don't remove them! What I've done in my mock project is add a new property on Section called IsDeleted and render it as a hidden element. In the JavaScript handler for the delete click, I hide the row and update the hidden input for that row's IsDeleted input to 'true'. When I submit the form, I'll now have a complete collection along with a handy flag that lets me know which rows I need to remove from my model.

I created a Test view bound to a model called FormData which contains a List.

@model MVCEditorTemplateDemo.Models.FormData
@using (Html.BeginForm())
{
    <table id="section-container">
        @Html.EditorFor(m => m.Sections)
    </table>

    @Ajax.ActionLink("Add Section", "GetNewSection", "Home", new AjaxOptions() { HttpMethod="POST", InsertionMode=InsertionMode.InsertAfter, UpdateTargetId="section-container" })
    <input type="submit" value="Submit" />
}

Yes, that EditorFor is taking the collection! But how does it know what to do with it? I created a folder in my Views/Home (can be in Shared if you want to use it across controllers) called EditorTemplates in which I place a partial view called Section.cshtml. The name is important - it should match the name of the object it will render. Since my model contains objects called Section, my EditorTemplate should also be called Section.

Here's what it looks like (EditorTemplatesSection.cshtml):

@model MVCEditorTemplateDemo.Models.Section
<tr>
    <td><a href="#" class="edit-section"><span class="glyphicon glyphicon-question-sign"></span></a></td>
    <td>@Html.TextBoxFor(m => Model.SectionOrder, new { @class = "form-control" })</td>
    <td>@Html.TextBoxFor(m => Model.Title, new { @class = "form-control" })</td>
    <td>@Html.TextBoxFor(m => Model.SubTitle, new { @class = "form-control" })</td>
    <td>
        @Html.HiddenFor(m => Model.Id)
        @Html.HiddenFor(m => Model.IsNew)
        @Html.HiddenFor(m => Model.IsDeleted)
        <a href="#" class="delete-section"><span class="glyphicon glyphicon-remove"></span></a>
    </td>
</tr>

I tried to keep it as close to what you had as I could in order to match your requirement. I wouldn't normally use tables otherwise when you plan to dynamically add or remote elements. Some browsers don't behave very well not to mention my designer has a burning hatred for the complication tables cause with rendering.

Alright, now you have what you need to let ASP.NET MVC automatically render your items and generate the indexes automatically. So let's see about deleting that row.

Back in my Test view, I've added a scripts section as follows:

@section scripts
{
    <script type="text/javascript">
        $(function () { 
            $("table").on("click", ".delete-section", function() {
                $(this).closest('tr').hide();
                $(this).prev('input').val('true');
            });
        });
    </script>
}

This works perfectly. When a user clicks the delete button, they get an immediate UI feedback that row is gone and when the form is submitted, I'll have the entire collection of items I rendered with a handy property letting me know which items I need to remove from my data store. The best part is I never had to iterate my collection and all my indexes were generated automatically.

And that ends my answer to your question.

But then I got curious what I'd need to do to make new rows. So let's take a look back at the Test view and that Ajax.Action helper. You'll notice right away that I'm instructing the browser to do a POST request. Why? Because browsers can cache GET requests to optimize performance. Normally you would't care as you'd typically be returning the same HTML for every request but since we need to include special naming, our HTML is actually different every time (to include the index in our input names). The rest is self explanatory. The real trick is on the server side - how do we return a partial to add a row to that table with the proper indexing?

Unfortunately the framework, while good at finding Views normally, seems to fall down at checking in the EditorTemplates and DisplayTemplates folders. Our Action is therefor a bit more dirty than we'd normally have if we weren't using templates.

public ActionResult GetNewSection()
{
    var section = new Section() { IsNew = true };
    FakedData.Sections.Add(section);
    ViewData.TemplateInfo.HtmlFieldPrefix = string.Format("Sections[{0}]", FakedData.Sections.Count-1);
    return PartialView("~/Views/Home/EditorTemplates/Section.cshtml", section);
}

Okay, so what are we seeing? First, I create a new Section object since I'm going to need it for the EditorTemplate to render. I added a second new property IsNew but I don't actually do anything with it at the moment. I just wanted a convenient way of seeing what's been added and deleted in my POST method coming up.

I add that new Section to my data store (FakedData). You could instead track the number of new requests in another way - just be sure it increments each time you click the Add Section link.

Now for the trick. Since we're returning a partial view it doesn't have the context of parent model. If we just return the template with only the section passed in, it won't know it's part of a larger collection and won't generate the names appropriately. So we tell it where we are with the HtmlFieldPrefix field. I use my data store to keep track of the proper index but again, this could come from anywhere and if you added the IsNew property, you'll be able to add the new (and not-deleted) records to your store on submit instead.

Like I said, the framework has a little trouble finding the template just based on the name so we have to give the path to it in order for it to return properly. Not a big deal overall but it is a bit annoying.

Now I added a POST handler for the Test view and I am reliably getting a count of items deleted, added and total count. Just remember that rows could be New and Deleted!

[HttpPost]
public ActionResult Test(FormData form)
{
    var sectionCount = form.Sections.Count();
    var deletedCount = form.Sections.Count(i => i.IsDeleted);
    var newItemCount = form.Sections.Count(i => i.IsNew);

    form.Sections = form.Sections.Where(s => !s.IsDeleted).ToList();
    FakedData = form;
    return RedirectToAction("Test");
}

And that's it. We have a complete end-to-end of rendering out your collection with proper indexes, "deleting" rows, adding new rows and we didn't have to hack the model binder, manipulate the names or resort to JavaScript hacks to re-number our elements on submit.

I'm eager for feedback on this. If I don't see a better answer, this might be the route I always use when doing stuff like this in the future.


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
OGeek|极客中国-欢迎来到极客的世界,一个免费开放的程序员编程交流平台!开放,进步,分享!让技术改变生活,让极客改变未来! Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...