The second post of the “Custom Model Binding” series is dedicated to “fixed representation” complex types, that is to types that for some reason cannot be handled as “simple types” but that are rendered with some pre-defined input fields. For a general discussion, on Custom Model Binding, please refer to the first post of the series. As an example of the general technique we will design and install a custom model binder for the DateTimeOffset .Net type. The DateTimeOffset is a generalization of the Datetime that contains also the Time Zone offset associated to the DateTime value. We can’t use the simple technique described in the first post of the series for two reasons:
- The DateTimeOffset is a system type so we can’t apply a custom TypeConverterAttribute to its definition.
- We need the DateTime and Time Zone offset be rendered into separate fields so that the user may edit date and time with a datetime-local Html5 input type. The Time Zone offset part will be stored in an hidden input field that will be handled automatically by some JavaScript. The hidden field must be updated each time the datetime-local input value changes because the Time Zone Offset is not constant but depends on the selected date (Daylight saving time on/off). On the server side we need a custom model binder that is able to combine both inputs into a DateTimeOffset instance.
More specifically, a DateTimeOffset that is rendered and then posted to the server again passes through the following transformations:
- DateTime and Time Zone offset are rendered in the two dedicated input fields described above.
- Once on the client side some JavaScript converts the DateTime/Time Zone offset pair into the client time zone, so that user may edit it in a datetime-local input field (we suppose the browser supports datetime-local)
- Each time the user changes the datetime value the associated Time Offset field is updated by some JavaScript
- Once the form is posted a custom model binder combines both the DateTime and the client Time Zone Offset into an unique DateTimeOffset.
- During the business processing the DateTimeOffset may be converted in other Time Zones, and possibly is rendered again, thus returning to step 1.
Summing up we need:
- A custom TagHelper to render DateTimeOffset into the 2 input fields format described above
- Some JavaScript for the conversion in the client Time Zone, and for updating automatically the hidden field
- A Custom Model for DateTimeOffset types
- Some wire up code to “connect” our custom model binder.
The above steps are a kind of standard, since custom model binders are always designed together with their compatible rendering counterparts.
A Custom TagHelper for DateTimeOffset
As a first step let create a new asp.net core project named “FixedRepresentationModelBinding” and let select “no authentication”.
Then create a new Folder called “TagHelpers”, and add a new DateTimeOffsetInputTagHelper class.
Our TagHelper should execute before the Asp.net Mvc standard input TagHelper and should intercept all DateTimeOffset rendering:
using System;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace FixedRepresentationModelBinding.TagHelpers
{
[HtmlTargetElement("input", Attributes = ForAttributeName,
TagStructure = TagStructure.WithoutEndTag)]
public class DateTimeOffsetInputTagHelper : TagHelper
{
public override int Order
{
get
{
return int.MinValue;
}
}
private const string ForAttributeName = "asp-for";
[HtmlAttributeName("type")]
public string InputTypeName { get; set; }
[HtmlAttributeName("value")]
public string Value { get; set; }
[HtmlAttributeName(ForAttributeName)]
public ModelExpression For { get; set; }
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
if(For.Metadata.UnderlyingOrModelType == typeof(DateTimeOffset))
{
var fullName = ViewContext.ViewData
.TemplateInfo.GetFullHtmlFieldName(For.Name);
var dvalue = (DateTimeOffset?)For.Model;
var offset = dvalue.HasValue ?
Convert.ToInt32(dvalue.Value.Offset.TotalMinutes) :
0;
if (String.IsNullOrEmpty(InputTypeName))
InputTypeName = "datetime-local";
if (!output.Attributes.ContainsName("type"))
output.Attributes.Add("type", InputTypeName);
if (String.IsNullOrEmpty(Value))
{
Value= dvalue.HasValue ?
string.Format("{0:00}-{1:00}-{2:00}T{3:00}:{4:00}:{5:00}",
dvalue.Value.Year, dvalue.Value.Month, dvalue.Value.Day,
dvalue.Value.Hour, dvalue.Value.Minute, dvalue.Value.Second) :
string.Empty;
}
if (!output.Attributes.ContainsName("value"))
output.Attributes.Add("value", Value);
output.Attributes.Add("data-has-offset", "true");
fullName = fullName.Length > 0 ? fullName + ".O" : "O";
output.PostElement.AppendHtml("<input name='" +
fullName + "' type='hidden' value='"+offset+"' />");
}
}
}
}
The Order property returning int.MinValue ensures that out TagHelper is the first to be executed.
The InputTypeName property is filled with the input “type” if any is specified. If no type is specified it is automatically set to “datetime-local” and copied into the output attributes dictionary
Similarly, if no input “value” is specified, value is taken from the model contained in the For property, serialized with the yyyy-MM-ddTHH:mm:ss format (the format required by a datetime-local input field) and copied to the output dictionary.
The input field itself is not rendered by our TagHelper since its rendering is left to the standard input TagHelper that is executed immediately after our custom TagHelper. However,
the standard TagHelper will use the value and type we have set in the output attributes.
We add the attribute “data-has-offset=’true’” to the output attributes in order to retrieve all DateTimeOffset/rendering input fields from JavaScript.
The last instruction adds further Html to the Html rendered by the standard TagHelper, with the help of PostElement.AppendHtml. This way we add the input type hidden containing the Time Zone offset. The offset is expressed in minutes, since some time zones have an hours+minutes format. Finally, hidden input name is the same as the main input name but with a “.O” postfix.
For more information about custom TagHelpers, please refer to the official documentation.
Now we need to register all TagHelpers contained in the web site dll. Open the _ViewImports.cshtml file contained in the Views folder and add the following line:
@addTagHelper *, FixedRepresentationModelBinding
Now we can test our TagHelper.
Go to “Views\Home\Index.cshtm” and replace the whole content with:
@model Nullable<DateTimeOffset>
@{
ViewData["Title"] = "Fixed representation Model Binding Test";
var datetimeTest = Model;
}
<h2>@ViewData["Title"]</h2>
<form asp-action="Index" asp-controller="Home">
<div class="form-group">
<label asp-for="@datetimeTest">Local date/time:</label>
<input asp-for="@datetimeTest" class="form-control">
</div>
<button type="submit" class="btn btn-default">Submit</button>
</form>
Model must be assigned to the variable datetimeTest otherwise the input field would have empty name and id.
This problem arises because in our very simple test the DateTimeOffset struct is not a property of a bigger model. With our trick the input field is named “datetimeTest” that must match the name of the parameter of the Home controller Index method, that we are going to substitute in the Home controller:
public IActionResult Index(DateTimeOffset? datetimeTest)
{
//datetimeTest = new DateTimeOffset(2000, 12, 2, 20, 0, 0,
// new TimeSpan(0, 5, 0, 0));
return View(datetimeTest);
}
In order to verify that our TagHelper is actually able to render a DateTimeOffset just un-comment the commented lines and run the project. If the selected browser supports datetime-local Httml5 inputs you should see a well formatted date+time. Moreover, looking at the Html sources the hidden input should contain 300 (5 hours times 60 minutes)
If everything is ok, comment again the un-commented lines.
Now is time to add the needed JavaScript. For simplicity, we may add it with an in-line script:
@section scripts{
<script type="text/javascript">
(function ($) {
function dateFromISO8601(isoDateString) {
var parts = isoDateString.match(/\d+/g);
var isoTime = Date.UTC(parts[0], parts[1] - 1,
parts[2], parts[3], parts[4], parts[5]);
var isoDate = new Date(isoTime);
return isoDate;
}
function localDateFromISO8601(isoDateString) {
var parts = isoDateString.match(/\d+/g);
var isoDate = new Date(parts[0], parts[1] - 1,
parts[2], parts[3] || 0, parts[4] || 0, parts[5] || 0);
return isoDate;
}
$("[data-has-offset]").each(function () {
var jThis = $(this);
var val = $.trim(jThis.val())
var date = val ? dateFromISO8601(val) : null;
//now we date have UTC + Server offset
//since val is interpreted as UTC date but is not
if (date) {
var offset = parseInt(this.nextElementSibling.value);
date = new Date(date.getTime()
- offset * 1000 * 60);
//after subtracting server offset we have right date
var clientOffset = -date.getTimezoneOffset();
//we change sign because getTimezoneOffset returns UTC-local time
//while we need local time - UTC
this.nextElementSibling.value = clientOffset;
date = new Date(date.getTime()
+ clientOffset * 1000 * 60);
//We add client offset because toISOString
//serialize UTD date not local date so we need to increase
//UTC date + clientOffset
jThis.val(date.toISOString().substr(0, 19));
}
})
.change(function(evt){
var target=evt.target;
var val = target.value.trim();
if (val) {
target.nextElementSibling.value =
-localDateFromISO8601(val).getTimezoneOffset();
//We must call localDateFromISO8601 because date is in local time
}
});
})(jQuery)
</script>
}
We select all input fields with the “data-has-offset” attribute, that are the ones to process. First of all we convert the DateTime/Time-Zone-Offset pairs into the client time zone. Dates math is done by converting everything in milliseconds: the date with get Time, and the offset by multiplying it times 60*1000. Then we apply a change event handler to all selected inputs, in order to update the Time Zone offset each time the date+time changes.
A Custom Model Binder for DateTimeOffset
Custom model binders for “fixed representations” usually mimic the standard Mvc SimpleTypeModelBinder, the only difference being that the SimpleTypeModelBinder processes just one input field while “fixed representations” model binders process several input fields. As you can see in the source code the SimpleTypeModelBinder uses TypeConverters to deserialize types; for our simple model binder, instead, a DateTime and an integer parsers will be enough.
Let add a new “ModelBinding” folder and create a DateTimeOffsetModelBinder class. Our model binder must implement the interface IModelBinder that contains the single method BindModelAsync:
using System;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace FixedRepresentationModelBinding.ModelBinding
{
public class DateTimeOffsetModelBinder: IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var valueProviderResult = bindingContext.ValueProvider
.GetValue(bindingContext.ModelName);
var auxValueProviderResult = bindingContext.ValueProvider
.GetValue(bindingContext.ModelName+".O");
if (valueProviderResult == ValueProviderResult.None ||
auxValueProviderResult == ValueProviderResult.None)
{
// no entry found for some of the two needed input fields
//return without setting result, that is, return a failure
return TaskCache.CompletedTask;
}
//store value retrieved for Date+Time in model state
//this way, in case of format errors user may
//correct input. No need to store also time zone offset,
//since it cannot be edited by the user
bindingContext.ModelState
.SetModelValue(bindingContext.ModelName, valueProviderResult);
try
{
var value = valueProviderResult.FirstValue;
var auxValue = auxValueProviderResult.FirstValue;
object model;
if (string.IsNullOrWhiteSpace(value) ||
string.IsNullOrWhiteSpace(auxValue))
{
//empty fields, means null model
model = null;
}
else
{
int offset = int.Parse(auxValue, CultureInfo.InvariantCulture);
DateTime dt = DateTime.Parse(value, CultureInfo.InvariantCulture);
model = new DateTimeOffset(dt, new TimeSpan(0, offset, 0));
}
//if model is null and type is not nullable
//return a required field error
if (model == null &&
!bindingContext.ModelMetadata
.IsReferenceOrNullableType)
{
bindingContext.ModelState.TryAddModelError(
bindingContext.ModelName,
bindingContext.ModelMetadata
.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
valueProviderResult.ToString()));
return TaskCache.CompletedTask;
}
else
{
bindingContext.Result =
ModelBindingResult.Success(model);
return TaskCache.CompletedTask;
}
}
catch (Exception exception)
{
//in case parsers throw a FormatException
//add error to the model state.
bindingContext.ModelState.TryAddModelError(
bindingContext.ModelName,
exception,
bindingContext.ModelMetadata);
return TaskCache.CompletedTask;
}
}
}
}
As a first step we use the value provider passed in the bindingContext to get the string values of our two input fields. If provided we use them to build a DateTimeOffset structure with the help of DateTime and int parse methods. In case of errors an error message is added to the ModelState. If the model binder returns TaskCache.CompleteTask without setting bindingContext.Result the framework assumes that model binding failed.
Installing Our Model Binder
There are various options to inform the framework on when to use our model binder:
- Decorate a ViewModel property or an Action Method parameter with a ModelBinderAttribute specifying the type of our model binder. In this case just that property or parameter will be bound with our model binder.
- Decorate the class or struct to bind with our model binder with the ModelBinderAttribute. In this case the model binder is used for each occurrence of the target type. Unluckily, we can’t follow this path since DateTimeOffset is a system type, so we can’t apply any attribute to it.
- Installing the model binder in the standard model binding pipeline. Also in this case the model binder is used for each occurrence of our target type.
Since we want our model binder be used on each occurrence of DateTimeOffset structures, and since option 2 is not viable we are forced to adopt option 3. Since in option 3 the model binder is not attached to any type or property we must instruct explicitly the standard model binding pipeline on when to use our model binder. This is done by defining an implementation of the IModelBinderProvider interface that exposes the single GetBinder method. Let call DateTimeOffsetModelBinderProvider our implementation, and place it in the already defined “ModelBinding” folder:
using System;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace FixedRepresentationModelBinding.ModelBinding
{
public class DateTimeOffsetModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.UnderlyingOrModelType == typeof(DateTimeOffset))
{
return new DateTimeOffsetModelBinder();
}
return null;
}
}
}
The implementation is straightforward, if the current type to bind is either a DateTimeOffset or a DateTimeOffset? our provider returns an instance of our model binder, otherwise it returns null. A null result informs the model binding pipeline to try the next IModelBinderProvider implementation.
In fact all installed IModelBinderProvider implementations are placed into an IList contained in the ModelBinderProviders property of the MvcOptions object. Accordingly, we may have our model binder working by adding its associated provider to this IList.
The MvcOptions default object may be modified in several ways, The easiest way being to substitute:
in the Startup.cs class, with:
services.AddMvc(o =>
{
o.ModelBinderProviders.Insert(0, new DateTimeOffsetModelBinderProvider());
});
We added our provider on top of the list so it is tried first, to avoid that some other “standard” provider might select a different model binder.
Done!
Run the application, and when the home page appears, place a breakpoint in the Index method of Home controller that will receive our submitted data. Now select select a date+time, submit and verify what is received by the Index method: a DateTimeOffset containing the date and time selected in the browser, an the right client Time Zone offset!
That’s all for now
In a short time the last episode of our series: model binding interfaces!
Stay tuned!
Francesco
Tags: Asp.net Mvc, Asp.net core, Model Binding