Search code examples
c#datetimeasp.net-mvc-5unobtrusive-validation

MVC 5: Validation of two DateTime properties entered as separate fields - day, month, year


Top-level goal: I need to calculate the age of a person by entering 2 dates - date of birth and date of death.

Goal details: These dates should be entered as separate fields - day, month, year (as shown on image - The way I enter two DateTime fields). These fields should be validated for range (day - 1..31, month - 1..12, year - 1900..current) and for relation - date of death should be greater than date of birth.

Question: How can I validate relation of 2 dates, display error messages if I don't create EditorFor the DateOfDeath3 field (Date3GreaterThanAnother validation attribute is applied on this field)? I need to validate date, but I display only its parts - day, month, year.

Maybe I've selected wrong approach for doing all this, please, give me a hint how it can be done better...

Thank you!

Current implementation details: I've already implemented dates entering, saving, validation (only server-side).

In order to implement DateTime entering as separate fields I use the following class:

public class Date3
{
    /// <summary>
    /// Creates Date3 instance with internal DateTime object set to null.
    /// </summary>
    public Date3()
    {
        DateTime = null;
    }

    public Date3(DateTime dateTime)
    {
        DateTime = dateTime;

        Day = dateTime.Day;
        Month = dateTime.Month;
        Year = dateTime.Year;
    }

    public DateTime? DateTime { get; private set; }

    /// <summary>
    /// Recreates inner DateTime object with specified Day, Month and Year properties.
    /// </summary>
    public void UpdateInner()
    {
        if (Day.HasValue && Month.HasValue && Year.HasValue)
            DateTime = new DateTime(Year.Value, Month.Value, Day.Value);
    }

    public override string ToString()
    {
        return DateTime.ToString();
    }

    [Range(1, 31, ErrorMessage = "Day number should be from 1 to 31.")]
    public int? Day { get; set; }

    [Range(1, 12, ErrorMessage = "Month number should be from 1 to 12.")]
    public int? Month { get; set; }

    [RangeCurrentYear(1900, ErrorMessage = "Year number should be from 1900 to current year.")]
    public int? Year { get; set; }
}

This "wrapper" for DateTime is used in view model which has the following code:

public class ViewModel
{
    ...

    public Date3 DateOfBirth3 { get; set; }

    [Date3GreaterThanAnother("DateOfBirth3", "Date of death should be after date of birth.")]
    public Date3 DateOfDeath3 { get; set; }

    ...
}

The Date3GreaterThanAnother validation attribute class looks like this (I've tried to implement client-side validation that's why it implements IClientValidatable interface):

[AttributeUsage(AttributeTargets.Property)]
public class Date3GreaterThanAnotherAttribute : ValidationAttribute, IClientValidatable
{
    private readonly string _anotherProperty;
    public Date3GreaterThanAnotherAttribute(string anotherDatePropertyName, string errorMessage = null)
    {
        _anotherProperty = anotherDatePropertyName;
        if(errorMessage != null)
            ErrorMessage = errorMessage;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var property = validationContext.ObjectType.GetProperty(_anotherProperty);
        if (property == null)
            return new ValidationResult($"Unknown property {_anotherProperty}", new string[] {_anotherProperty});

        var otherDateValue = property.GetValue(validationContext.ObjectInstance, null);
        if (!(otherDateValue is Date3 otherDate3))
            return new ValidationResult($"Other property is not of Date3 type.");

        var date3 = (Date3)value;

        otherDate3.UpdateInner();
        date3.UpdateInner();

        if (otherDate3.DateTime >= date3.DateTime)
            return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));

        return ValidationResult.Success;
    }

    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        var rule = new ModelClientValidationRule()
        {
            ErrorMessage = FormatErrorMessage(metadata.GetDisplayName()),
            ValidationType = "greaterthananotherdate"
        };
        rule.ValidationParameters.Add("otherdate", _anotherProperty);

        yield return rule;
    }
}

And finally, this is how I display it in View page (non-relevant markup code is removed):

