Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

MVC generic ViewModel

In short I would like to be able to pass a generic ViewModel into my views

Here is some simplified code of the gist of what I am trying to achieve

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

public class FakePerson : IPerson
{
    public FakePerson()
    {
        FirstName = "Foo";
        LastName = "Bar";
    }

    public string FirstName {get; private set;} 
    public string LastName {get; private set;} 
}

public class HomeViewModel<T> 
    where T : IPerson, new()
{
    public string SomeOtherProperty{ get; set;}
    public T Person { get; private set; }

    public HomeViewModel()
    {
        Person = new T();
    }
}

public class HomeController : Controller {
    public ViewResult Index() {
        return View(new HomeViewModel<FakePerson>());
    }
}

If I create my view as follows all works as expected

<%@ Page Language="C#" 
    MasterPageFile="~/Views/Shared/Site.Master"
    Inherits="System.Web.Mvc.ViewPage<HomeViewModel<FakePerson>>" %>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
    <%: Html.DisplayFor(m=>m.Person.FirstName) %>
    <%: Html.DisplayFor(m=>m.Person.LastName) %>   
</asp:Content>

However I do not want to depend directly on FakePerson in the view in the event that I want to pass some other IPerson implementation, so I tried to change the page directive to

<%@ Page Language="C#" 
    MasterPageFile="~/Views/Shared/Site.Master"
    Inherits="System.Web.Mvc.ViewPage<HomeViewModel<IPerson>>" %>

But of course that does not work, so, after a whole day of mucking about, I have more grey hair and no clue what to do next.

Can anybody help please.

[UPDATE]

Some advise has sugested that I should use a covariant interface; define a non-generic interface and use it in the View. Unfortuanetly I have tried this but there is one aditional implication. I would like the HtmlHelper function to be able to access any data annotation attributes that may be defined in the IPerson derived class

 public class FakePerson : IPerson
 {
    public FakePerson()
    {
        FirstName = "Foo";
        LastName = "Bar";
    }

    [DisplayName("First Name")]
    public string FirstName {get; private set;}

    [DisplayName("Last Name")]
    public string LastName {get; private set;} 
}

So while using a covariant interface this way does work, partly, to access the derived type through the ViewModel; since the view is typed to the interface, it appears the attributes are not accessible.

Is there perhaps a way, in the view, to get access to these attributes, maybe with reflection. Or could there be anotherway to type the View to the generic.

like image 711
ricardo Avatar asked Jan 17 '11 18:01

ricardo


1 Answers

I have successfully got covariant to work, that is, binding the View to an abstract base class. In fact, what I have is a binding to a List<MyBaseClass>. Then I create a specific View strongly typed to each subclass. That takes care of the binding.

But rebinding will fail because the DefaultModelBinder only knows about abstract base class and you will get an exception like, 'Cannot create abstract class'. The solution is to have a property on your base class like this:

    public virtual string BindingType
    {
        get
        {
            return this.GetType().AssemblyQualifiedName;
        }
    }

Bind that to a hidden input in your view. Then your replace your default ModelBinder with a custom one in Global.asax:

    // Replace default model binder with one that can deal with BaseParameter, etc.
    ModelBinders.Binders.DefaultBinder = new CustomModelBinder();

And in your custom model binder you intercept the bind. If it is for one of your known abstract types, you parse the BindingType property and replace the model type so you get an instance of the subclass:

    public class CustomModelBinder : DefaultModelBinder
{
    private static readonly ILog logger = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);

    protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, System.Type modelType)
    {
        if (modelType.IsInterface || modelType.IsAbstract)
        {
            // This is our convention for specifying the actual type of a base type or interface.
            string key = string.Format("{0}.{1}", bindingContext.ModelName, Constants.UIKeys.BindingTypeProperty);            
            var boundValue = bindingContext.ValueProvider.GetValue(key);

            if (boundValue != null && boundValue.RawValue != null)
            {
                string newTypeName = ((string[])boundValue.RawValue)[0].ToString();
                logger.DebugFormat("Found type override {0} for Abstract/Interface type {1}.", modelType.Name, newTypeName);

                try
                {
                    modelType = System.Type.GetType(newTypeName);
                }
                catch (Exception ex)
                {
                    logger.ErrorFormat("Error trying to create new binding type {0} to replace original type {1}. Error: {2}", newTypeName, modelType.Name, ex.ToString());
                    throw;
                }
            }
        }

        return base.CreateModel(controllerContext, bindingContext, modelType);
    }

    protected override object GetPropertyValue(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor, IModelBinder propertyBinder)
    {
        if (propertyDescriptor.ComponentType == typeof(BaseParameter))
        {
            string match = ".StringValue";
            if (bindingContext.ModelName.EndsWith(match))
            {
                logger.DebugFormat("Try override for BaseParameter StringValue - looking for real type's Value instead.");
                string pattern = match.Replace(".", @"\.");
                string key = Regex.Replace(bindingContext.ModelName, pattern, ".Value");
                var boundValue = bindingContext.ValueProvider.GetValue(key);
                if (boundValue != null && boundValue.RawValue != null)
                {
                // Do some work here to replace the base value with a subclass value...
                    return value;
                }
            }
        }

        return base.GetPropertyValue(controllerContext, bindingContext, propertyDescriptor, propertyBinder);
    }
}

Here, my abstract class is BaseParameter and I am replacing the StringValue property with a different value from the subclass (not shown).

Note that although you can rebind to the correct type, the form values associated only with the subclass will not be automatically roundtripped because the modelbinder only see the properties on the base class. In my case, I only had to replace one value in GetValue and get it instead from the subclass, so it was easy. If you need to bind lots of subclass properties you will need to do a bit more work and fetch them out the form (ValueProvider[0]) and populate the instance yourself.

Note that you can add a new model binder for a specific type so you could avoid the generic type checking.

like image 133
Rob Kent Avatar answered Oct 20 '22 00:10

Rob Kent