How to validate a date using 3 dropdowns( day, month, year ) using jquery unobtrusive validation?
Asked Answered
B

4

5

I have a model to validate and the problem is the date of birth field. It must be composed of 3 dropdowns (day, month, year).

<div id="dob-editor-field" class="model-field-editor">
      @Html.LabelFor(m => m.DateOfBirth, new { @class = "label-div" })
      @Html.Telerik().DropDownList().Name("DobDay").BindTo((SelectList)ViewData["Days"]).HtmlAttributes(new {id = "DobDaySel"})
      @Html.Telerik().DropDownList().Name("DobMonth").BindTo((SelectList)ViewData["Months"]).HtmlAttributes(new { id = "DobMonthSel"})
      @Html.Telerik().DropDownList().Name("DobYear").BindTo((SelectList)ViewData["Years"]).HtmlAttributes(new { id = "DobYearSel" })
      @Html.ValidationMessageFor(m => m.DateOfBirth)
</div>

On the server side i do this

        [HttpPost]
        public ActionResult Register(RegistrationModel regInfo, int DobDay, int DobMonth, int DobYear)
        {
            SetRegisterViewData(DobDay, DobMonth, DobYear);
            if (DobDay == 0 || DobMonth == 0 && DobYear == 0)
            {
                ModelState.AddModelError("DateOfBirth", "Date of birth is required");
            }
            else
            {
                DateTime dt = new DateTime(DobYear, DobMonth, DobDay);
                long ticks = DateTime.Now.Ticks - dt.Ticks;
                int years = new DateTime(ticks).Year;
                if (years < 18)
                {
                    ModelState.AddModelError("DateOfBirth", "You must be at least 18");
                }
            }            
            if (ModelState.IsValid)
            {
                //register user
                return RedirectToAction("Index", "Home");
            }
            return View(regInfo);
        }

Questions:

  1. Server side : how to make it better? (i am thinking of adding dob, month, and year properties RegistrationModel and add attribute on DateOfBirth to check those properties)
  2. Client side : i was looking at Perform client side validation for custom attribute but it got me confused. What is the way to make it?

LE: I created a custom model binder for the date like this:

    public class DobModelBinder : DefaultModelBinder
    {
        protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor)
        {
            if (propertyDescriptor.Name == "DateOfBirth")
            {
                DateTime dob = DateTime.MinValue;
                var form = controllerContext.HttpContext.Request.Form;
                int day = Convert.ToInt32(form["DobDay"]);
                int month = Convert.ToInt32(form["DobMonth"]);
                int year = Convert.ToInt32(form["DobYear"]);
                if (day == 0 || month == 0 || year == 0)
                {
                    SetProperty(controllerContext, bindingContext, propertyDescriptor, DateTime.MinValue);
                }
                else
                {
                    SetProperty(controllerContext, bindingContext, propertyDescriptor, new DateTime(year, month, day));
                }
            }
            else
            {
                base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
            }
        }
    }

I registered it like this:

ModelBinders.Binders.Add(typeof(DateTime), new DobModelBinder());

I used it like this:

public ActionResult Register([ModelBinder(typeof(DobModelBinder))]RegistrationModel regInfo)

DateOfBirth binds well.

LE2:

I created validation attributes for the date of birth like this:

 public override bool IsValid(object value)
    {
        DateTime date = Convert.ToDateTime(value);
        return date != DateTime.MinValue;
    }

    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        yield return new ModelClientValidationRule
        {
            ErrorMessage = this.ErrorMessage,
            ValidationType = "dateRequired"
        };
    }
}

public class DateGraterThanEighteen : ValidationAttribute, IClientValidatable
{
    public override bool IsValid(object value)
    {
        DateTime date = Convert.ToDateTime(value);
        long ticks = DateTime.Now.Ticks - date.Ticks;
        int years = new DateTime(ticks).Year;
        return years >= 18;
    }

    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        yield return new ModelClientValidationRule
        {
            ErrorMessage = this.ErrorMessage,
            ValidationType = "dateGraterThanEighteen"
        };
    }
}

I applied attributes like this

        [DateGraterThanEighteen(ErrorMessage="You must be at least 18")]
        [DateRequired(ErrorMessage = "Date of birth is required")]
        public DateTime DateOfBirth { get; set; }

LE3:

In the client side i do this:

      $(function () {
            jQuery.validator.addMethod('dobRequired', function (value, element, params) {
                if (!/Invalid|NaN/.test(new Date(value))) {
                    return true;
                }
                else {
                    return false;
                }
            }, '');
            jQuery.validator.unobtrusive.adapters.add('dateRequired', {}, function (options) {
                options.rules['dobRequired'] = true;
                options.messages['dobRequired'] = options.message;
            });
        });

