2

Having some trouble creating a time-only input field with unobtrusive validation, mapping to a non-nullable DateTime field.

I am working with a database-first approach. The database has datetime fields used to store both dates and times. By that I mean some datetimes have real date data and 00:00:00 for time while others have meaningless date data with real times. I understand this came about due to limitations with EF5 and C# datatypes. Now using EF6, but not particularly intending to change the database.

Here is the view model

[UIHint("Date")]
public DateTime MatchDate { get; set; }

[UIHint("Time")]
public DateTime StartTime { get; set; }

The date-only field, MatchDate, is working. It uses an EditorFor template, Date.cshtml (below). It allows text input, correctly attaches a jQuery datepicker and correctly validates server side and client-side with unobtrusive.

@model DateTime
@if (Model == DateTime.MinValue)
{
    @Html.TextBox("", "", new { @class = "datepicker" })
}
else
{   
    @Html.TextBox("", String.Format("{0:d/M/yyyy}", Model), new { @class = "datepicker" })
}

So I created a new EditorFor template for Time.cshtml

@model DateTime
@if (Model == DateTime.MinValue)    
{
    @Html.TextBox("", "", new { @class = "timepicker" })
}
else
{
    @Html.TextBox("", String.Format("{0:h\\:mm tt}", Model), new { @class = "timepicker" })
}

At this point the Html input element gets an unwanted data-val-date attribute, causing unobtrusive date validation. I found that by adding [DataType(DataType.Time)], the data-val-date is no longer added. While this is a good thing, I'm not if that is exactly (and only) what the datatype attribute is supposed to do. So this is the first part of the question.

Next, I have created custom validation rules (server & client-side) for a 12-hr time format.

Annotation:

public class TimeTwelveAttribute : ValidationAttribute, IClientValidatable
{
    private const string _ErrorMessage = "Invalid time format. Please use h:mm am/pm (TimeAttribute)";
    public TimeTwelveAttribute()
    {
        this.ErrorMessage = _ErrorMessage;
    }
    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        yield return new ModelClientValidationRule
        {
            ErrorMessage = _ErrorMessage,
            ValidationType = "timetwelve"
        };
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        DateTime time;
        if (value == null || !DateTime.TryParse(value.ToString(), out time))
        {
            return new ValidationResult(_ErrorMessage);
        }

        return ValidationResult.Success;
    }
}

javascript

$.validator.unobtrusive.adapters.addSingleVal("timetwelve");
$.validator.addMethod(
        "timetwelve",
        function (value, element) {
        return this.optional(element) || /^((0?[1-9]|1[012])(:[0-5]\d){1,2}(\ ?[AP]M))$/i.test(value);
        },
        "Invalid time format. Please use h:mm am/pm"
    );

However, adding the [TimeTwelve] data annotation to StartTime has no effect:

  • Server-side validation is occurring, but it's creating a different error message that I can't override: "The value 'asdf' is not valid for Start Time."
  • There is no client-side validation message popping up if I type 'asdf' in to the time field.

I gather this is caused by the postback values being mapped in to C# DateTimes. Whatever mechanism does that takes a higher priority and overrides all other validation. As an exercise, I rewrote StartTime as a string (and updated the templates etc.) and sure enough [TimeTwelve] works, client-side and server-side as expected. I feel like this is aspect has not been covered in any of the related questions here on stackoverflow.

So the second part of my question, given that I would really prefer to use DateTimes in my view model, how can I get validation to work?

This is the closest question I could find, but is from 2013.

Khyron
  • 468
  • 3
  • 11
  • 1
    If your dealing with time only, then your property in your view model should be `TimeSpan` not `DateTime` –  Aug 24 '17 at 05:24
  • `DateTime` hour integer value is limited to 23. To use difference between two range of times use `TimeSpan` instead (it includes days, hours, mins & seconds). – Tetsuya Yamamoto Aug 24 '17 at 05:42
  • @StephenMuecke Thanks. How would you deal with the meridian indicator using TimeSpan? Why is TimeSpan better than just string? – Khyron Aug 25 '17 at 01:12
  • Did you mean better that `DateTime`? But just read the question again and noticed that you do not want to change the database (although it really should be store as [TIME](https://learn.microsoft.com/en-us/sql/t-sql/data-types/time-transact-sql) if time is all you want) –  Aug 25 '17 at 01:41
  • And yes, if you were to submit 'asdf' the `DefaultModelBinder` cannot convert it to a `DateTime` swhich adds the _The value 'asdf' is not valid for Start Time._ error to `ModelState` and no further validation is done (because there would be no point). –  Aug 25 '17 at 01:45
  • Having said that, your scripts look correct so not sure why your not getting the client side error, although it looks like you may be using a jquery plugin for the timepicker, it which case it may be making your textbox hidden and replacing with its own html (check the actual html being generated to see if `display:none;` has been added). By default hidden inputs are not validated - refer [this answer](https://stackoverflow.com/questions/35844336/asp-mvc-jquery-validation-in-bootsrap-tabs-causes-an-undesired-postback/35856410#35856410) for how to override that –  Aug 25 '17 at 01:48
  • @StephenMuecke No, I mean better than string. Data from the browser is a string, and in my case ultimately has to be converted to a DateTime for the database. What benefit does an intermediate datatype like TimeSpan offer, particularly when TimeSpan is not truly a 'time of day'. Feels like a lot of hack work :/ – Khyron Aug 25 '17 at 02:05
  • @StephenMuecke Regarding the `DefaultModelBinder` firstly, thanks for the keyword, now I know what to search about. I guess what I'm wanting to do is to override the DefautlModelBinder, or at least the error message it generates. – Khyron Aug 25 '17 at 02:10
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/152805/discussion-between-stephen-muecke-and-khyron). –  Aug 25 '17 at 02:11
  • @StephenMuecke The timepicker is just constructing a string 'h:mm tt' for the user, but they can type it as well. There are no hidden fields. This is the one I'm using: https://fgelinas.com/code/timepicker/ – Khyron Aug 25 '17 at 02:13

1 Answers1

2

First, to get your client side validation working, change your scripts to

$.validator.unobtrusive.adapters.add('timetwelve', function (options) {
    options.rules['timetwelve'] = {};
    options.messages['timetwelve'] = options.message;
});
// no need to hard code the message - it will use the one you have defined in the validation atribute
$.validator.addMethod('timetwelve', function (value, element) {
    return this.optional(element) || /^((0?[1-9]|1[012])(:[0-5]\d){1,2}(\ ?[AP]M))$/i.test(value);
});

or you can use the .addBool() method

$.validator.unobtrusive.adapters.addBool("timetwelve");

As far as for receiving the The value 'asdf' is not valid for Start Time if you your enter "asdf" in the textbox, that is the default behavior. The DefaultModelBinder uses ValueConverters to parse the values in the Request to the property type. Since asdf cannot be converted to DateTime, a ModelState error is added and no further validation is done (because its already invalid and there would be no point). Of course, once client side validation is working, you will never get to that point anyway (unless the user has disabled javascript or its a malicious user).

Note also your IsValid() method can simply be

protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
    return ValidationResult.Success;
}

By the time you reach here, the DefaultModelBinder has already set the DateTime value (by combining today's date with the time entered in the form), so there is no need to try and parse it again.

  • Just a couple of points for anybody reading this later: Firstly, we didn't really find an answer as to why the validation scripts I used did not work. This is one of those times where we find an answer that works and move on. Secondly, Steven brought my attention to the `DefaultModelBinder`, which is a part of the MVC framework that I have not really given much consideration since things 'usually just work'. – Khyron Aug 28 '17 at 01:28