Question

I'm still fairly new to ASP.NET and MVC and despite days of googling and experimenting, I'm drawing a blank on the best way to solve this problem.

I wrote a BirthdayAttribute that I want to work similar to the EmailAddressAttribute. The birthday attribute sets the UI hint so that the birthday DateTime will be rendered using an editor template that has 3 dropdown lists. The attribute can also be used to set some additional meta data that tells the year dropdown how many years it should display.

I know I could use jQuery's date picker, but in the case of a birthday I find the 3 dropdowns much more usable.

@model DateTime
@using System;
@using System.Web.Mvc;
@{
    UInt16 numberOfVisibleYears = 100;
    if (ViewData.ModelMetadata.AdditionalValues.ContainsKey("NumberOfVisibleYears"))
    {
        numberOfVisibleYears = Convert.ToUInt16(ViewData.ModelMetadata.AdditionalValues["NumberOfVisibleYears"]);
    }
    var now = DateTime.Now;
    var years = Enumerable.Range(0, numberOfVisibleYears).Select(x => new SelectListItem { Value = (now.Year - x).ToString(), Text = (now.Year - x).ToString() });
    var months = Enumerable.Range(1, 12).Select(x => new SelectListItem{ Text = new DateTime( now.Year, x, 1).ToString("MMMM"), Value = x.ToString() });
    var days = Enumerable.Range(1, 31).Select(x => new SelectListItem { Value = x.ToString("00"), Text = x.ToString() });
}

@Html.DropDownList("Year", years, "<Year>") /
@Html.DropDownList("Month", months, "<Month>") /
@Html.DropDownList("Day", days, "<Day>")

I also have a ModelBinder to rebuild my date afterwards. I've removed the content of my helper functions for brevity, but everything works great up to this point. Normal, valid dates, work just fine for creating or editing my members.

public class DateSelector_DropdownListBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        if (controllerContext == null)
            throw new ArgumentNullException("controllerContext");
        if (bindingContext == null)
            throw new ArgumentNullException("bindingContext");

        if (IsDropdownListBound(bindingContext))
        {
            int year    = GetData(bindingContext, "Year");
            int month   = GetData(bindingContext, "Month");
            int day     = GetData(bindingContext, "Day");

            DateTime result;
            if (!DateTime.TryParse(string.Format("{0}/{1}/{2}", year, month, day), out result))
            {
                //TODO: SOMETHING MORE USEFUL???
                bindingContext.ModelState.AddModelError("", string.Format("Not a valid date."));
            }

            return result;
        }
        else
        {
            return base.BindModel(controllerContext, bindingContext);
        }

    }

    private int GetData(ModelBindingContext bindingContext, string propertyName)
    {
        // parse the int using the correct value provider
    }

    private bool IsDropdownListBound(ModelBindingContext bindingContext)
    {
        //check model meta data UI hint for above editor template
    }
}

Now that I'm looking at it, I should probably be using a nullable DateTime, but that's neither here nor there.

The problem I'm having is with very basic validation of invalid dates such as February 30th, or September 31st. The validation itself works great, but the invalid dates aren't ever saved and persisted when the form is reloaded.

What I'd like is to remember the invalid date of February 30th and redisplay it with the validation message instead of resetting the dropdowns to their default value. Other fields, like the email address (decorated with the EmailAddressAttribute) preserve invalid entries just fine out of the box.

At the moment I am just trying to get the server side validation working. To be honest, I haven't even started thinking about the client side validation yet.

I know there is lots I could do with javascript and ajax to make this problem a moot point, but I would still rather have the proper server side validation in place to fall back on.

Was it helpful?

Solution

I finally managed to solve my problem, so I wanted to share my solution.

DISCLAIMER: Although I used to be great with .NET 2.0 back in the day, I'm only now updating my skills to the latest versions of C#, ASP.NET, MVC, and Entity Framework. If there are better ways to do anything I've done below please I'm always open to feedback.

TODO:

  • Implement client side validation for invalid dates such as February 30th. Client side validation for [Required] attribute is already built in.

  • Add support for cultures so that the date shows up in desired format

The solution came to me when I realized that the problem I was having is that DateTime will not allow itself to be constructed with an invalid date such as February 30th. It simply throws an exception. If my date wouldn't construct, I knew of no way to pass my invalid data back through the binder to the ViewModel.

To solve this problem, I had to do away with the DateTime in my view model and replace it with my own custom Date class. The solution below will provide fully functioning server side validation in the event that Javascript is disabled. In the event of a validation error the invalid selections will persist after the validation message is displayed allowing the user to easily fix their mistake.

It should be easy enough to map this view-ish Date class to the DateTime in your date model.

