Reputation: 13745
I have a static generic FormBuilder HTML helper method (extension method on the HTMLHelper class) that takes a generic argument of the view model type and then, when passed one or more string property names from the database, generates a HTML form in ASP.NET MVC 5.1 with .NET 4.5.
I have a single public method to generate the form, and separate private methods to generate the "module" sections within the form and then render each field within those. Type arguments are passed down this chain from top to bottom.
In the "RenderField" method I create a typed HtmlHelper using the code-
var typedHelper = helper as HtmlHelper<TModel>;
Where helper is the HtmlHelper extended in the RenderForm method.
I then create an expression using the code-
var modelType = typeof(TModel);
...
var modelProperty = modelType.GetProperty(field.PropertyName);
if (modelProperty == null)
{
Elmah.ErrorSignal.FromCurrentContext().Raise(new ArgumentException(string.Format("Model {0} does not contain property {1}", modelType.Name, field.PropertyName)));
return null;
}
var modelPropertyType = modelProperty.PropertyType;
var parameter = Expression.Parameter(modelType, "m");
var property = Expression.Property(parameter, field.PropertyName);
var expression = Expression.Lambda<Func<TModel, object>>(property, parameter);
This I can then later use to create an EditorFor, DisplayFor, or ValidationMessageFor as follows-
fieldContainer.InnerHtml += typedHelper.EditorFor(expression);
fieldContainer.InnerHtml += typedHelper.ValidationMessageFor(expression);
This works for string editors, but if I try a nullable datetime then I get the error-
Expression of type 'System.Nullable`1[System.DateTime]' cannot be used for return type 'System.Object'
If I try and convert the property to an object as I've seen in a Jon Skeet answer by changing the following line-
var expression = Expression.Lambda<Func<TModel, object>>(Expression.Convert(property, typeof(object)), parameter);
And change the editors code to -
var compiledExpression = expression.Compile()(model);
fieldContainer.InnerHtml += typedHelper.EditorFor(compiledExpression);
fieldContainer.InnerHtml += typedHelper.ValidationMessageFor(compiledExpression);
I get the error message that the type arguments cannot be inferred from usage.
If I change the return type to "dynamic" then it states "Extension Methods cannot be dynamically dispatched".
I cannot specify "modelPropertyType" as the return argument for the generic method - presumably as it is not guaranteed to be a concrete type at compile time.
Is there any way I can dynamically specify the return type of the expression to be that of the property at run time so I can use the EditorFor helper methods provided by ASP.NET MVC?
Upvotes: 2
Views: 1919
Reputation: 34992
Let's say you create a helper function with generic arguments for TModel
and TProperty
, where you can build the html for editing a single model property. This helper will receive an instance of the HtmlHelper<TModel>
and a PropertyInfo
and it will create the proper lambda expression that the likes of EditorFor
require. The helper may look like this:
private static MvcHtmlString GetPropertyEditor<TModel, TProperty>(HtmlHelper<TModel> htmlHelper, PropertyInfo propertyInfo)
{
//Get property lambda expression like "m => m.Property"
var modelType = typeof(TModel);
var parameter = Expression.Parameter(modelType, "m");
var property = Expression.Property(parameter, propertyInfo.Name);
var propertyExpression = Expression.Lambda<Func<TModel, TProperty>>(property, parameter);
//Get html string with label, editor and validation message
var editorContainer = new TagBuilder("div");
editorContainer.AddCssClass("editor-container");
editorContainer.InnerHtml += htmlHelper.LabelFor(propertyExpression);
editorContainer.InnerHtml += htmlHelper.EditorFor(propertyExpression);
editorContainer.InnerHtml += htmlHelper.ValidationMessageFor(propertyExpression);
return new MvcHtmlString(editorContainer.ToString());
}
That helper will generate a container div <div class="editor-container"></div>
, and its inner html will contain the label, an editor and the validation message.
As you can see, the helper still requires you to provide the generic type of the property TProperty
, which you won't have as you are using reflection to loop through each of the properties. However you can also use reflection to call this helper for each property:
foreach (var propertyInfo in modelType.GetProperties())
{
var openMethod = typeof(HtmlExtensions).GetMethod("GetPropertyEditor", BindingFlags.Static | BindingFlags.NonPublic);
var genericMethod = openMethod.MakeGenericMethod(modelType, propertyInfo.PropertyType);
var editorHtml = genericMethod.Invoke(null, new object[] { htmlHelper, propertyInfo });
//add editorHtml to the form
}
So you can create your own HtmlHelper extension method that generates a form for a given model:
public static MvcHtmlString RenderForm<TModel>(this HtmlHelper<TModel> htmlHelper)
{
var modelType = typeof(TModel);
var form = new TagBuilder("form");
foreach (var propertyInfo in modelType.GetProperties())
{
//call generic GetPropertyEditor<TModel, TProperty> with the type of this property
var openMethod = typeof(HtmlExtensions).GetMethod("GetPropertyEditor", BindingFlags.Static | BindingFlags.NonPublic);
var genericMethod = openMethod.MakeGenericMethod(modelType, propertyInfo.PropertyType);
var editorHtml = genericMethod.Invoke(null, new object[] { htmlHelper, propertyInfo });
//add the html to the form
form.InnerHtml += editorHtml;
}
return new MvcHtmlString(form.ToString());
}
Given a model like the following:
public class RegisterViewModel
{
[Required]
[Display(Name = "User name")]
public string UserName { get; set; }
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[System.ComponentModel.DataAnnotations.Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
public DateTime? RegisterDate { get; set; }
}
You can use it in the view like this:
@Html.RenderForm()
And it will generate the following html:
<form>
<div class="editor-container">
<label for="UserName">User name</label>
<input class="text-box single-line" data-val="true" data-val-required="The User name field is required." id="UserName" name="UserName" type="text" value="" /><span class="field-validation-valid" data-valmsg-for="UserName" data-valmsg-replace="true"></span>
</div>
<div class="editor-container">
<label for="Password">Password</label>
<input class="text-box single-line password" data-val="true" data-val-length="The Password must be at least 6 characters long." data-val-length-max="100" data-val-length-min="6" data-val-required="The Password field is required." id="Password" name="Password" type="password" value="" /><span class="field-validation-valid" data-valmsg-for="Password" data-valmsg-replace="true"></span>
</div>
<div class="editor-container">
<label for="ConfirmPassword">Confirm password</label>
<input class="text-box single-line password" data-val="true" data-val-equalto="'Confirm password' and 'Password' do not match." data-val-equalto-other="*.Password" id="ConfirmPassword" name="ConfirmPassword" type="password" value="" /><span class="field-validation-valid" data-valmsg-for="ConfirmPassword" data-valmsg-replace="true"></span>
</div>
<div class="editor-container">
<label for="RegisterDate">RegisterDate</label>
<input class="text-box single-line" data-val="true" data-val-date="The field RegisterDate must be a date." id="RegisterDate" name="RegisterDate" type="datetime" value="" /><span class="field-validation-valid" data-valmsg-for="RegisterDate" data-valmsg-replace="true"></span>
</div>
</form>
You will have different requirements for the structure of the generated html, class names, attributes etc, but I hope this may help you completing the FormBuilder you were writing!
Upvotes: 3