haacked / aspnet-client-validation

A client validation library for ASP.NET MVC that does not require jQuery

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Custom validator for "differs to"

lonix1 opened this issue · comments

@haacked, @dahlbyk

I needed a custom validator that is the opposite of [Compare], i.e. validates that two inputs differ. My use case was a password change form: new and old passwords must differ. Was very tricky to get it working on both server and client sides.

Posting my solution here in case it helps someone else (and for my own future reference! 😆).

DiffersToAttribute.cs

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class DiffersToAttribute : ValidationAttribute, IClientModelValidator
{

  // based on
  // https://github.com/haacked/aspnet-client-validation/blob/062c8433f6c696bc41cd5d6811c840905c63bc9c/README.MD#adding-custom-validation
  // https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-7.0#custom-client-side-validation
  // https://github.com/dotnet/runtime/blob/dff486f2d78d3f932d0f9bfa38043f85e358fb8c/src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/CompareAttribute.cs
  // https://github.com/dotnet/aspnetcore/blob/d0ca5a8d20ac50a33d5451e998a5d411a810c8d7/src/Mvc/Mvc.DataAnnotations/src/CompareAttributeAdapter.cs

  private const string _errorMessage = "'{0}' and '{1}' must differ.";
  public override bool RequiresValidationContext => true;
  public string OtherProperty { get; }
  public string? OtherPropertyDisplayName { get; internal set; }

  public DiffersToAttribute(string otherProperty) : base(_errorMessage)
  {
    if (string.IsNullOrWhiteSpace(otherProperty)) throw new ArgumentNullException(nameof(otherProperty));
    OtherProperty = otherProperty;
  }

  public override string FormatErrorMessage(string name) =>
    string.Format(CultureInfo.CurrentCulture, ErrorMessageString, name, OtherPropertyDisplayName ?? OtherProperty);

  protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
  {
    var otherPropertyInfo = validationContext.ObjectType.GetRuntimeProperty(OtherProperty);

    if (otherPropertyInfo == null)
      return new ValidationResult(string.Format("Could not find a property named {0}.", OtherProperty));
    if (otherPropertyInfo.GetIndexParameters().Length > 0)
      throw new ArgumentException(string.Format("The property {0}.{1} could not be found.", validationContext.ObjectType.FullName, OtherProperty));

    var otherPropertyValue = otherPropertyInfo.GetValue(validationContext.ObjectInstance, null);

    if (Equals(value, otherPropertyValue))
    {
      OtherPropertyDisplayName ??= GetDisplayNameForProperty(otherPropertyInfo);
      var memberNames = validationContext.MemberName != null
         ? new[] { validationContext.MemberName }
         : null;
      return new ValidationResult(FormatErrorMessage(validationContext.MemberName ?? validationContext.DisplayName), memberNames);
    }

    return null;
  }

  private string? GetDisplayNameForProperty(PropertyInfo property)
  {
    var attributes = CustomAttributeExtensions.GetCustomAttributes(property, true);
    foreach (Attribute attribute in attributes)
    {
      if (attribute is DisplayAttribute display)
        return display.GetName();
    }
    return OtherProperty;
  }

  public void AddValidation(ClientModelValidationContext context)
  {
    ArgumentNullException.ThrowIfNull(context, nameof(context));
    MergeAttribute(context.Attributes, "data-val", "true");
    MergeAttribute(context.Attributes, "data-val-differsto", FormatErrorMessage(context.ModelMetadata.Name!));        // null forgiving: false positive as must be non-null for property
    MergeAttribute(context.Attributes, "data-val-differsto-other", $"*.{OtherProperty}");
    // allows other property to be nested or not, e.g. `FooModel.User.Name` and `FooModel.UserName`, respectively; https://github.com/dotnet/aspnetcore/blob/d0ca5a8d20ac50a33d5451e998a5d411a810c8d7/src/Mvc/Mvc.DataAnnotations/src/CompareAttributeAdapter.cs#L19
  }

  private static bool MergeAttribute(IDictionary<string, string> attributes, string key, string value)
  {
    if (attributes.ContainsKey(key)) return false;
    attributes.Add(key, value);
    return true;
  }

}

