views:

330

answers:

2

I have a form with various inputs. I have a bunch of optional parameters that have some number of choices. I'd like to allow the user to select these optional parameters in the following way:

First, the user clicks the Add Component button at the bottom of the form and two new dropdowns appear above the button. The first dropdown has a list of Types that can be selected and the second one will be disabled. When the user selects a valid choice in the first dropdown, I want to populate the second dropdown with some Values that are specific to the specified Type. The user should be able to continue adding new Components (the pair of dropdowns) until all the desired optional Components are added. Ideally the form wouldn't be posted until all the fields have been filled out and the desired Components added.

My question is this: How do I design this so that when the form is submitted and there are errors, that the dynamically added fields (the Components) will remain on the page and display the correct values?

I was planning on having the Add Component button be an Ajax.ActionLink that retrieves a partialview:

<div id="divComponentHolder"></div>
<%= Ajax.ActionLink("Add a Component", "GetComponentSelector", new AjaxOptions { UpdateTargetId = "divComponentHolder", InsertionMode = InsertionMode.InsertAfter}) %>

This partial view would look something like this:

   <%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<MVCAndWebFormsTest.Models.ComponentSelectorModel>" %>
   <%= Html.Encode("Type:")%>
   <%= Html.DropDownList("ComponentType", Model.ComponentTypes, "", new {onchange = "updateCompValues(this);"}) %>
   <%= Html.Encode("File/Folder:")%>
   <div id="selectdiv">
       <% Html.RenderPartial("ComponentValueSelector", Model.ComponentValues); %>
   </div> 
   <br/>
   <script type="text/javascript" language="javascript">
        function updateCompValues(obj) {
            $.ajax({
                url: <% Url.Action("GetCompValues") %>,
                async: true,
                type: 'POST',
                data: { type: obj.value },
                dataType: 'text',
                success: function(data) { $("#selectdiv").html(data); },
                error: function() {
                    console.log('Erreur');
                }
            });
        }
   </script>

And the ComponentValueSelector partial would be pretty simple:

    <%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<MVCAndWebFormsTest.Controllers.ViewModels.ComponentValueModel>" %>
    <%= Html.DropDownList("CompValue", Model.SelectList) %>
+1  A: 

Take a look at submitting list in MVC, here are a few useful sites:

This is useful for submitting your dynamic DOM you are building up.

Another way instead of making an ajax call to render a partial view you could always directly add elements to the DOM with jquery. For example use the jquery clone ( $('element').clone(); ) method that would copy your list boxes then do some regex to change the id's of the input boxes so they have unique id/names.

As you are passing through a List of these 'choices' to your controller, you would then have to set them back in your Model and have your View iterate through them to display the correct amount of choices added.

Here is a bare bones example. This may not be the best implementation for yourself or someone else may have better ideas.

View

<% for (int i = 0; i < in Model.Results.Count; i++) { %>
    //render better HTML but you should get the point!
    <%= Html.Hidden("choices[" + i + "].ID", i) %>
    <%= Html.DropDownList("choices[" + i + "].Choice1", ...) %>
    <%= Html.DropDownList("choices[" + i + "].Choice2", ...) %>
<% } %>

- add button

jQuery

$('#addButton').click(function()
{
    //say if your choice drop downs were in a table then take the last
    //row and clone it
    var row = $('table tr:last').clone(true);
    var newId = //work out the new id from how many rows in the table

    //make sure to update the id and name parameters of inputs 
    //of the cloned row
    row.find(':input')
    .attr('id', function()
    {
       return $(this).attr('id').replace(/\[[\d+]\]/g, '[' + newlId + ']');
       //this replaces the cloned [id] with a new id
    })
    .attr('name', function()
    {
       return $(this).attr('name').replace(/\[[\d+]\]/g, '[' + newId + ']');
    });

    row.find(':hidden').val(newId); //update the value of the hidden input

    //alert(row.html()); //debug to check your cloned html is correct!
    //TODO: setup ajax call for 1st drop down list to render 2nd drop down

    $('table tr:last').after(row);//add the row

    return false;
});

Controller

