Creating Dynamic Forms with MVC and jQuery
About six months ago Phil Haack wrote a post on how to use the DefaultModelBinder to bind a form to a list. He concluded by asking how this functionality would be used. In this post I'm going to show how a dynamic form that uses the model binder’s ability to work with lists can be created using MVC and jQuery. The example I’m going to use was inspired by an application I'm working on that provides a web interface to the bug tracking system we used during the development of MVC. The application is not intended to become an internal tool, but rather to explore the capabilities of MVC and hopefully identify areas that can be improved in future releases. It also afforded me a chance to explore jQuery.
Background
The system we use for tracking bugs in MVC contains over 3000 databases spanning multiple products and product families. Apart from using the system to create and resolve bugs it allows users to create and save queries that are executed against a specific databases. For example, I could create a query to find all the work items that were assigned to me for RC1 of MVC. The interface provided by the application to accomplish this is fairly simple as shown below. jQuery and MVC provided all the necessary tools to design a web application that could mimic this behavior.
Sample Application
I’ve made the source code of the sample application available for download instead of posting everything here. Instead I’m just going to highlight some aspects of the application. The design can definitely be improved.
-
The JavaScript is embedded within the Create view. This avoids script code from being cached by the development server in Visual Studio (I got tired of remembering to hit Ctrl+F5 every time I launched the application when I modified the JavaScript). The downside of this is that it complicates script debugging in Visual Studio.
-
The only validation being performed by the controller is to check whether the form is empty (no rows were inserted by the user) and that any rows that are present have a value inside the corresponding textbox.
QueryController
Once the page for the Create view has completed loading it immediately makes a request to the controller to retrieve a list of all the field definitions. The definitions are stored on the client side to avoid going to the server every time a new row is added to the form.
1: var fieldDefinitions = null;
2:
3: $(document).ready(function() {
4: // Retrieve all the field definitions once the page is loaded.
5: $.getJSON("/Query/Fields", null, function(data) {
6: fieldDefinitions = data;
7: });
8: });
The Fields action simply returns a list of definitions that have been created when the application starts. In a real application one might need to retrieve this data from a file, a web service or a database. The definitions are then serialized and consumed on the client when a new row is inserted into the form. If you go through the source code you’ll notice that the field definitions are stored inside a dictionary, hence the reference to the Values property in the code below. Using a dictionary makes accessing a field definition on the server easier during validation.
1: public ActionResult Fields() {
2: return Json(FieldDefinitions.Fields.Values);
3: }
The Create action takes two parameters. The first is a string that contains a comma separated list of field names that appear in the query and will be used during validation when the form needs to be generated again in case there were errors. The second is a list of fields with each entry representing a single row on the form that was submitted. The action doesn’t do much right now. It performs some basic validation and if successful, creates a string representation of the query the user created that is echoed back in the Results view.
1: public ActionResult Create() {
2: return View();
3: }
4:
5: [AcceptVerbs(HttpVerbs.Post)]
6: public ActionResult Create(string queryFields, IList<Field> query) {
7: if (!ValidateQuery(query)) {
8: ViewData["queryFields"] = queryFields;
9: return View();
10: }
11:
12: StringBuilder queryString = new StringBuilder();
13:
14: foreach (Field field in query) {
15: queryString.AppendFormat("{0} {1} {2} {3}", field.AttachWith, field.Name, field.Operator, field.Value);
16: queryString.AppendLine();
17: }
18:
19: ViewData["queryString"] = queryString.ToString();
20:
21: return View("Results");
22: }
The Field model used to represent a single query row is very simple.
1: public class Field {
2: public string AttachWith {
3: get;
4: set;
5: }
6:
7: public string Name {
8: get;
9: set;
10: }
11:
12: public string Operator {
13: get;
14: set;
15: }
16:
17: public string Value {
18: get;
19: set;
20: }
21: }
Query View
Initially the user is confronted with a simple form that only contains two buttons; one to add a new row and another to submit the form.
I’ve used a table for the form layout since each row contains exactly the same elements and this makes it a bit easier to keep the form organized.
1: <form action="/Query/Create" method="post"><input id="queryFields" name="queryFields" type="hidden" value="" />
2: <table id="queryTable">
3: <thead>
4: <tr>
5: <th>Attach With</th>
6: <th>Field</th>
7: <th>Operator</th>
8: <th>Value</th>
9: </tr>
10: </thead>
11: <tbody>
12: </tbody>
13: </table>
14: <p>
15: <input type="button" value="Add Field" onclick="addQueryField()" />
16: <input type="submit" value="Submit Query" onclick="updateQueryFields()" />
17: </p>
18: </form>
When a user clicks on the Add Field button the addQueryField function will insert a new row into the table. The row contains three dropdown lists, a text field and a button to remove the row from the form.
Since the form will be bound to an IList<Field> we need to ensure that the indices generated for the name attribute in the various HTML elements remain sequential. If we end up with non-sequential indices then the form fields will not be bound properly to our model. The HTML for the newly added row in the example above will look like this:
Determining the value of the index used by the various name attributes is quite easy using jQuery’s selectors as shown below on line 3.
1: function addQueryField() {
2: // Determine the index of the next row to insert
3: var index = $("tr[id^=queryRow]").size();
4: // Create DOM element for table row
5: var oTr = $(document.createElement("tr")).attr("id", "queryRow" + index);
6: // Create DOM element for value textbox
7: var oValueTextBox = $(document.createElement("input")).attr("name", "query[" + index + "].Value").attr("id", "Value"+index).attr("type", "text");
8: // Create DOM element for Name select list
9: var oSelectListName = createSelectListForName(index);
10: // Create DOM element for Remove button to delete the row from the table
11: var oButtonRemove = $(document.createElement("input")).attr("type", "button").attr("value", "Remove").attr("id", "Remove"+index).click(function() {
12: removeRow(index);
13: });
14: // Create <td> elements
15: oTr.append($(document.createElement("td")).append(createSelectListForAttachWith(index)));
16: oTr.append($(document.createElement("td")).append(oSelectListName));
17: oTr.append($(document.createElement("td")).append(createSelectListForOperator(oSelectListName.val(), index)));
18: oTr.append($(document.createElement("td")).append(oValueTextBox).append(oButtonRemove));
19: // Insert the row into the table
20: $("#queryTable").append(oTr);
21: }
On line 12 we bind the removeRow function to the onclick event of the the Remove button. This function is responsible for two tasks:
-
It needs to remove the row from the table.
-
It needs to update the remaining rows to keep the indices sequential.
Updating the rows is not a difficult task, but the syntax required by the DefaultModelBinder to specify a property and index for the model conflicts with the syntax used by the selectors in jQuery. The ‘[‘ and ‘]’ characters are used to specify attribute values inside a selector. To work around this the id attributes of the elements that are inserted into the DOM only contains alphanumerical characters. This allows the removeRow function to select and update each row using jQuery selectors. On lines 8, 9, 13, and 14 we need to unbind our functions before rebinding them since their index parameters have changed. Without doing the .unbind() jQuery will just chain the event handlers and you’ll end up with some really funny behavior.
1: function removeRow(index) {
2: // Delete the row
3: $("#queryRow" + index).remove();
4: // Search through the table and update all the remaining rows so that indices remain sequential
5: $("tr[id^=queryRow]").each(function(i) {
6: $(this).attr("id", "queryRow" + i);
7: $("td select[id^=AttachWith]", $(this)).attr("name", "query[" + i + "].AttachWith").attr("id", "AttachWith" + i);
8: $("td select[id^=Name]", $(this)).attr("name", "query[" + i + "].Name").attr("id", "Name" + i).unbind("change").change(function() {
9: updateOperator(i);
10: });
11: $("td select[id^=Operator]", $(this)).attr("name", "query[" + i + "].Operator").attr("id", "Operator" + i);
12: $("td input[id^=Value]", $(this)).attr("name", "query[" + i + "].Value").attr("id", "Value" + i);
13: $("td input[id^=Remove]", $(this)).attr("id", "Remove" + i).unbind("click").click(function() {
14: removeRow(i);
15: });
16: });
17: }
Validation
Performing client side validation on a dynamic form makes perfect sense and jQuery provides the necessary tools to do this. Depending on how your application works it’s reasonable to expect that some elements can only be validated on the server. The only problem that needs to be solved is displaying all the original fields that the user added when redirecting back to the form. To solve this, I included a hidden input named queryFields. When the user hits the submit button it executes a function to update the hidden input with a comma separated list of fields. When validation fails the string is placed into ViewData and the form the user submitted can be generated using the HTML helpers.
1: <%1:
2: string queryFields = ViewData["queryFields"] as string;3: if (!String.IsNullOrEmpty(queryFields)) {4: int i = 0;5: foreach (string field in queryFields.Split(new[] { ',' })) {6: string queryPrefix = "query["+Convert.ToString(i)+"].";7: string attachWithName = queryPrefix + "AttachWith";8: string fieldName = queryPrefix + "Name";9: string operatorName = queryPrefix + "Operator";10: string valueName = queryPrefix+"Value";11: string trId = "queryRow"+Convert.ToString(i);%>
2:
3: <tr id="<% =trId %>">
4: <td><%1: =Html.DropDownList(attachWithName, FieldDefinitions.AttachWith, new { id = "AttachWith" + Convert.ToString(i) })%></td>
5: <td><%1: =Html.DropDownList(fieldName, FieldDefinitions.FieldNames, new { id = "Name" + Convert.ToString(i), onchange="updateOperator("+Convert.ToString(i)+")"})%></td>
6: <td><%1: =Html.DropDownList(operatorName, FieldDefinitions.Fields[field].Operators, new { id = "Operator" + Convert.ToString(i) })%></td>
7: <td>
8: <%1: =Html.TextBox(valueName, null, new { id = "Value" + Convert.ToString(i) })%>
9: <input type="button" value="Remove" onclick="removeRow(<% =Convert.ToString(i) %>)" />
10: <% 1: =Html.ValidationMessage(valueName)
%>
11: </td>
12: </tr>
13: i++;
14: }
15: } %>
Once the system passes validation you should see a screen that echoes the query back to you. Consider the following query:
Hitting the Submit Query button will produce the result below.
IE8 Quirks
While working on the application I mentioned at the beginning of this post I discovered a small bug in the developer tools of IE8 (Open IE8 and hit F12 to open the tools). When creating a new element in the DOM and setting its name attribute the toolbar will display the attribute as propdescname instead of name. The DOM is still correct though and the problem does not occur if you specified the name attribute explicitly in the HTML.
Conclusion
When I wrote the first version of my application I avoided using jQuery. The result of that was that my code only worked in IE. Getting it to work in Safari and FireFox took another day. Now I understand why, when talking to JavaScript developers, you sometimes see little drops of blood welling up in their eyes. jQuery on the other hand takes care of all the browser compatibility issues. Apart from this using jQuery made the code much easier to maintain and modify. I’m going to attribute that to two things: selectors and chaining. If you have any suggestions on how to improve the JavaScript in the example I’ve given I’d love to hear from you.