I've done this in the past, and i may have to do it again, but before i do, i want to throw it out there to see how people handle it.
Razor view:
<ul>
@Html.EditorFor(model => model.Questions)
</ul>
Which could produce:
<ul>
<li><input type="text" id="Questions_0__Title" name="Questions[0].Title" value="hi"/></li>
<li><input type="text" id="Questions_1__Title" name="Questions[1].Title" value="hi2"/></li>
<ul>
Pretty dead simple.
Now, i need to allow the user to add, edit or remove any of those "Questions".
For Edit, it's easy - no work req'd. But for add/delete, if i were to do it with jQuery, i would need to be smart about the way i generate the "id" and "name" attributes (e.g get a count of how many is there, add 1, etc), and for delete i would to "re-render" some elements (for example, if one was deleted, i would need to change the "id" and "name" attributes to be -1 for all following elements).
All of this is because it's a form, and it needs to be model bound.
That to me is too hairy, so in the past, i have done an AJAX call to the server with whatever is on the form, then add/remove elements in the controller, and render a partial view. This way all the elements are "ready to be model bound". I still need a little but of jQuery, but nowhere near as much.
Has anyone found a better way?
I've had to deal with exactly that same situation as well. The easiest solution I came up with, was using a wrapper model, similar to:
public class QuestionListModel
{
public IList<QuestionModel> Questions { get; set; }
public IList<QuestionModel> Template
{
get
{
return new List<QuestionModel>
{
new QuestionModel {/* defaults for new question */}
};
}
}
public QuestionListModel()
{
Questions = new List<QuestionModel>();
}
}
The important part, is having the Template property, which is also a an IEnumerable<T>
of the same type as the actual model you want. That way, the auto-numbering will be done for you by MVC. So using this model, what you get is, your collection, with "Questions_0__Title" numbering, and you also get one template row with "Template_0__Title" naming.
I keep my template row hidden from the UI and use it when adding a new row.
In razor, you'd bind the template just as you'd bind a regular question, the only difference is it would be in a hidden <div>
. You also would need to bind the Count
of your Questions list to a hidden field as well. In my case, I have an editor template for a Question, which renders it inside <div>
with a specific selector, for easy access later on.
So what you end up with is something similar to:
<div class="templateContainer">
<div class="question">
[Template]
</div>
</div>
<div class="items">
[for each of your items]
<div class="question">
[Question]
</div>
</div>
When adding a row, the trick is, using javascript:
Get the count from the hidden field, increment it.
var counter = $("#QuestionsListCount");
var count = parseInt(counter.val());
count++;
Take the entire template block, clone it (using jquery's .clone(true)
for example), name it as something unique (using the counter value from step 1 for example), and append it to the section where your questions are.
var template = $("#templateContainer");
var newItem = template.clone(true);
var newId = "item_" + count;
var newQuestion = newItem.children().first();
newQuestion.attr("id", newId);
newQuestion.appendTo('#items');
For each item, such as inputs in your new appended block (you can find it with the new id you assigned to it), you replace ids => "Template_0" with "Questions__count from step 2", and names => "Template[0]" with "Questions[count from step 2]".
$("#" + newId + " :input").each(function (index, input) {
input.id = input.id.replace("Template_0", "Questions_" + (count - 1));
input.name = input.name.replace("Template[0]", "Questions[" + (count - 1) + "]");
});
Update the hidden field for the counter=> counter.val(count);
- ...
- profit!
Now, regarding deletions, the way I do it, is, my ViewModel for it actually has an IsDeleted
flag, which i bind to a hidden field within the Question editor template as well.
That way, deletions are as simple as hiding the particular Question (the question selector comes handy) and you set that IsDeleted
field to true.
When you get your entire list back through the default model binder, you need to discard all deleted ones (or issue actual deletes, depending on your back-end data model).
This way I avoid having to deal with individual ways of identifying deleted items, and also renumbering all the items in the UI. Plus, you get the advantage of having server-side code determine what should actually happen when deleting (such as validating or undoing the action).
It is a long post, but the implementation is not really that difficult (or long), and can easily be reused in a generic way.
Cheers!
I'm assuming that these things have a primary key Id field?
Not sure it is the best way, but I have a hidden input field called, for example, deletedIds. While rendering the input element I add an attribute like:
data-rowId='@Model.Id'
And then when they click delete I add the id to the hidden list OR I mark the row as deleted with a css class and scoop them up at the end, before submit, for example:
var deletedIds = [];
$('myselector').each(function(){ deletedIds.push($(this).attr('data-rowId'));});
$('deletedIds').val(deletedIds.join(','));
Then split the string back to an array on the server.
If you are dealing with them positionally and you are sure that the sequence on the server cannot change between requests, then I would use the same approach but using the index of the array rather than the id.
So i thought of a better solution, that requires not much JS at all.
- In my controller, i create a model with around 100 elements. All empty, but "newed" up, and with a css class of "hidden"
- Based on how many existing values are filled in, i update the elements, including removing the "hidden" css class.
- I then use CSS to hide the ones with the "hidden" css class.
- When i click "Add More", i remove the "hidden" class from the first 3 elements.
- When i click "Delete", i simply add the "hidden" class back in.
Easy, why didn't i think of this before.