Custom JQuery Unobtrusive Validation

Requirements

  • Custom validation attribute that contains the server-side validation logic
  • AttributeAdapter which is responsible for rendering data-val-* attributes on the HTML form control so that it can take part in client side validation
  • AttributeAdapterProvider registers the adapter with the DI/validation system. The framework itself registers multiple adapters with one adapter provider: ValidationAttributeAdapterProvider

Custom Validation Attribute

using System.ComponentModel.DataAnnotations;
namespace IntranetCore.Web.ValidationAttributes;

/// <summary>
/// Requires all properties to be set if any of the properties are set
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class RequireAllIfAnyAttribute(params string[] propertyNames) : ValidationAttribute
{
    public string[] PropertyNames { get; } = propertyNames;

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        if (PropertyNames.Any(prop => !string.IsNullOrWhiteSpace(validationContext.ObjectType.GetProperty(prop).GetValue(validationContext.ObjectInstance)?.ToString())))
        {
            bool allFilled = PropertyNames.All(prop => !string.IsNullOrWhiteSpace(validationContext.ObjectType.GetProperty(prop).GetValue(validationContext.ObjectInstance)?.ToString()));

            if (!allFilled)
            {
                return new ValidationResult(ErrorMessage ?? "All Proof Email fields (Template, From Address, Subject) are required if any one is filled.");
            }
        }
        return ValidationResult.Success;
    }
}

This code defines a custom validation attribute named RequireAllIfAnyAttribute in C#. The purpose of this attribute is to enforce a rule where if any of the specified properties are filled in (i.e., not empty or whitespace), then all of the specified properties must be filled in.

Here's a breakdown of how it works:

  1. Constructor:

    • The constructor takes an array of property names (params string[] propertyNames) that need to be validated together.
    • The propertyNames array is stored in the private field propertyNames.
  2. IsValid Method:

    • This is the main validation logic. It overrides the IsValid method from the base ValidationAttribute class.
  3. Check if any properties are filled:

    • The code uses LINQ's Any method to check if any of the specified properties (propertyNames) are filled (i.e., not null, empty, or whitespace).
    • If none of the properties are filled, the validation passes (returning ValidationResult.Success).
  4. Check if all properties are filled:

    • If at least one property is filled, it then checks if all specified properties are filled using LINQ's All method.
    • If not all properties are filled, it returns a ValidationResult with an error message indicating that all specified fields are required if any one is filled.

AttributeAdapter

This code defines a class RequireAllIfAnyAttributeAdapter, which acts as an adapter for the RequireAllIfAnyAttribute custom validation attribute. The adapter class is used to integrate the server-side validation attribute with client-side validation in ASP.NET Core.

using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.Extensions.Localization;

namespace IntranetCore.Web.ValidationAttributes;

public class RequireAllIfAnyAttributeAdapter(RequireAllIfAnyAttribute attribute, IStringLocalizer stringLocalizer) :  
 
    AttributeAdapterBase<RequireAllIfAnyAttribute>(attribute, stringLocalizer)
{
    public override void AddValidation(ClientModelValidationContext context)
    {
        MergeAttribute(context.Attributes, "data-val", "true");
        MergeAttribute(context.Attributes, "data-val-require-all-if-any", GetErrorMessage(context));
        var attribute = context.ModelMetadata.ValidatorMetadata.OfType<RequireAllIfAnyAttribute>().FirstOrDefault(); 
        if (attribute != null) 
        { 
            MergeAttribute(context.Attributes, "data-val-require-all-if-any-properties", string.Join(",", attribute.PropertyNames)); 
        }
    }

    public override string GetErrorMessage(ModelValidationContextBase validationContext) =>
        GetErrorMessage(validationContext.ModelMetadata, validationContext.ModelMetadata.GetDisplayName());
    
}

Here's how it works:

  1. Constructor:

    • The constructor takes an instance of RequireAllIfAnyAttribute and an IStringLocalizer.
    • It passes these parameters to the base class AttributeAdapterBase, which handles the localization of validation messages.
  2. AddValidation Method:

    • This method is overridden to add the necessary HTML5 data attributes for client-side validation.
    • MergeAttribute is a helper method that adds or updates the specified attribute in the context.Attributes dictionary.
      • It adds the data-val attribute with the value "true" to indicate that client-side validation is enabled.
      • It adds the data-val-require-all-if-any attribute with the value returned by the GetErrorMessage method. This attribute holds the error message to be displayed if the validation fails.
  3. GetErrorMessage Method:

    • This method is overridden to provide the error message for the client-side validation.
    • It calls the base class's GetErrorMessage method, passing the model metadata and display name of the property being validated.