Client validation doesn't seems to work. How can I fix it? I am kinda confused with the way these adapters work.

Bushing answered 13/7, 2012 at 17:48 Comment(2)
You could use a custom model binder and a DateTime field on your model. As far as the client validation is concerned, you could use a custom validation attribute on this property that implements IClientValidatable and a custom unobtrusive adapter. How does it sound? Feasible or do I have to provide an example?Lyre
Any example from you makes it very clear. Thanks.Bushing
L
14

You could use a custom editor template.

Let's first look at how the final solution might look like first before getting into implementation details.

So we could have a view model (as always) decorated with some data annotation attributes indicating the metadata we would like to attach to it:

public class MyViewModel
{
    [DisplayName("Date of birth:")]
    [TrippleDDLDateTime(ErrorMessage = "Please select a valid DOB")]
    [Required(ErrorMessage = "Please select your DOB")]
    [MinAge(18, ErrorMessage = "You must be at least 18 years old")]
    public DateTime? Dob { get; set; }
}

then we could have a controller:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        var model = new MyViewModel();
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(MyViewModel model)
    {
        if (!ModelState.IsValid)
        {
            return View(model);
        }

        return Content(
            string.Format(
                "Thank you for selecting your DOB: {0:yyyy-MM-dd}", 
                model.Dob
            )
        );
    }
}

a view (~/Views/Home/Index.cshtml):

@model MyViewModel
@using (Html.BeginForm())
{
    @Html.EditorFor(x => x.Dob)
    <button type="submit">OK</button>
}

and a corresponding editor template which will allow us to display 3 dropdown lists for editing the DateTime field instead of a simple textbox (~/Views/Shared/EditorTemplates/TrippleDDLDateTime.cshtml):

@{
    var now = DateTime.Now;
    var years = Enumerable.Range(0, 150).Select(x => new SelectListItem { Value = (now.Year - x).ToString(), Text = (now.Year - x).ToString() });
    var months = Enumerable.Range(1, 12).Select(x => new SelectListItem { Value = x.ToString("00"), Text = x.ToString() });
    var days = Enumerable.Range(1, 31).Select(x => new SelectListItem { Value = x.ToString("00"), Text = x.ToString() });

    var result = ViewData.ModelState[ViewData.TemplateInfo.HtmlFieldPrefix];
    if (result != null)
    { 
        var values = result.Value.RawValue as string[];
        years = new SelectList(years, "Value", "Text", values[0]);
        months = new SelectList(months, "Value", "Text", values[1]);
        days = new SelectList(days, "Value", "Text", values[2]);
        result.Value = null;
    }
}

<div class="trippleddldatetime">
    @Html.Label("")

    @Html.DropDownList("", years, "-- year --")
    @Html.DropDownList("", months, "-- month --")
    @Html.DropDownList("", days, "-- day --")

    @Html.ValidationMessage("")
</div>

Now let's see how the [TrippleDDLDateTime] attribute could be implemented:

public class TrippleDDLDateTimeAttribute : ValidationAttribute, IMetadataAware
{
    public void OnMetadataCreated(ModelMetadata metadata)
    {
        metadata.TemplateHint = "TrippleDDLDateTime";
    }

    public override bool IsValid(object value)
    {
        // It's the custom model binder that is responsible for validating 
        return true;
    }
}

Notice how the attribute implements the IMetadataAware interface which allows us to associate the view model property with the custom editor template we wrote (TrippleDDLDateTime.cshtml).

And next comes the [MinAge] attribute:

public class MinAgeAttribute : ValidationAttribute
{
    private readonly int _minAge;
    public MinAgeAttribute(int minAge)
    {
        _minAge = minAge;
    }

    public override bool IsValid(object value)
    {
        if (value == null)
        {
            return true;
        }

        DateTime date = Convert.ToDateTime(value);
        long ticks = DateTime.Now.Ticks - date.Ticks;
        int years = new DateTime(ticks).Year;
        return years >= _minAge;
    }
}

The last piece of the puzzle is to write a custom model binder that will be associated to properties decorated with the [TrippleDDLDateTime] attribute in order to perform the parsing:

public class TrippleDDLDateTimeModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var metadata = bindingContext.ModelMetadata;
        var trippleDdl = metadata.ContainerType.GetProperty(metadata.PropertyName).GetCustomAttributes(typeof(TrippleDDLDateTimeAttribute), true).FirstOrDefault() as TrippleDDLDateTimeAttribute;
        if (trippleDdl == null)
        {
            return base.BindModel(controllerContext, bindingContext);
        }

        var prefix = bindingContext.ModelName;
        var value = bindingContext.ValueProvider.GetValue(prefix);
        var parts = value.RawValue as string[];
        if (parts.All(string.IsNullOrEmpty))
        {
            return null;
        }

        bindingContext.ModelState.SetModelValue(prefix, value);

        var dateStr = string.Format("{0}-{1}-{2}", parts[0], parts[1], parts[2]);
        DateTime date;
        if (DateTime.TryParseExact(dateStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date))
        {
            return date;
        }

        bindingContext.ModelState.AddModelError(prefix, trippleDdl.ErrorMessage);

        return null;
    }
}

Notice how the binder simply uses the default binder if the field is not decorated with the custom attribute. This way it doesn't interfere with other DateTime fields for which we don't want the tripple ddl behavior. The model binder will simply be associated with the DateTime? type in Application_Start:

ModelBinders.Binders.Add(typeof(DateTime?), new TrippleDDLDateTimeModelBinder());

OK, so far we have a solution that performs server side validation. That's always what you should start with. Because that's where you can also stop and still have a safe and working site.

Of course if you have time you could now improve the user experience by implementing client side validation. Client side validation is not compulsory, but it saves bandwidth and avoids server round-trips.

So we start by making our 2 custom attributes implement the IClientValidatable interface which is the first step in enabling unobtrusive client side validation.

[TrippleDDLDateTime]:

public class TrippleDDLDateTimeAttribute : ValidationAttribute, IMetadataAware, IClientValidatable
{
    public void OnMetadataCreated(ModelMetadata metadata)
    {
        metadata.TemplateHint = "TrippleDDLDateTime";
    }

    public override bool IsValid(object value)
    {
        // It's the custom model binder that is responsible for validating 
        return true;
    }

    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        var rule = new ModelClientValidationRule();
        rule.ErrorMessage = ErrorMessage;
        rule.ValidationType = "trippleddldate";
        yield return rule;
    }
}

[MinAge]:

public class MinAgeAttribute : ValidationAttribute, IClientValidatable
{
    private readonly int _minAge;
    public MinAgeAttribute(int minAge)
    {
        _minAge = minAge;
    }

    public override bool IsValid(object value)
    {
        if (value == null)
        {
            return true;
        }

        DateTime date = Convert.ToDateTime(value);
        long ticks = DateTime.Now.Ticks - date.Ticks;
        int years = new DateTime(ticks).Year;
        return years >= _minAge;
    }

    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        var rule = new ModelClientValidationRule();
        rule.ErrorMessage = ErrorMessage;
        rule.ValidationType = "minage";
        rule.ValidationParameters["min"] = _minAge;
        yield return rule;
    }
}

OK, so we have implemented the GetClientValidationRules on both attributes. All that's left is to write the corresponding unobtrusive adapters.

This should be done in a separate javascript file of course. For example it could be trippleddlAdapters.js:

(function ($) {
    $.fn.getDateFromTrippleDdls = function () {
        var year = this.find('select:nth(0)').val();
        var month = this.find('select:nth(1)').val();
        var day = this.find('select:nth(2)').val();
        if (year == '' || month == '' || day == '') {
            return NaN;
        }

        var y = parseInt(year, 10);
        var m = parseInt(month, 10);
        var d = parseInt(day, 10);

        var date = new Date(y, m - 1, d);
        var isValidDate = date.getFullYear() == y && date.getMonth() + 1 == m && date.getDate() == d;
        if (isValidDate) {
            return date;
        }

        return NaN;
    };

    $.validator.unobtrusive.adapters.add('trippleddldate', [], function (options) {
        options.rules['trippleddldate'] = options.params;
        if (options.message) {
            options.messages['trippleddldate'] = options.message;
        }
    });

    $.validator.addMethod('trippleddldate', function (value, element, params) {
        var parent = $(element).closest('.trippleddldatetime');
        var date = parent.getDateFromTrippleDdls();
        console.log(date);
        return !isNaN(date);
    }, '');

    $.validator.unobtrusive.adapters.add('minage', ['min'], function (options) {
        options.rules['minage'] = options.params;
        if (options.message) {
            options.messages['minage'] = options.message;
        }
    });

    $.validator.addMethod('minage', function (value, element, params) {
        var parent = $(element).closest('.trippleddldatetime');
        var birthDate = parent.getDateFromTrippleDdls();
        if (isNaN(birthDate)) {
            return false;
        }

        var today = new Date();
        var age = today.getFullYear() - birthDate.getFullYear();
        var m = today.getMonth() - birthDate.getMonth();
        if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
            age--;
        }
        return age >= parseInt(params.min, 10);
    }, '');
})(jQuery);

