Search code examples
asp.netasp.net-mvcunobtrusive-validationfluentvalidationmvc-editor-templates

Client-side unobtrusive validation for EditorTemplate when sending IEnumerable<T>


There is a possibility that I just wasn't able to find the solution, or lack thereof, through my searches. Maybe I didn't word it properly, but my problem is trying to get client-side unobtrusive validation to fire on an EditorTemplate when I pass an IEnumerable<T> to it. My setup:

ParentModel.cs

[Validator(typeof(ParentModelValidator))]
public class ParentModel
{
     ...
     public IEnumerable<ChildModel> ChildModels { get; set; }
}

public class ParentModelValidator : AbstractValidator<ParentModel>
{
    public ParentModelValidator()
    {
        RuleFor(x => x.ChildModels).SetCollectionValidator(new ChildModelValidator());
    }
}

ChildModel.cs

[Validator(typeof(ChildModelValidator))]
public class ChildModel
{
     public bool IsRequired { get; set; }
     public string foo { get; set; }
}

public class ChildModelValidator : AbstractValidator<ChildModel>
{
    public ChildModelValidator ()
    {
        RuleFor(x => x.foo)
            .NotEmpty().When(x => x.IsRequired);
    }
}

ParentShell.cshtml

@model ParentModel

@using (Html.BeginForm("Index", "Application", FormMethod.Post))
{
    @Html.AntiForgeryToken()
    @Html.Partial("_Parent", Model)
    @Html.EditorFor(m => m.ChildModels)
    <input type="submit" value="submit" />
}

The _Parent partial just contains a handful of common, reusable @Html.TextBoxFor(m => m.bar) and @Html.ValidationMessageFor(m => m.bar) fields.

ChildModel.cshtml EditorTemplate

@model ChildModel

@Html.TextBoxFor(m => m.foo)
@if (Model.IsRequired)
{
    @Html.ValidationMessageFor(m => m.foo)
}

The client-side validation fires for all fields in the _Parent partial, but I get nothing when IsRequired is true and should have a ValidationMessageFor. Is this a known constraint of the client-side unobtrusive validation with EditorTemplate that receives an IEnumerable<T>? Is it due to the indexer that gets inserted during rendering (ChildModels[0].foo and ChildModels_0__.foo)?


Solution

  • From the documentation for FluentValidation

    Note that FluentValidation will also work with ASP.NET MVC's client-side validation, but not all rules are supported. For example, any rules defined using a condition (with When/Unless), custom validators, or calls to Must will not run on the client side

    Because you have used a .When condition, you will not get client side validation.

    Using an alternative such as foolproof [RequiredIfTrue] attribute will work for a simple property, but not for a complex object or collection.

    You can solve this by creating you own custom ValidationAttribute that implements IClientValidatable

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
    public sealed class ComplexRequiredIfTrue : ValidationAttribute, IClientValidatable
    {
        private const string _DefaultErrorMessage = "The {0} field is required.";
        public string OtherProperty { get; private set; }
        public ComplexRequiredIfTrue(string otherProperty) : base(_DefaultErrorMessage)
        {
            if (string.IsNullOrEmpty(otherProperty))
            {
                throw new ArgumentNullException("otherProperty");
            }
            OtherProperty = otherProperty;
        }
    
        public override string FormatErrorMessage(string name)
        {
            return string.Format(ErrorMessageString, name, OtherProperty);
        }
    
        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            if (value == null)
            {
                PropertyInfo otherProperty = validationContext.ObjectInstance.GetType().GetProperty(OtherProperty);
                bool isRequired = (bool)otherProperty.GetValue(validationContext.ObjectInstance, null);
                if (isRequired)
                {
                    return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
                }
            }
            return ValidationResult.Success;
        }
    
        public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
        {
            var clientValidationRule = new ModelClientValidationRule()
            {
                ErrorMessage = FormatErrorMessage(metadata.GetDisplayName()),
                ValidationType = "complexrequirediftrue"
            };
            clientValidationRule.ValidationParameters.Add("otherproperty", OtherProperty);
            return new[] { clientValidationRule };
        }
    }
    

    and the associated script

    function nameToIndex (value) {
      return value.replace(/[\[\].]/g, '_');
    }
    
    (function ($) {
      $.validator.addMethod("complexrequirediftrue", function (value, element, params) {
        // We need to get the prefix of the control we are validating
        // so we can get the corresponding 'other property'
        var name = $(element).attr('name');
        var index = name.lastIndexOf('.');
        var prefix = nameToIndex(name.substr(0, index + 1));
        var otherProp = $('#' + prefix + params);
        if (otherProp.val() == "True" && !value) {
          return false;
        }
        return true;
      });
      $.validator.unobtrusive.adapters.addSingleVal("complexrequirediftrue", "otherproperty");
    }(jQuery));
    

    then apply it to you property

    public class ChildModel
    {
      public bool IsRequired { get; set; }
      [ComplexRequiredIfTrue("IsRequired")]
      public string foo { get; set; }
    }
    

    and in the EditorTemplate, include @Html.HiddenFor(m => m.IsRequired)

    @model ChildModel
    @Html.HiddenFor(m => m.IsRequired)
    @Html.TextBoxFor(m => m.foo)
    @Html.ValidationMessageFor(m => m.foo)
    

    Edit: Further to comments, if the controller is

    model.ChildModels = new List<ChildModel>() { new ChildModel() { IsRequired = true }, new ChildModel() };
    return View(model);
    

    then the html generated when the submit button is clicked is:

    <input data-val="true" data-val-required="The IsRequired field is required." id="ChildModels_0__IsRequired" name="ChildModels[0].IsRequired" type="hidden" value="True">
    <input class="input-validation-error" data-val="true" data-val-complexrequirediftrue="The foo field is required." data-val-complexrequirediftrue-otherproperty="IsRequired" id="ChildModels_0__foo" name="ChildModels[0].foo" type="text" value="">
    <span class="field-validation-error" data-valmsg-for="ChildModels[0].foo" data-valmsg-replace="true">The foo field is required.</span>
    <input data-val="true" data-val-required="The IsRequired field is required." id="ChildModels_1__IsRequired" name="ChildModels[1].IsRequired" type="hidden" value="False">
    <input data-val="true" data-val-complexrequirediftrue="The foo field is required." data-val-complexrequirediftrue-otherproperty="IsRequired" id="ChildModels_1__foo" name="ChildModels[1].foo" type="text" value="">
    <span class="field-validation-valid" data-valmsg-for="ChildModels[1].foo" data-valmsg-replace="true"></span>
    

    Note the form did not submit and the error message was displayed for the first textbox