ViewModel with List<BaseClass> and editor templates - asp.net-mvc

I have a view that lists tables being added to a floor plan. Tables derive from TableInputModel to allow for RectangleTableInputModel, CircleTableInputModel, etc
The ViewModel has a list of TableInputModel which are all one of the derived types.
I have a partial view for each of the derived types and given a List of mixed derived types the framework knows how to render them.
However, on submitting the form the type information is lost. I have tried with a custom model binder but because the type info is lost when it's being submitted, it wont work...
Has anyone tried this before?

Assuming you have the following models:
public abstract class TableInputModel
{
}
public class RectangleTableInputModel : TableInputModel
{
public string Foo { get; set; }
}
public class CircleTableInputModel : TableInputModel
{
public string Bar { get; set; }
}
And the following controller:
public class HomeController : Controller
{
public ActionResult Index()
{
var model = new TableInputModel[]
{
new RectangleTableInputModel(),
new CircleTableInputModel()
};
return View(model);
}
[HttpPost]
public ActionResult Index(TableInputModel[] model)
{
return View(model);
}
}
Now you could write views.
Main view Index.cshtml:
#model TableInputModel[]
#using (Html.BeginForm())
{
#Html.EditorForModel()
<input type="submit" value="OK" />
}
and the corresponding editor templates.
~/Views/Home/EditorTemplates/RectangleTableInputModel.cshtml:
#model RectangleTableInputModel
<h3>Rectangle</h3>
#Html.Hidden("ModelType", Model.GetType())
#Html.EditorFor(x => x.Foo)
~/Views/Home/EditorTemplates/CircleTableInputModel.cshtml:
#model CircleTableInputModel
<h3>Circle</h3>
#Html.Hidden("ModelType", Model.GetType())
#Html.EditorFor(x => x.Bar)
and final missing peace of the puzzle is the custom model binder for the TableInputModel type which will use the posted hidden field value to fetch the type and instantiate the proper implementation:
public class TableInputModelBinder : DefaultModelBinder
{
protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
{
var typeValue = bindingContext.ValueProvider.GetValue(bindingContext.ModelName + ".ModelType");
var type = Type.GetType(
(string)typeValue.ConvertTo(typeof(string)),
true
);
var model = Activator.CreateInstance(type);
bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => model, type);
return model;
}
}
which will be registered in Application_Start:
ModelBinders.Binders.Add(typeof(TableInputModel), new TableInputModelBinder());
and that's pretty much all. Now inside the Index Post action the model array will be properly initialzed with correct types.

There was "Derived Type Model Binder" in mvccontrib. But, unfortunately, there is no such binder in mvccontrib version 3

Related

Null value being returned from view [duplicate]

I have cut the model back to one field:
//Model
public class LetterViewModel
{
public string LetterText;
}
//Controller
public ActionResult Index()
{
var model = new LetterViewModel();
model.LetterText = "Anything";
return View(model);
}
[HttpPost]
public ActionResult Index(LetterViewModel model)
{
//model.LetterText == null
return View(model);
}
//view
#model Test.Models.LetterViewModel
#{
Layout = "~/Views/Shared/_Layout.cshtml";
ViewBag.Title = "Create a Letter";
}
#using (Html.BeginForm())
{
<div id="Bottom">
#Html.TextAreaFor(m => m.LetterText)
<input type="submit" value="Ok" class="btn btn-default" />
</div>
}
When I check the Network tab in dev tools it is showing the value entered is being included in the request. However, when the HttpPost controller is fired the field is empty.
The DefaultModelBinder does not set the value of fields, only properties. You need to change you model to include properties
public class LetterViewModel
{
public string LetterText { get; set; } // add getter/setter
}
You can also use a custom binder instead of the default binder in cases where you don't have the option to convert fields to properties.
Loop through the form inputs and set them using reflection. MemberInformation is my class but you can just use FieldInfo.
This doesn't do an object graph but if I need that ability I'll enhance my answer. The tuple in the foreach uses c# 7.0. It also assumes that you saved your object from the previous GET before this POST.
using CommonBusinessModel.Metadata;
using GHCOMvc.Controllers;
using System;
using System.Linq;
using System.Web.Mvc;
namespace AtlasMvcWebsite.Binders
{
public class FieldModelBinder : DefaultModelBinder
{
// this runs before any filters (except auth filters)
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var form = controllerContext.HttpContext.Request.Form;
Type type = typeof(GHCOBusinessModel.GHCOPPAType);
AtlasKernelBusinessModel.VersionedObject instance = PolicyController.Policy;
foreach ((var value, var member) in (from string input in form
let fi = type.GetField(input)
where fi != null
let mi = new MemberInformation(fi, instance)
where !mi.ReadOnly
select (form[input], mi)))
member.SetValue(value);
return instance;
}
}
}
You need to add [FromBody] before parameter to Action
[HttpPost]
public ActionResult Index([FromBody]LetterViewModel model)
{
//model.LetterText == null
return View(model);
}