Date.cs

public class Date
{
    public Date() : this( System.DateTime.MinValue ) {}
    public Date(DateTime date)
    {
        Year = date.Year;
        Month = date.Month;
        Day = date.Day;
    }

    [Required]
    public int Year  { get; set; }

    [Required, Range(1, 12)]
    public int Month { get; set; }

    [Required, Range(1, 31)]
    public int Day   { get; set; }

    public DateTime? DateTime
    {
        get
        {
            DateTime date;
            if (!System.DateTime.TryParseExact(string.Format("{0}/{1}/{2}", Year, Month, Day), "yyyy/M/d", CultureInfo.InvariantCulture, DateTimeStyles.None, out date))
                return null;
            else
                return date;
        }
    }
}

This is just a basic date class that you can construct from a DateTime. The class has properties for Year, Month, and Day as well as a DateTime getter that can try to retrieve you a DateTime class assuming you have a valid date. Otherwise it returns null.

When the built in DefaultModelBinder is mapping your form back to this Date object, it will take care of the Required and Range validation for you. However, we will need a new ValidationAtribute to make sure that invalid dates such as February 30th aren't allowed.

DateValidationAttribute.cs

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
public sealed class DateValidationAttribute : ValidationAttribute
{
    public DateValidationAttribute(string classKey, string resourceKey) :
        base(HttpContext.GetGlobalResourceObject(classKey, resourceKey).ToString()) { }

    public override bool IsValid(object value)
    {
        bool result = false;
        if (value == null)
            throw new ArgumentNullException("value");

        Date toValidate = value as Date;

        if (toValidate == null)
            throw new ArgumentException("value is an invalid or is an unexpected type");

        //DateTime returns null when date cannot be constructed
        if (toValidate.DateTime != null)
        {
            result = (toValidate.DateTime != DateTime.MinValue) && (toValidate.DateTime != DateTime.MaxValue);
        }

        return result;
    }
}

This is a ValidationAttribute that you can put on your Date fields and properties. If you pass in the resource file class and the resource key it will search the corresponding resource file in your "App_GlobalResources" folder for the error message.

Inside the IsValid method, once we're sure we're validating a Date we check it's DateTime property to see if it's not null to confirm that it's valid. I throw in a check for DateTime.MinValue and MaxValue for good measure.

So that's about it really. With this Date class, I managed to do away completely with the custom ModelBinder. This solution relies completely on the DefaultModelBinder, which means all of the validation works right out of the box. It apparently even checks my new DateValidationAttribute, which I was super excited about. I stressed forever thinking I might have to muck with validators in a custom binder. This feels a lot cleaner.

Here is the complete code for the partial view I'm using.

DateSelector_DropdownList.cshtml

@model Date
@{
    UInt16 numberOfVisibleYears = 100;
    if (ViewData.ModelMetadata.AdditionalValues.ContainsKey("NumberOfVisibleYears"))
    {
        numberOfVisibleYears = Convert.ToUInt16(ViewData.ModelMetadata.AdditionalValues["NumberOfVisibleYears"]);
    }
    var now = DateTime.Now;
    var years = Enumerable.Range(0, numberOfVisibleYears).Select(x => new SelectListItem { Value = (now.Year - x).ToString(), Text = (now.Year - x).ToString() });
    var months = Enumerable.Range(1, 12).Select(x => new SelectListItem { Text = new DateTime(now.Year, x, 1).ToString("MMMM"), Value = x.ToString() });
    var days = Enumerable.Range(1, 31).Select(x => new SelectListItem { Value = x.ToString(), Text = x.ToString() });
}

@Html.DropDownList("Year", years, "<Year>") /
@Html.DropDownList("Month", months, "<Month>") /
@Html.DropDownList("Day", days, "<Day>")

I'll also include the attribute I use that sets up the template hint and the number of visible years to show.

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public sealed class DateSelector_DropdownListAttribute : DataTypeAttribute, IMetadataAware
{
    public DateSelector_DropdownListAttribute() : base(DataType.Date) { }

    public void OnMetadataCreated(ModelMetadata metadata)
    {
        metadata.AdditionalValues.Add("NumberOfVisibleYears", NumberOfVisibleYears);
        metadata.TemplateHint = TemplateHint;
    }

    public string TemplateHint { get; set; }
    public int NumberOfVisibleYears { get; set; }
}

I think the solution turned out a lot cleaner than I expected it to. It solves all of my problems in the exact way that I was hoping to. I do wish that I was somehow able to keep the DateTime, but this is the only way I could figure out how to maintain an invalid selection using only server side code.

Are there any improvements you would make?

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top