Finally we include the 3 necessary scripts to the page to enable the unobtrusive client side validation:

<script src="@Url.Content("~/Scripts/jquery.validate.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/trippleddlAdapters.js")" type="text/javascript"></script>
Lyre answered 15/7, 2012 at 9:56 Comment(4)
There is a problem - it conflicts with the default validator, so when I play a bit with those dropdowns, I occasionally get "The field Date of birth must be a date." in browser. Is there any way to remove the default date validation rule?Sectionalize
You should use ErrorMessageString in your validators rather than ErrorMessage as the former will also work for error messages in resource files.Workbook
For anyone stumbling on the default validation that happens and shows the "The field Date of birth must be a date." message, what you should do is remove the date validation from these three fields through the validation plugin, e.g.: $('select[name = "Date"]').rules("remove", "date");Labialize
It works like a charm. For Australian(dd-MM-yyyy) date, you just need to reorder the dropdowns in the EditorTemplate and then change the TrippleDDL....Binder class to: if (DateTime.TryParseExact(dateStr, "dd-MM-yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out date))Forsberg
S
2

I tried the solution by Darin Dimitrov, but it had some minor problems.

One of the issues was conflicts with the default MVC 4 Javascript date validator - sometimes it kicked in even for valid dates and confused web site users. I invented a solution which can be found here: How to remove default client-side validators?

The second issue was that this solution generates the same id attributes for all the three dropdowns, and that is not good - ids should be unique per HTML page. Here is how I fixed it:

<div class="trippleddldatetime">
    @Html.DropDownList("", years, "Year:", new { id = @ViewData.TemplateInfo.HtmlFieldPrefix + "_y" })
    @Html.DropDownList("", months, "Month:", new { id = @ViewData.TemplateInfo.HtmlFieldPrefix + "_m" })
    @Html.DropDownList("", days, "Day:", new { id = @ViewData.TemplateInfo.HtmlFieldPrefix + "_d" })
</div>

The last issue was that these dropdowns threw exception when I tried to preset some value on them from Controller. Here's how I fixed it:

var result = ViewData.ModelState[ViewData.TemplateInfo.HtmlFieldPrefix];
if (result != null && result.Value != null)
{ 
    var values = result.Value.RawValue as string[];
    years = new SelectList(years, "Value", "Text", values[0]);
    months = new SelectList(months, "Value", "Text", values[1]);
    days = new SelectList(days, "Value", "Text", values[2]);
    result.Value = null;
}
else
{
    var currentValue = ViewData.Model;
    if (currentValue != null)
    {
        years = new SelectList(years, "Value", "Text", currentValue.Year);
        months = new SelectList(months, "Value", "Text", currentValue.Month.ToString("00"));
        days = new SelectList(days, "Value", "Text", currentValue.Day.ToString("00"));
    }
}

And the last improvement - month names as text:

var months = Enumerable.Range(1, 12).Select(x => new SelectListItem { Value = x.ToString("00"), Text = System.Threading.Thread.CurrentThread.CurrentUICulture.DateTimeFormat.GetMonthName(x) });
Sectionalize answered 20/9, 2012 at 10:11 Comment(2)
It's not immediately obvious where you put your second block of code: var result = ViewData.ModelState.... Does it go in the model binder? Or in the controller where you try to set the default values themselves? What ends up consuming the result var?Extract
All the code in my post goes into \Views\Shared\EditorTemplates\TrippleDDLDateTime.cshtmlSectionalize
B
2

From the beginning I would like to say that what I'm writing here is tested in MVC 4.

I've tried different solutions in order to implement the custom date selector based on 3 drop-down-lists. Everything went perfect but as someone mentioned earlier in this post as a response, there were times when also the standard date validator kicked-in with the standard message (this thing really drove me crazy).

In order to fix this problem and don't disable the standard date validator for good, I've found the following solution:

a) in the custom date model attribute use the following GetClientValidationRules version:

public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        ModelClientValidationRule rule = new ModelClientValidationRule();
        rule.ErrorMessage = this.ErrorMessageString;
        rule.ValidationType = "extendeddate";
        rule.ValidationParameters.Add("isrequired", metadata.IsRequired.ToString().ToLower());
        rule.ValidationParameters.Add("disablestandardvalidation", true.ToString().ToLower());

        yield return rule;
    }