MVC Model Binding with Checkbox list....possibly haunted [duplicate]

I have cut the model back to one field:
//Model
public class LetterViewModel
{
public string LetterText;
}
//Controller
public ActionResult Index()
{
var model = new LetterViewModel();
model.LetterText = "Anything";
return View(model);
}
[HttpPost]
public ActionResult Index(LetterViewModel model)
{
//model.LetterText == null
return View(model);
}
//view
#model Test.Models.LetterViewModel
#{
Layout = "~/Views/Shared/_Layout.cshtml";
ViewBag.Title = "Create a Letter";
}
#using (Html.BeginForm())
{
<div id="Bottom">
#Html.TextAreaFor(m => m.LetterText)
<input type="submit" value="Ok" class="btn btn-default" />
</div>
}
When I check the Network tab in dev tools it is showing the value entered is being included in the request. However, when the HttpPost controller is fired the field is empty.
The DefaultModelBinder does not set the value of fields, only properties. You need to change you model to include properties
public class LetterViewModel
{
public string LetterText { get; set; } // add getter/setter
}
You can also use a custom binder instead of the default binder in cases where you don't have the option to convert fields to properties.
Loop through the form inputs and set them using reflection. MemberInformation is my class but you can just use FieldInfo.
This doesn't do an object graph but if I need that ability I'll enhance my answer. The tuple in the foreach uses c# 7.0. It also assumes that you saved your object from the previous GET before this POST.
using CommonBusinessModel.Metadata;
using GHCOMvc.Controllers;
using System;
using System.Linq;
using System.Web.Mvc;
namespace AtlasMvcWebsite.Binders
{
public class FieldModelBinder : DefaultModelBinder
{
// this runs before any filters (except auth filters)
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var form = controllerContext.HttpContext.Request.Form;
Type type = typeof(GHCOBusinessModel.GHCOPPAType);
AtlasKernelBusinessModel.VersionedObject instance = PolicyController.Policy;
foreach ((var value, var member) in (from string input in form
let fi = type.GetField(input)
where fi != null
let mi = new MemberInformation(fi, instance)
where !mi.ReadOnly
select (form[input], mi)))
member.SetValue(value);
return instance;
}
}
}
You need to add [FromBody] before parameter to Action
[HttpPost]
public ActionResult Index([FromBody]LetterViewModel model)
{
//model.LetterText == null
return View(model);
}

Model not null, but children objects are null [duplicate]