Attribute Adapter Provider

The RequireAllIfAnyAdapterProvider class implements the IValidationAttributeAdapterProvider interface. Its purpose is to provide a custom validation adapter for a specific attribute (RequireAllIfAnyAttribute). If the attribute is not RequireAllIfAnyAttribute, it falls back to a base provider for other attributes.

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.Extensions.Localization;

public class RequireAllIfAnyAdapterProvider : IValidationAttributeAdapterProvider
{
    private readonly IValidationAttributeAdapterProvider baseProvider = new ValidationAttributeAdapterProvider();
    public IAttributeAdapter GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer stringLocalizer)
    {
        if (attribute is RequireAllIfAnyAttribute)
        {
            return new RequireAllIfAnyAttributeAdapter(attribute as RequireAllIfAnyAttribute, stringLocalizer);
        }
        else
        {
            return baseProvider.GetAttributeAdapter(attribute, stringLocalizer);
        }
    }
}

Register the AdapterProvider

builder.Services.AddSingleton<IValidationAttributeAdapterProvider, RequireAllIfAnyAdapterProvider>();

Extend jQuery Validation

Add a validation method and an adapter

$.validator.addMethod('require-all-if-any', function (value, element, params) {
    var properties = params.properties.split(',');
    var allFilled = true;
    var anyFilled = false;
    for (var i = 0; i < properties.length; i++) {
        var propertyValue = $('#' + properties[i]).val();
        if (propertyValue) {
            anyFilled = true; 
        } else {
            allFilled = false;
        }
    } if (anyFilled && !allFilled) {
        return false;
    }
    return true;
});

$.validator.unobtrusive.adapters.add('require-all-if-any', ['properties'], function (options) {
    options.rules['require-all-if-any'] = { properties: options.params.properties };
    options.messages['require-all-if-any'] = options.message;
});

$.validator.unobtrusive.adapters.add

The add method is used to create a custom adapter for an unobtrusive validation attribute. It allows you to define how your custom validation attribute should be interpreted and handled by the jQuery Unobtrusive Validation framework.

The add method takes three parameters:

  • Adapter Name: 'require-all-if-any' - This is the name of the custom validation method.
  • Dependent Parameters: ['properties'] - This array lists the names of the additional parameters that should be passed to the custom validation method. In this case, it's the properties parameter.
  • Adapter Function: A function that defines how the validation rules and messages should be set up.

Adapter Function

The adapter function receives an options object, which contains various properties related to the validation, including parameters and messages.

  1. options.rules:

    • options.rules is an object that holds the validation rules for the element.
    • options.rules['require-all-if-any'] adds a new validation rule named require-all-if-any.
    • { properties: options.params.properties } sets the properties parameter for this rule. The options.params.properties value comes from the data-val-require-all-if-any-properties attribute that you added to the HTML element.
  2. options.messages:

    • options.messages is an object that holds the validation messages for the element.
    • options.messages['require-all-if-any'] sets the validation message for the require-all-if-any rule.
    • options.message contains the error message specified in the attribute (e.g., "All fields (Proof Email Template, Proof From Email Address, Proof Email Subject) are required if a value is provided for any one of them").

IClientModelValidator

As an alternative to creating an AttributeAdapter and AttributeAdapterProvider, you can implement IClientModelValidator in the ValidationAttribute, which requires you to include the AddValidation method there:

public void AddValidation(ClientModelValidationContext context)
{
    context.Attributes.TryAdd("data-val", "true");
    context.Attributes.TryAdd("data-val-required-if", ErrorMessage ?? $"This field is required");
    context.Attributes.TryAdd("data-val-required-if-property-name", PropertyName);
    context.Attributes.TryAdd("data-val-required-if-expected-val", ExpectedValue?.ToString());
}

The AttributeAdapter approach is generally recommended as it separates server side validation (in the attribute) with client validation concerns (rendering data-val-* attributes).

Last updated: 1/16/2025 12:00:12 PM