Add to js init scripts:

function getModelPrefix(fieldName) {                           // https://github.com/aspnet/jquery-validation-unobtrusive/blob/a5b50566f8b839177bc7733d67be3a37bca400ff/src/jquery.validate.unobtrusive.js#L44
  return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {                    // https://github.com/aspnet/jquery-validation-unobtrusive/blob/a5b50566f8b839177bc7733d67be3a37bca400ff/src/jquery.validate.unobtrusive.js#L48
  return value.indexOf("*.") === 0
    ? value.replace("*.", prefix)
    : value;
}

const v = new aspnetValidation.ValidationService();
v.addProvider('differsto', (value, element, params) => {
  if (!value) return true;
  let prefix       = getModelPrefix(element.name);
  let otherName    = appendModelPrefix(params.other, prefix);
  let otherElement = Array.from(element.form.querySelectorAll('input')).filter(x => x.name === otherName)[0];
  let otherValue   = otherElement.value;
  return value !== otherValue;
});
v.bootstrap();

Example:

[Required]
[PasswordPropertyText]
[DataType(DataType.Password)]
public string PasswordOld { get; init; }

[Required]
[DiffersTo(nameof(PasswordOld), ErrorMessage = "Passwords must differ")]
[PasswordPropertyText]
[DataType(DataType.Password)]
public string PasswordNew { get; init; }

If you ever decide to enable the wiki repo, it would be nice to have a page where we can aggregate our custom validators.

Please close this issue once you've seen it.

Thanks for sharing!

Above code doesn't work in some cases, so I updated it. But in the original [Compare] validator it performs some processing here which this library replicates here.

It's been a while since I did any TypScript, so am unsure. I'd like to use that getRelativeFormElement function, but cannot (v.getRelativeFormElement() is null). I assume because it's not marked as export?

Is there some way I can access that method from a custom client validator, or should I copy-paste that function into my own code? (It seems very useful to have for any custom validator though, so wondering why it's hidden - anyone know?)

It's been a while since I did any TypScript, so am unsure. I'd like to use that getRelativeFormElement function, but cannot (v.getRelativeFormElement() is null). I assume because it's not marked as export?

You're close. If we were to export it you could import { getRelativeFormElement } from 'aspnet-client-validation' but it wouldn't be a method on v.

Is there some way I can access that method from a custom client validator, or should I copy-paste that function into my own code? (It seems very useful to have for any custom validator though, so wondering why it's hidden - anyone know?)

There's no way to access it now, but I don't mind the idea of just adding export where it is. It's a pure function that doesn't depend on any internals of the ValidationService, so we can reasonably expose it as a helper for custom validation without making it part of the service's primary API.

Similar to:

export const isValidatable = (element: Node): element is ValidatableElement =>

Thanks for the refresher, haven't touched ts in some years!

After closer inspection, it seems that function supports the library's internal machinery, but is not useful for reuse. So I've resorted to using the logic from the original library (which is simpler), and updated my code above. It now works in all uses cases. (But I haven't added any tests... I leave that as an exercise for future readers :) )

I suspect ASP.NET Core will soon dump jquery/jquery-validate/jquery-unobtrusive in lieu of this library (I hope so anyway), so at some point it'll be important to make it easy to extend via custom validators. For now though, there are probably enough extension points. Also, the validator I wrote above was fairly complex (as it involved two fields), so it should be good as a starting point for anyone who wants to add custom validators.

After closer inspection, it seems that function supports the library's internal machinery, but is not useful for reuse.

In theory, getRelativeFormElement() should encapsulate the correct way to reference a related nested property. If your simpler version is more correct, we should probably use that. 😁

I suspect ASP.NET Core will soon dump jquery/jquery-validate/jquery-unobtrusive in lieu of this library

Probably not soon. No response to dotnet/aspnetcore#8573 (comment) and that issue was dropped to the Backlog.