public ActionResult YourMethod(IList<YourObject> choices, any other parameters)
{
   YourViewModel model = new YourViewModel();
   model.Results = choices; //where Results is IList<YourObject>

   return View(model);
}
David Liddle
Great links, Thank you. [Regarding your last paragraph] What do you mean when you say "set them back in your Model"? It sounds like I need to add a List<Component> to my View's model, add a List<Component> parameter to my Action method, and follow the naming convention (from the links) in my partial views, that the DefaultModelBinder will bind the value of the dropdowns to the List?
sdr
I've updated my answer to include a better example. I actually extended this further so that my validation would write "[1] invalid date" or "[4] invalid selection" in the Validation Summary based on how many rows were in my table. I can also show you that if you need.
David Liddle
This is great. Based on the information you gave me I was able to create a solution with a slightly different angle (I'll post it as another answer). I've learned a lot about model binding from this discussion. Thanks for all your help.
sdr
A: 

Based on advice from David Liddle, I found a different design that was a bit more elegant. It uses more jQuery and fewer partial views and Ajax requests.

Instead of adding a bunch of DropDownLists, I decided to go with a table, a pair of dropdowns and an "Add" button. When the user selects a Type option in the first dropdown, ajax is still used to retrieve the partial view for populating the second Value dropdown. Once a Value option has been selected, the user then clicks the Add button.

Using jQuery, two hidden inputs are added to the page. The naming convention in the links from David are used to name these elements (comps[0].Type and comps[0].Value). Also, a new row is added to the table with the same Type and Value for visual feedback to the user showing what has been added.

I also defined a Component class that just has Type and Value properties and added a List to the Model. In the view, I iterate over this list and add all components in the Model to the table and as hidden inputs.

IndexView

<table id="componentTable">
    <tr>
        <th>Type</th>
        <th>Deploy From</th>
    </tr>
    <% foreach (Component comp in Model.comps) { %>
        <tr>
            <td><%= Html.Encode(comp.Type) %></td>
            <td><%= Html.Encode(comp.Value) %></td>
        </tr> 
    <% } %>
</table>

<div id="hiddenComponentFields">
<% var index = 0;%>
<% foreach (Component comp in Model.comps) { %>
    <input type="hidden" name="comps[<%= Html.Encode(index) %>].Type" value="<%= Html.Encode(comp.Type) %>" />
    <input type="hidden" name="comps[<%= Html.Encode(index) %>].Value" value="<%= Html.Encode(comp.value) %>" />
    <% index++; %>
<% } %>
</div>

<%= Html.DropDownList("ComponentTypeDropDown", Model.ComponentTypes, "", new { onchange = "updateCompValues();"}) %>
<span id="CompValueContainer">
    <% Html.RenderPartial("ComponentValueSelector", new ComponentValueModel()); %>
</span>

<span class="button" id="addComponentButton" onclick="AddComponentButtonClicked()">Add the File</span>

<span id="componentStatus"></span>

ComponentValueSelector PartialView

<%@ Control Language="C#" Inherits="ViewUserControl<ComponentValueModel>" %>

<% if(Model.SelectList == null) { %>
    <select id="CompValue" name="CompValue" disabled="true">
        <option></option>
    </select>
<% } else { %>
    <%= Html.DropDownList("CompValue", Model.SelectList, "") %>
<% } %>

jQuery

function updateCompValues() {
    $.ajax({
        url: '<%= Url.Action("GetComponentValues") %>',
        async: true,
        type: 'POST',
        data: { type: $("#CompValue").value },
        dataType: 'text',
        success: function(data) { $("#CompValueContainer").html(data); enable($("#CompValue")) },
        error: function() {
            console.log('Erreur');
        }
    });
}

function AddComponentButtonClicked() {
    UpdateCompStatus("info", "Updating...");
    var type = $("#ComponentTypeDropDown").val();
    var value = $("#CompValue").val();
    if (type == "" || value == "") {  // No values selected
        UpdateCompStatus("warning", "* Please select both a type and a value");
        return;  // Don't add the component
    }
    AddComponent(type, value);
}

function AddComponent(type, setting_1) {
    // Add hidden fields
    var newIndex = GetLastCompsIndex() + 1;
    var toAdd = '<input type="hidden" name="comps[' + newIndex + '].Type" value="' + type + '" />' +
                '<input type="hidden" name="comps[' + newIndex + '].Setting_1" value="' + setting_1 + '" />';
    $("#hiddenComponentFields").append(toAdd);

    // Add to page
    // Note: there will always be one row of headers so the selector should always work.
    $('#componentTable tr:last').after('<tr><td>'+type+'</td><td>'+setting_1+'</td>remove</tr>');
}

function GetLastCompsIndex() {
    // TODO
    alert("GetLastCompsIndex unimplemented!");
    // haven't figured this out yet but something like 
    // $("#hiddenComponentFields input:hidden" :last).useRegExToExtractIndexFromName(); :)
}

function UpdateCompStatus(level, message) {
    var statusSpan = $("#componentStatus");
    // TODO Change the class to reflect level (warning, info, error?, success?)
    // statusSpan.addClassName(...)
    statusSpan.html(message);
}

Controller

public ActionResult Index() { 
    SelectList compTypes = repos.GetAllComponentTypesAsSelectList();
    return View(new IndexViewModel(compTypes));
}

[AcceptVerbs(HttpVerbs.Post)] public ActionResult Index(Component[] comps, other params...) { foreach(Component comp in comps) { // Do something with comp.Type and comp.Value } return RedirectToAction(...); }

public ActionResult GetComponentValues(string type) {
    ComponentValueModel valueModel = new ComponentValueModel();
    valueModel.SelectList = repos.GetAllComponentValuesForTypeAsSelectList(type);
    return PartialView("ComponentValueSelector", valueModel);
}

IndexViewModel

public class IndexViewModel {
    public List<Component> comps { get; set; }
    public SelectList ComponentTypes { get; set; }

    public IndexViewModel(SelectList types) {
        comps = new List<Component>();
        ComponentTypes = types;
    }
}
sdr