...
    @Html.LabelFor(x => x.Estate.DateOfBirth3.Day, "Date of Birth", new {@class = "control-label col-md-2 control-label-details"})
    <div class="col-md-3">
        @Html.EditorFor(x => x.Estate.DateOfBirth3.Day, new {htmlAttributes = new {@class = "form-control date3 day", placeholder = "DD"}})
        @Html.ValidationMessageFor(x => x.Estate.DateOfBirth3.Day)
        /
        @Html.EditorFor(x => x.Estate.DateOfBirth3.Month, new {htmlAttributes = new {@class = "form-control date3 month", placeholder = "MM"}})
        @Html.ValidationMessageFor(x => x.Estate.DateOfBirth3.Month)
        /
        @Html.EditorFor(x => x.Estate.DateOfBirth3.Year, new {htmlAttributes = new {@class = "form-control date3 year", placeholder = "YYYY"}})
        @Html.ValidationMessageFor(x => x.Estate.DateOfBirth3.Year)
    </div>
...
    @Html.LabelFor(x => x.Estate.DateOfDeath3.Day, "Date of Death", new { @class = "control-label col-md-2 control-label-details" })
    <div class="col-md-3">
        @Html.EditorFor(x => x.Estate.DateOfDeath3.Day, new { htmlAttributes = new { @class = "form-control date3 day", placeholder = "DD" } })
        @Html.ValidationMessageFor(x => x.Estate.DateOfDeath3.Day)
        /
        @Html.EditorFor(x => x.Estate.DateOfDeath3.Month, new { htmlAttributes = new { @class = "form-control date3 month", placeholder = "MM" } })
        @Html.ValidationMessageFor(x => x.Estate.DateOfDeath3.Month)
        /
        @Html.EditorFor(x => x.Estate.DateOfDeath3.Year, new { htmlAttributes = new { @class = "form-control date3 year", placeholder = "YYYY" } })
        @Html.ValidationMessageFor(x => x.Estate.DateOfDeath3.Year)
    </div>
    @Html.ValidationMessageFor(x => x.Estate.DateOfDeath3)
...

If JS code is necessary - I found the solutions on stackoverflow regarding unobtrusive JQuery validation but didn't implement it since I am unable to make it running because of my MVC-related problems.

Without any usage of DateOfDeath3 in View page unobtrusive validation is not working. I've tried to add HiddenFor for DateOfDeath3, unobtrusive jquery validation started working but in Date3GreaterThanAnother class I caught exceptions for value parameter = null.

$.validator.addMethod("greaterthananotherdate",
    function (value, element, params) {
        // TODO: implement validation logic.
        console.log("validation method triggered");
        return false;
    }, 'ERROR');


$.validator.unobtrusive.adapters.add(
    "greaterthananotherdate",
    ["otherdate"],
    function (options) {
        console.log("adapter triggered");
        options.rules["greaterthananotherdate"] = "#" + options.params.otherdate;
        options.messages["greaterthananotherdate"] = options.message;
    });

Solution

  • Consider the following approach:

    Here is your model:

    public class AgeModel: IValidatableObject
    {
        [DataType(DataType.Date)]
        public DateTime Birth { get; set; }
    
        [DataType(DataType.Date)]
        public DateTime Death { get; set; }
    
        public TimeSpan Age
        {
            get
            {
                return this.Death.Subtract(this.Birth);
            }
        }
    
        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            if (this.Death < this.Birth)
                yield return new ValidationResult("It died before birth", new[] { nameof(this.Death) });
    
            if (this.Birth > this.Death)
                yield return new ValidationResult("It born after death", new[] { nameof(this.Birth) });
        }
    }
    

    Here is your view:

    @using (Html.BeginForm("Age", "User", FormMethod.Post))
    {
        <br />
        @Html.EditorFor(m => m.Birth)
        @Html.ValidationMessageFor(m => m.Birth)
        <br />
        @Html.EditorFor(m => m.Death)
        @Html.ValidationMessageFor(m => m.Death)
        <br />
        @Html.ValidationSummary();
        <br />
        <input type="submit" value="Submit" />
    }
    

    There is no need in wrapper over DateTime, and all the controls are rendered automatically