Note: the most important line is the last validation parameter that is added - this will be explained why a bit later. For now, please keep in mind that this will be transformed in an HTML attribute called "data-val-extendeddate-disablestandardvalidation".

b) in an external js file (doesn't matter where - is just an example, but preferable after you loaded all the external libraries), write the following block:

$(document).ready(function () {
var currentCulture = $("meta[name='accept-language']").prop("content");

// Set Globalize to the current culture driven by the meta tag (if any)
if (currentCulture) {
    Globalize.culture(currentCulture);
}

$.validator.methods.date = function (value, element) {
    var isDateValidationDisabled = $(element).data("val-extendeddate-disablestandardvalidation");

    if (typeof isDateValidationDisabled != "undefined") {
        return true;
    }

    var val = Globalize.parseDate(value);
    return this.optional(element) || (val);
};

$.validator.methods.number = function (value, element) {
    var val = Globalize.parseFloat(value);
    return this.optional(element) || ($.isNumeric(val));
}; });

Note: In this code block I'm also loading the globalize jquery plug-in for other possible cultures of my application.

The most interesting part of the last code block is that if I'm finding that data attribute on the validated control, I'm passing the standard validation and I'm returning true.

Bottom line and why I'm doing this - when you have a complex control that should be validated as a whole, the standard validator will not work because it will try to extract the date from each modified drop-down-list. It took me 8 hrs to realize why the standard validator still kicked-in.

Good luck!

PS: I hope you understood my comments - I'm still excited by the fact that I really fixed it!

Besides answered 21/3, 2013 at 15:0 Comment(0)
N
0

I'd like to add to Darin Dmitrov's answer that when you select year, month and then day, the red border for validating year and month still remains active. We expect the two other components to be in sync as well when a valid date is entered so I tweaked the JavaScript as below. (Two functions, removeChildValidationErrors & addChildValidationErrors, are added and called based on the date validation result.)

(function ($) {
    $.fn.getDateFromTrippleDdls = function () {
        var year = this.find('select:nth(0)').val();
        var month = this.find('select:nth(1)').val();
        var day = this.find('select:nth(2)').val();
        if (year == '' || month == '' || day == '') {
            return NaN;
        }

        var y = parseInt(year, 10);
        var m = parseInt(month, 10);
        var d = parseInt(day, 10);

        var date = new Date(y, m - 1, d);
        var isValidDate = date.getFullYear() == y && date.getMonth() + 1 == m && date.getDate() == d;
        if (isValidDate) {
            return date;
        }

        return NaN;
    };

    $.fn.removeChildValidationErrors = function () {

        var year = this.find('select:nth(0)');
        var month = this.find('select:nth(1)');
        var day = this.find('select:nth(2)');

        $(year).removeClass("input-validation-error");
        $(month).removeClass("input-validation-error");
        $(day).removeClass("input-validation-error");


    };

    $.fn.addChildValidationErrors = function () {

        var year = this.find('select:nth(0)');
        var month = this.find('select:nth(1)');
        var day = this.find('select:nth(2)');

        $(year).addClass("input-validation-error");
        $(month).addClass("input-validation-error");
        $(day).addClass("input-validation-error");


    };

    $.validator.unobtrusive.adapters.add('trippleddldate', [], function (options) {
        options.rules['trippleddldate'] = options.params;
        if (options.message) {
            options.messages['trippleddldate'] = options.message;
        }
    });

    $.validator.addMethod('trippleddldate', function (value, element, params) {
        var parent = $(element).closest('.trippleddldatetime');
        var date = parent.getDateFromTrippleDdls();

        if (!isNaN(date))
        {
          parent.removeChildValidationErrors();
        }
        else
        {
           parent.addChildValidationErrors();
        }


        return !isNaN(date);
    }, '');


})(jQuery);

function removeDefaultDateValidators(selector, validatorToRemove) {
    $('form').each(function () {
        var settings = $(this).validate().settings;
        $(selector, this).each(function () {
            // rules and messages seem to be keyed by element name, not id
            var elmName = $(this).attr('name');
            delete settings.rules[elmName][validatorToRemove];
            delete settings.messages[elmName][validatorToRemove];
        });
    });
}

$(function () {
    removeDefaultDateValidators('select[data-val-trippleddldate]', 'date');
});
Nary answered 10/3, 2015 at 3:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.