Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Group validation messages for multiple properties together into one message asp.net mvc

I have a view model that has year/month/day properties for someone's date of birth. All of these fields are required. Right now, if someone doesn't enter anything for the date of birth they get 3 separate error messages.

Date of birth fields

What I want to do is somehow group those error messages together into 1 message that just says 'Date of birth is required'. So if 1 or more of those fields are blank, they will always just get the 1 validation message.

I NEED this to work on client-side validation via jquery validate and unobtrusive validate. I know this is possible with the jquery validate plugin by looking at this question. But I don't know how to achieve this with asp.net mvc using validation attributes on my model and unobtrusive validation. Hopefully there's some built in way to group properties for validation purposes, but if not can this be done with a custom validation attribute?

Here's what my existing model and view looks like:

The Model:

public class MyModel {
    [Required(ErrorMessage = "Year is required")]
    public int Year { get; set; }
    [Required(ErrorMessage = "Month is required")]
    public int Month { get; set; }
    [Required(ErrorMessage = "Day is required")]
    public int Day { get; set; }
}

The View:

<div>
    <label>Date of birth: <span style="color:red;">*</span></label>
    <div>@Html.DropDownListFor(m => m.Year, ApplicationModel.GetSelectListForDateRange(DateTime.Today.Year - 16, DateTime.Today.Year - 10), "", new{data_description="birthDate"})@Html.LabelFor(m => m.StudentBirthYear)</div>
    <div>@Html.DropDownListFor(m => m.Month, ApplicationModel.GetSelectListForDateRange(1, 12, true), "", new{data_description="birthDate"})@Html.LabelFor(m => m.StudentBirthMonth)</div>
    <div>@Html.DropDownListFor(m => m.Day, ApplicationModel.GetSelectListForDateRange(1, 31), "", new{data_description="birthDate"})@Html.LabelFor(m => m.StudentBirthDay)</div>
</div>
<div class="error-container">@Html.ValidationMessageFor(m => m.Year)</div>
<div class="error-container">@Html.ValidationMessageFor(m => m.Month)</div>
<div class="error-container">@Html.ValidationMessageFor(m => m.Day)</div>
like image 839
bbak Avatar asked Sep 10 '13 17:09

bbak


1 Answers

I am somewhat late to the party (only couple of years) still...

Most appropriate solution is indeed creating a CustomAttribute but instead of giving you good advice an leaving to die I will show you how.

Custom Attribute:

public class GroupRequiredAttribute : ValidationAttribute, IClientValidatable
{
    private readonly string[] _serverSideProperties;

    public GroupRequiredAttribute(params string[] serverSideProperties)
    {
        _serverSideProperties = serverSideProperties;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        if (_serverSideProperties == null || _serverSideProperties.Length < 1)
        {
            return null;
        }

        foreach (var input in _serverSideProperties)
        {
            var propertyInfo = validationContext.ObjectType.GetProperty(input);
            if (propertyInfo == null)
            {
                return new ValidationResult(string.Format("unknown property {0}", input));
            }

            var propertyValue = propertyInfo.GetValue(validationContext.ObjectInstance, null);
            if (propertyValue is string && !string.IsNullOrEmpty(propertyValue as string))
            {
                return null;
            }

            if (propertyValue != null)
            {
                return null;
            }
        }

        return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
    }

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

        rule.ValidationParameters["grouprequiredinputs"] = string.Join(",", this._serverSideProperties);

        yield return rule;
    }
}

ViewModel: Decorate only one field on your viewModel like following:

    [GroupRequired("Year", "Month", "Day", ErrorMessage = "Please enter your date of birth")]
    public int? Year { get; set; }

    public int? Month { get; set; }

    public int? Day { get; set; }

Jquery: You will need to add adapters in my case it's jquery.validate.unobtrusive.customadapters.js or wherever you register your adapters (you might put this on the page just do it after unobtrusive validation runs).

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

jQuery.validator.addMethod('grouprequired', function (value, element, params) {
    var inputs = params.grouprequiredinputs.split(',');
    var values = $.map(inputs, function (input, index) {
        var val = $('#' + input).val();
        return val != '' ? input : null;
    });
    return values.length == inputs.length;
});

and that should do it.

For those who are interested what this does: In C# land it grabs the ids of fields glues them with , and puts into custom attribute on Year field.

HTML should look something like this (If it doesn't debug C# attribute):

<input class="tooltip form-control input dob--input-long" data-val="true" data-val-grouprequired="Please enter your date of birth" data-val-grouprequired-grouprequiredinputs="Year,Month,Day" name="Year" placeholder="YYYY" tabindex="" type="text" value="">

Then Jquery validation splits them back into id's and checks if all of them are not empty and that's pretty much it.

You will want to mark fields as invalid somehow (now it would only mark the field attribute is sitting on) most appropriate solution IMHO is to wrap all fields in container with class field-error-wrapper and then add following to your page after Jquery validation is loaded:

$.validator.setDefaults({
    highlight: function (element) {
        $(element).closest(".field-error-wrapper").addClass("input-validation-error");
    },
    unhighlight: function (element) {
        $(element).closest(".field-error-wrapper").removeClass("input-validation-error");
    }
});

instead of marking field it will mark container and then you can write your css in a way that if container is marked with .input-validation-error then all fields inside turn red. I think my job here is done.

EDIT: Ok so there appears to be one more issue where fields get unmarked because validator thinks that day and month are valid and it needs to remove invalid class from parent, validator first marks invalid fields then unmarks valid which causes validation not to get highlighted, so I changed the sequence in which validation happens, I wouldn't recommend overriding this globally (cause I am not sure on what catastrophic side affects it might have) just paste it on the page where you have birthdate fields.

$(function () {
    $.data($('form')[0], 'validator').settings.showErrors = function () {
        if (this.settings.unhighlight) {
            for (var i = 0, elements = this.validElements() ; elements[i]; i++) {
                this.settings.unhighlight.call(this, elements[i], this.settings.errorClass, this.settings.validClass);
            }
        }
        this.hideErrors();
        for (var i = 0; this.errorList[i]; i++) {
            var error = this.errorList[i];
            this.settings.highlight && this.settings.highlight.call(this, error.element, this.settings.errorClass, this.settings.validClass);
            this.showLabel(error.element, error.message);
        }
        if (this.errorList.length) {
            this.toShow = this.toShow.add(this.containers);
        }
        if (this.settings.success) {
            for (var i = 0; this.successList[i]; i++) {
                this.showLabel(this.successList[i]);
            }
        }
        this.toHide = this.toHide.not(this.toShow);

        this.addWrapper(this.toShow).show();
    };
});

Hope this saves you some time.

like image 156
Matas Vaitkevicius Avatar answered Oct 20 '22 20:10

Matas Vaitkevicius