I have cut the model back to one field:
//Model
public class LetterViewModel
{
public string LetterText;
}
//Controller
public ActionResult Index()
{
var model = new LetterViewModel();
model.LetterText = "Anything";
return View(model);
}
[HttpPost]
public ActionResult Index(LetterViewModel model)
{
//model.LetterText == null
return View(model);
}
//view
#model Test.Models.LetterViewModel
#{
Layout = "~/Views/Shared/_Layout.cshtml";
ViewBag.Title = "Create a Letter";
}
#using (Html.BeginForm())
{
<div id="Bottom">
#Html.TextAreaFor(m => m.LetterText)
<input type="submit" value="Ok" class="btn btn-default" />
</div>
}
When I check the Network tab in dev tools it is showing the value entered is being included in the request. However, when the HttpPost controller is fired the field is empty.
The DefaultModelBinder does not set the value of fields, only properties. You need to change you model to include properties
public class LetterViewModel
{
public string LetterText { get; set; } // add getter/setter
}
You can also use a custom binder instead of the default binder in cases where you don't have the option to convert fields to properties.
Loop through the form inputs and set them using reflection. MemberInformation is my class but you can just use FieldInfo.
This doesn't do an object graph but if I need that ability I'll enhance my answer. The tuple in the foreach uses c# 7.0. It also assumes that you saved your object from the previous GET before this POST.
using CommonBusinessModel.Metadata;
using GHCOMvc.Controllers;
using System;
using System.Linq;
using System.Web.Mvc;
namespace AtlasMvcWebsite.Binders
{
public class FieldModelBinder : DefaultModelBinder
{
// this runs before any filters (except auth filters)
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var form = controllerContext.HttpContext.Request.Form;
Type type = typeof(GHCOBusinessModel.GHCOPPAType);
AtlasKernelBusinessModel.VersionedObject instance = PolicyController.Policy;
foreach ((var value, var member) in (from string input in form
let fi = type.GetField(input)
where fi != null
let mi = new MemberInformation(fi, instance)
where !mi.ReadOnly
select (form[input], mi)))
member.SetValue(value);
return instance;
}
}
}
You need to add [FromBody] before parameter to Action
[HttpPost]
public ActionResult Index([FromBody]LetterViewModel model)
{
//model.LetterText == null
return View(model);
}

Why isn't this simple MVC form posting the model back? [duplicate]

I have cut the model back to one field:
//Model
public class LetterViewModel
{
public string LetterText;
}
//Controller
public ActionResult Index()
{
var model = new LetterViewModel();
model.LetterText = "Anything";
return View(model);
}
[HttpPost]
public ActionResult Index(LetterViewModel model)
{
//model.LetterText == null
return View(model);
}
//view
#model Test.Models.LetterViewModel
#{
Layout = "~/Views/Shared/_Layout.cshtml";
ViewBag.Title = "Create a Letter";
}
#using (Html.BeginForm())
{
<div id="Bottom">
#Html.TextAreaFor(m => m.LetterText)
<input type="submit" value="Ok" class="btn btn-default" />
</div>
}
When I check the Network tab in dev tools it is showing the value entered is being included in the request. However, when the HttpPost controller is fired the field is empty.
The DefaultModelBinder does not set the value of fields, only properties. You need to change you model to include properties
public class LetterViewModel
{
public string LetterText { get; set; } // add getter/setter
}
You can also use a custom binder instead of the default binder in cases where you don't have the option to convert fields to properties.
Loop through the form inputs and set them using reflection. MemberInformation is my class but you can just use FieldInfo.
This doesn't do an object graph but if I need that ability I'll enhance my answer. The tuple in the foreach uses c# 7.0. It also assumes that you saved your object from the previous GET before this POST.
using CommonBusinessModel.Metadata;
using GHCOMvc.Controllers;
using System;
using System.Linq;
using System.Web.Mvc;
namespace AtlasMvcWebsite.Binders
{
public class FieldModelBinder : DefaultModelBinder
{
// this runs before any filters (except auth filters)
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var form = controllerContext.HttpContext.Request.Form;
Type type = typeof(GHCOBusinessModel.GHCOPPAType);
AtlasKernelBusinessModel.VersionedObject instance = PolicyController.Policy;
foreach ((var value, var member) in (from string input in form
let fi = type.GetField(input)
where fi != null
let mi = new MemberInformation(fi, instance)
where !mi.ReadOnly
select (form[input], mi)))
member.SetValue(value);
return instance;
}
}
}
You need to add [FromBody] before parameter to Action
[HttpPost]
public ActionResult Index([FromBody]LetterViewModel model)
{
//model.LetterText == null
return View(model);
}

