imaCoden
imaCoden

Reputation: 477

mvc3 Reflect data annotations off a concrete class from an inteface

I am trying to abstract my view model from various types of views. The entire thing compiles without a issue but I am having issues with "reflecting" (formally known as unboxing) the data annotations.

I have an interface:

public interface IPerson
{
    string FirstName { get;set;}
    string LastName {get;set;}
}

And I have two class which implement the interface as such:

public class Employee : IPerson
{
    [Required]
    [Display(Description = "Employee First Name", Name = "Employee First Name")]
    public string FirstName {get;set;}

    [Required]
    [Display(Description = "Employee Last Name", Name = "Employee Last Name")]
    public string LastName {get;set;}

    public int NumberOfYearsWithCompany {get;set;}
}

public class Client : IPerson
{
    [Required]
    [Display(Description = "Your first Name", Name = "Your first Name")]
    public string FirstName {get;set;}

    [Display(Description = "Your last Name", Name = "Your last Name")]
    public string LastName {get;set;}

    [Display(Description = "Company Name", Name = "What company do you work for?")]
    public string CompanyName {get;set;}
}

Person Edit View: views/Person/Edit as such:

@model IPerson

<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => model.FirstName)
    </div>
    <div class="editor-field">
        @Html.EditorFor(model => model.FirstName)
        @Html.ValidationMessageFor(model => model.FirstName)
    </div>
</div>
<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => model.LastName)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(model => model.LastName)
        @Html.ValidationMessageFor(model => model.LastName)
    </div>
</div>

Employee Edit View: views/Employee/Edit:

@model Employee

Html.RenderAction("Edit", "Person", new { person = Model });

<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => model.CompanyName)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(model => model.CompanyName)
        @Html.ValidationMessageFor(model => model.CompanyName)
    </div>
</div>    

where the PersonController is:

public ActionResult Edit(IPerson person)
{
    return PartialView(person);
}

Everything compiles and renders fine. However, the data annotations are being lost.

So, Employee/Edit is coming out like:

FirstName [textfield]

LastName [textfield]

What Company do you work for? [textfield] Company Name is a required field

Is there anyway of unboxing those data annotations for the concrete class?

Side note

I tried explicitly casting the IPerson to Employee as such:

@model IPerson
@{
    var employee = (Employee)Model;
}
<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => employee.FirstName)
    </div>
    <div class="editor-field">
        @Html.EditorFor(model => employee.FirstName)
        @Html.ValidationMessageFor(model => employee.FirstName)
    </div>
</div>
<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => employee.LastName)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(model => employee.LastName)
        @Html.ValidationMessageFor(model => employee.LastName)
    </div>
</div>

Doing this made the first name required but didn't take the display properties from the label.

Update After much discussion as to whether this is or is not unboxing, I have not yet found a simple solution of grabbing the data annotation from the (more basic) concrete class. It would really defeat the goal of simplicity to use reflection in view (or helper) to get at the data annotations of the concrete class.

We have a few views that essentially are the same but have slightly different required fields and display names. It would be REALLY convenient if I could just pass a view model into an interfaced view and it would figure out the required fields and display properties. If anyone has figured out a way to do this it would be greatly appreciated.

Upvotes: 3

Views: 1586

Answers (3)

Tim Pickin
Tim Pickin

Reputation: 419

I had the same problem with the TextBoxFor helper not generating the correct markup validation.

The way I was able to solve it was to use the TextBox helper instead of the TextBoxFor helper.

Here is the partial snippet which worked for me

    @model Interfaces.Models.EntryPage.ICustomerRegisterVM

    <p>
        @Html.ValidationMessageFor(model => model.Department)
        @Html.TextBox(Html.NameFor(model => model.Department).ToString(), Model.Department)
    </p>

As you can see I used the Html.NameFor helper to generate the correct name from the expression and then passed in the property. Using this approach MVC was able to successfully generate the correct unobtrusive validation markup for the concrete class which implemented the interface which is referenced as the viewmodel.

I have not tried this approach for LabelFor or other helpers. But I hope that the result would be the same.

Please note the Html.NameFor helper is available in MVC5

Upvotes: 2

Dan Pettersson
Dan Pettersson

Reputation: 763

I found this post struggling with the exact same issue.

