Dynamic Forms in C# MVC
Something that I haven’t found well documented is how to generate dynamic form content in C# MVC without using another tool or framework like Angular, Knockout, etc. It is entirely possible to generate dynamic form content in C# MVC without the use of third party tools (other than JQuery!).
View Models
Our example contains two view models that correspond to the parent form and a collection of form sections:
The ComplicatedFormViewModel
represents the parent form, with some metadata
properties (e.g. Name
) and a collection of form sections, FormSections
:
public class ComplicatedFormViewModel
{
public ComplicatedFormViewModel()
{
FormSections = new List<FormSection>();
}
public string Name { get; set; }
public string Description { get; set; }
public IEnumerable<FormSection> FormSections { get; set; }
}
The FormSection
view model represents a smaller subsection of the parent form:
public class FormSection
{
public string Name { get; set; }
public string Description { get; set; }
}
Form Binding Conventions
From what I can tell, the default model binder leverages the name
attribute
of HTML elements in order to bind the element’s data to a view model property.
For example, the following HTML element is bound as the Description
property of the
ComplicatedFormViewModel
when posted to a controller method:
<input class="text-box" name="Description" type="text" value="">
In the case of nested complex objects, the name
attribute value is delimited
nested property names. Take for example this nested complex object structure:
public class Parent
{
public Child FirstChild { get; set; }
}
public class Child
{
public GrandChild AnotherChild { get; set; }
}
public class GrandChild
{
public string Description { get; set; }
}
When posted to a controller method that accepts a Parent
model as its parameter,
the HTML element corresponding to the GrandChild.Description
property would
be named as follows:
<input class="text-box" name="FirstChild.AnotherChild.Description" type="text" value="">
The built-in HtmlHelper
EditorFor
methods take care of this naming
by convention for you when you use them. However, if you call an EditorFor
on
a nested property without the context of its parent(s), the name
attribute will
just be the name of the property, and will not be prefixed by the parent(s).
However, when you use an EditorFor
with the proper parent context, the value
of ViewData.TemplateInfo.HtmlFieldPrefix
is where the name
attribute
comes from. In our case here, its value is FirstChild.AnotherChild.Description
.
We need to set the value of ViewData.TemplateInfo.HtmlFieldPrefix
when rendering
the view in order to have the subsection bind correctly via the default model binder.
Controller Methods
Our example controller contains three methods. The Index
method displays
an editable ComplicatedFormViewModel
, which is saved in the Create
method. The GetBlankFormSection
method is used to help generate our
dynamic form content and set the ViewData.TemplateInfo.HtmlFieldPrefix
value.
public class DynamicFormController : Controller
{
[HttpGet]
public ActionResult Index()
{
var vm = new ComplicatedFormViewModel();
return View(vm);
}
[HttpPost]
public ActionResult Create(ComplicatedFormViewModel postedModel)
{
// todo: save data, etc...
return View("Index", postedModel);
}
[HttpGet]
public ActionResult GetBlankFormSection(string prefix, int index)
{
var vm = new FormSection();
ViewData.TemplateInfo.HtmlFieldPrefix = prefix + "[" + index + "]";
return PartialView(
"~/Views/DynamicForm/EditorTemplates/FormSection.cshtml",
vm);
}
}
Views
Our example has two views configured for filling out the form. One main view displays
the ComplicatedFormViewModel
, and we have an editor template for the
FormSection
properties.
Main Form
The main form is displayed by the Index
method, where we have a button that
adds a new form section to the bottom of our existing form sections. The button click
is handled in JavaScript.
@model ViewModels.DynamicForms.ComplicatedFormViewModel
<h2>Index</h2>
@using (Html.BeginForm("Create", "DynamicForm", FormMethod.Post))
{
<div class="container">
<div class="row">
<div class="col">
@Html.LabelFor(mod => mod.Name)
@Html.EditorFor(mod => mod.Name)
</div>
</div>
<div class="row">
<div class="col">
@Html.LabelFor(mod => mod.Description)
@Html.EditorFor(mod => mod.Description)
</div>
</div>
<div class="container">
<div class="row">
<div class="col">
<input type="button"
id="addFormSectionBtn"
data-prefix="@nameof(Model.FormSections)"
value="Add Form Section"
class="btn btn-sm btn-primary"/>
</div>
</div>
<div class="container" id="formSectionTarget">
@if (Model.FormSections.Any())
{
@Html.EditorFor(mod => mod.FormSections)
}
else
{
<div class="row noFormSectionsDisplay">
<div class="col">
No Form Sections Yet Configured
</div>
</div>
}
</div>
</div>
<div class="row">
<div class="col">
<input type="submit" class="btn btn-primary" value="Submit" />
</div>
</div>
</div>
}
Editor Template
The editor template for the FormSection
is located in the
Views\DynamicForm\EditorTemplates
folder.
@model ViewModels.DynamicForms.FormSection
<div class="container formSection">
<div class="row">
<div class="col-md-2">
@Html.LabelFor(mod => mod.Name)
</div>
<div class="col-md-10">
@Html.EditorFor(mod => mod.Name)
</div>
</div>
<div class="row">
<div class="col-md-2">
@Html.LabelFor(mod => mod.Description)
</div>
<div class="col-md-10">
@Html.EditorFor(mod => mod.Description)
</div>
</div>
</div>
JavaScript
Our JavaScript button handler looks at the data attributes for the button itself, counts the number of existing form sections, calls into the server to get a new partial view, and then appends that partial view to the list of existing form sections.
$('#addFormSectionBtn').on('click', function(e){
e.preventDefault();
var numberExistingFormSections = $('.formSection').length;
var modelPrefix = $(this).data('prefix');
$.ajax({
url: '/DynamicForm/GetBlankFormSection',
type: 'GET',
data: {
prefix: modelPrefix,
index: numberExistingFormSections
},
success: function (result) {
$('.noFormSectionsDisplay').hide();
$('#formSectionTarget').append(result);
}
});
});
Rendered HTML
The result of our call to get the FormSection
partial is as follows:
<div class="container formSection">
<div class="row">
<div class="col-md-2">
<label for="FormSections_0__Name">Name</label>
</div>
<div class="col-md-10">
<input class="text-box single-line" id="FormSections_0__Name" name="FormSections[0].Name" type="text" value="">
</div>
</div>
<div class="row">
<div class="col-md-2">
<label for="FormSections_0__Description">Description</label>
</div>
<div class="col-md-10">
<input class="text-box single-line" id="FormSections_0__Description" name="FormSections[0].Description" type="text" value="">
</div>
</div>
</div>
We have the name
now properly constructed to use the prefix FormSections
,
and we update the index based on the number of already existing .formSection
divs.
This will now all post properly with the rest of the form data when submitted
to the Create
method, automatically bound to a ComplicatedFormViewModel
object.