Using HtmlString throws an InvalidOperationException when updating the model

I'm using an HtmlString property on my model like this
public HtmlString Html { get; set; }
then I have an EditorTemplate that renders and html editor but when I use TryUpdateModel() I get an InvalidOperationException because no type converter can convert between these types String and HtmlString.
Do I need to create a custom model binder or is there another way?
UPDATE:
I'm trying to use HtmlString on my model, mostly for making it obvious that it contains HTML.
So this is what my complete model looks like:
public class Model {
public HtmlString MainBody { get; set; }
}
and this is how I render the form:
#using (Html.BeginForm("save","home")){
#Html.EditorForModel()
<input type="submit" name="submit" />
}
I have created my own editor template called Object.cshtml so that the field MainBody can be rendered as a textarea.
My controller has a Save method that looks like this:
public void Save([ModelBinder(typeof(FooModelBinder))]Model foo) {
var postedValue = foo.MainBody;
}
As you can see I have been playing around with a custom model binder that looks like this:
public class FooModelBinder : DefaultModelBinder {
protected override object GetPropertyValue(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor, IModelBinder propertyBinder) {
if (propertyDescriptor.PropertyType == typeof(HtmlString)) {
return new HtmlString(controllerContext.HttpContext.Request.Form["MainBody.MainBody"]);
}
return null;
}
}
this works as expected but I don't know how to get the complete ModelName from the bindingContext because bindingContext.ModelName only contains the MainBody and not MainBody.MainBody?
I'm also interested in other solutions regarding this or maybe if someone thinks it's a really bad idea.
Do I need to create a custom model binder
Yes, if you want to use an HtmlString property on your view model because this class has no parameterless constructor and the default model binder has no clue how to instantiate it.
or is there another way?
Yes, don't use HtmlString property on the view model. There might also be other ways. Unfortunately since you have provided strictly 0 information about your context and what precisely you are trying to achieve that's all we could help you with so far.
UPDATE:
Now that you have shown a wee-bit of your code here's a sample.
Model:
public class Model
{
public HtmlString MainBody { get; set; }
}
Controller:
public class HomeController : Controller
{
public ActionResult Index()
{
return View(new Model());
}
[HttpPost]
public ActionResult Index([ModelBinder(typeof(FooModelBinder))]Model foo)
{
var postedValue = foo.MainBody;
return Content(postedValue.ToHtmlString(), "text/plain");
}
}
Model binder:
public class FooModelBinder : DefaultModelBinder
{
protected override object GetPropertyValue(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor, IModelBinder propertyBinder)
{
if (propertyDescriptor.PropertyType == typeof(HtmlString))
{
return new HtmlString(bindingContext.ValueProvider.GetValue(bindingContext.ModelName).AttemptedValue);
}
return null;
}
}
View (~/Views/Home/Index.cshtml):
#using (Html.BeginForm())
{
#Html.EditorForModel()
<input type="submit" name="submit" />
}
Custom object editor template in order to do a deep dive (~/Views/Shared/EditorTemplates/Object.cshtml):
#foreach (var property in ViewData.ModelMetadata.Properties.Where(x => x.ShowForEdit))
{
if (!string.IsNullOrEmpty(property.TemplateHint))
{
#Html.Editor(property.PropertyName, property.TemplateHint)
}
else
{
#Html.Editor(property.PropertyName)
}
}
Custom editor template for the HtmlString type to be rendered as a textarea (~/Views/Shared/EditorTemplates/HtmlString.cshtml):
#Html.TextArea("")
By the way I still don't understand why you would want to use HtmlString as a property instead of a simple string but anyway.

Resources