(On a side note: I had already implemented my own DataAnnotationsModelMetadataProvider following this sample to be able to access custom attributes inside my editor templates: http://weblogs.asp.net/seanmcalinden/archive/2010/06/11/custom-asp-net-mvc-2-modelmetadataprovider-for-using-custom-view-model-attributes.aspx. Ensure to not miss the step in application start if you want to use your own DataAnnotationsModelMetadataProvider for this problem)

So after almost giving up on getting this to work I decided to debug my CreateMetadata override to see what I could get hold of there. I also found this post:

Obtain containing object instance from ModelMetadataProvider in ASP.NET MVC

This in combination with some reflection of the DataAnnotationsModelMetadataProvider class lead me to the following solution:

public class MyModelMetadataProvider : DataAnnotationsModelMetadataProvider
{
    protected override ModelMetadata CreateMetadata(
        IEnumerable<Attribute> attributes,
        Type containerType,
        Func<object> modelAccessor,
        Type modelType,
        string propertyName)
    {
        //If containerType is an interface, get the actual type and the attributes of the current property on that type.
        if (containerType != null && containerType.IsInterface)
        {
            object target = modelAccessor.Target;
            object container = target.GetType().GetField("container").GetValue(target);
            containerType = container.GetType();
            var propertyDescriptor = this.GetTypeDescriptor(containerType).GetProperties()[propertyName];
            attributes = this.FilterAttributes(containerType, propertyDescriptor, Enumerable.Cast<Attribute>((IEnumerable)propertyDescriptor.Attributes));
        }

        var modelMetadata = base.CreateMetadata(attributes, containerType, modelAccessor, modelType, propertyName);

        //This single line is for the "sidenote" in my text above, remove if you don't use this:
        attributes.OfType<MetadataAttribute>().ToList().ForEach(x => x.Process(modelMetadata));

        return modelMetadata;
    }
}

So now I can have an EditorTemplate that has an interface type as the model and then use different implementations of it to be able to have different field names and validation rules via data annotations. I'm using this for a form that takes three different addresses; home address, work address and invoice address. The user interface for these groups of inputs are exactly the same but the validation rules differ.

This is of course a bit of a convention based solution saying that this behaviour should always apply when the editor template model is an interface. If you have existing editor templates where the model is an interface type that it self has data annotations, this solution will of course break that. For my case we are just starting up our usage of MVC and for now this convention will work. It would be interesting to maybe send a combination of the attributes from the interface and the actual type to the base-implementation but I'll save that experiment for later.

Please also let me know if you're a reader that is aware of some serious flaw with this solution.

Upvotes: 1

moribvndvs
moribvndvs

Reputation: 42497

You're specifying a model (IPerson) which has no data attributes when you're calling the PersonController.Edit action. The default metadata provider will only pick up the data attributes defined explicitly on the specified type (in this case, IPerson) or those that are inherited. You can share a metadata class or interface or copy the data annotations attributes to the interface.

However, I think you may want to redesign how this works a bit (for example, calling RenderAction to include another view into the current view is a code smell).

I would create a partial view for Person. Then, you can create a partial view for each type of person (Client, etc.). You can then add any additional markup, and include your Person view by using @Html.Partial("Person", Model).

You might also want to use a base class Person instead of an interface, otherwise it'll get tricky overriding the data attributes for FirstName and LastName.

public abstract class Person
{
    public virtual string FirstName {get;set;}

    public virtual string LastName {get;set;}
}

public class Employee : Person
{
    [Required]
    [Display(Description = "Employee First Name", Name = "Employee First Name")]
    public override string FirstName {get;set;}

    [Required]
    [Display(Description = "Employee Last Name", Name = "Employee Last Name")]
    public override string LastName {get;set;}

    public int NumberOfYearsWithCompany {get;set;}
}

public class Client : Person
{
    [Required]
    [Display(Description = "Your first Name", Name = "Your first Name")]
    public override string FirstName {get;set;}

    [Display(Description = "Your last Name", Name = "Your last Name")]
    public override string LastName {get;set;}

    [Display(Description = "Company Name", Name = "What company do you work for?")]
    public string CompanyName {get;set;}
}

Views/Shared/Person.cshtml

@model Person

<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => model.FirstName)
    </div>
    <div class="editor-field">
        @Html.EditorFor(model => model.FirstName)
        @Html.ValidationMessageFor(model => model.FirstName)
    </div>
</div>
<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => model.LastName)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(model => model.LastName)
        @Html.ValidationMessageFor(model => model.LastName)
    </div>
</div>

Views/Employees/Edit.cshtml

@model Employee

@Html.Partial("Person", Model);

<div class="clear paddingbottomxxsm">
    <div class="editor-label">
        @Html.LabelFor(model => model.CompanyName)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(model => model.CompanyName)
        @Html.ValidationMessageFor(model => model.CompanyName)
    </div>
</div>

Controllers/EmployeesController.cs

public class EmployeesController : Controller
{
    public ActionResult Edit(int id)
    {
         var model = GetEmployee(id); // replace with your actual data access logic

         return View(model);
    }
}

Upvotes: 1

Related Questions