Mvc.ValidationTookit Alpha Release: Conditional Validation with MVC 3
Update: I've not forgotten about this everyone, apologies for the delay. My attempt to get approval to publish failed due to vacations (mine and others) so as soon as I can I'll try again.
My blog posts on doing conditional validation in MVC have always been popular, and I’m frequently surprised by where I find that code – customers often say to me “we found some guys blog with some code to do the validation we needed” and sure enough it usually turns out to be mine!
There are two problems though – firstly, my code was always intended to be a sample to get you started, and that meant I’d not written a comprehensive set of tests. Secondly, there are all sorts of validation cases that developers need that I hadn’t covered.
So I decided to refresh the code base, both with additional validators and with a pretty decent set of tests. The first Alpha (i.e. incomplete, probably buggy, etc, etc you have been warned ) release of this code is attached to this blog post.
What’s in the box?
There are four main topics included in the box;
- Conditional required validation. This is the most common requirement, and is often used for forms that have optional sections.
- RequiredIfAttribute. This attribute says “this field is required if some other field has value X”. It is used as [RequiredIf(“OtherField”, “TargetValue”)]
- RequiredEmptyIfAttribute. This attribute says “this field must be empty if some other field has value X”. It is used as [RequiredEmptyIf(“OtherField”, “TargetValue”)]
- conditional-validation.js – a JavaScript file to does the above in the browser to match the server-side implementation
- Dependency validation. Sometimes the value of one field depends on another.
- RangeDefinedByFieldsAttribute. This states “this field must be between the values in X field and Y field”. It is used as [RangeDefinedByFields(typeof(Int32), “MinField”, “MaxField”)].
- There is also script in conditional-validation.js to do this in the browser.
- Validation groups. Sometimes a field is only relevant if the user clicks one of the buttons available; perhaps if they click Delete none of the edited fields must be entered.
- RequiredForButtonAttribute. This states “the field is required if they user clicks button X”. It is used as [RequiredForButton(“ValidationGroupName”, “ButtonName”)]
- RequiredEmptyForButtonAttribute. OK, you probably get the point now. I’ll let you infer what this does!
- Html.SubmitForValidationGroup. To “play nice” with the RequiredForButtonAttribute and RequiredEmptyForButtonAttribute we need to ensure the buttons are created correctly, and we need a hidden field. This HtmlHelper extension handles this for us, and takes the form Html.SubmitForValidationGroup(“ValidationGroupName”, “ButtonName”)]
- validation-groups.js – This JavaScript ensures that client-side validation groups work as well as server-side.
Note that all of these pieces work both server-side and client-side (in the browser) if you use the JavaScript included in /Mvc.ValidationToolkit.Harness/Scripts/MvcValidationToolkit/*.js.
Also, note that they all have MSTest unit tests and automated WaTiN UI tests that exercise both the JavaScript and Server implementations of each feature. I’m positive it needs more tests (for example different data types).
What do I need from you?
I must emphasise that this release is a preview. So what next? Well I want to know a few things;
- What do you think? Is this useful?
- What validators are missing?
- What bugs have you found?
- What testing priorities are there?
Please comment on this post if you’ve answers to those questions. But I also want to know where this should live and how it should move forwards. I would consider setting up a codeplex project if there were a few strong MVC & jQuery developers that wanted to add to and evolve the library. Or is this drop enough to get you started? Let me know your thoughts.
In the mean time, I may blog on using the attributes a little more if I get the chance.
Go Get It
To have a look at the code, download the attachment to this post and have a look in Readme.txt – the only thing you need to do is grab a couple of NuGet packages and everything should work. Note: if you’re running the WaTiN tests, run Visual Studio as elevated else you’ll just get timeouts.
Mvc.ValidationToolkit.Alpha.zip
Comments
Anonymous
September 28, 2011
I get the following errors when opening the Solution: Mvc.ValidationToolkitMvc.ValidationToolkit.csproj : error : Unable to read the project file 'Mvc.ValidationToolkit.csproj'. Mvc.ValidationToolkitMvc.ValidationToolkit.csproj(69,3): The imported project ".nugetNuGet.targets" was not found. Confirm that the path in the <Import> declaration is correct, and that the file exists on disk. Mvc.ValidationToolkit.HarnessMvc.ValidationToolkit.Harness.csproj : error : Unable to read the project file 'Mvc.ValidationToolkit.Harness.csproj'. Mvc.ValidationToolkit.HarnessMvc.ValidationToolkit.Harness.csproj(220,3): The imported project ".nugetNuGet.targets" was not found. Confirm that the path in the <Import> declaration is correct, and that the file exists on disk. Mvc.ValidationToolkit.TestsMvc.ValidationToolkit.Tests.csproj : error : Unable to read the project file 'Mvc.ValidationToolkit.Tests.csproj'. Mvc.ValidationToolkit.TestsMvc.ValidationToolkit.Tests.csproj(108,3): The imported project ".nugetNuGet.targets" was not found. Confirm that the path in the <Import> declaration is correct, and that the file exists on disk. Cheers HarryAnonymous
September 28, 2011
@ Harry - Oops sorry. Fixed now! It was because I was using the NuGetPowerTools to auto-fetch NuGet dependencies that were not in my source control system. I actually found that to be a problem though as I often work offline on trains etc, and it would fail my build if it couldn't hit the NuGet servers. Looks like uninstalling the package didn't clean it properly. All done now. SimonAnonymous
September 28, 2011
Very nice! How about publishing it somewhere (GitHub?) so that we can track changes more easily?Anonymous
September 30, 2011
I have month and year drop-down for credit card expiration fields. Is there anyway to create validation attribute to validate future expiry date?Anonymous
October 01, 2011
These look like good additions. Perhaps set this up at codeplex.com?Anonymous
October 01, 2011
Better to add Enable and disable a field feature along with conditional logic.Anonymous
December 01, 2011
I am interested in RequiredIfAttribute, one question is that does it support required if another attribute is not empty (string with any value)? ThanksAnonymous
February 16, 2012
In your GetClientValidationRules method on RequiredIfAttribute, you're using "yield return rule". You shouldn't do this. An outside caller could potentially recreate the rule multiple times. Example: var rules = GetClientValidationRules(); var rule1 = rules.First(); var rule2 = rules.First(); Object.ReferenceEquals(rule1, rule2) will return false. Both times calling First() causes the dynamic enumerator created by a yield to start all over again. You should, instead, return a new array with the rule in it.Anonymous
April 29, 2012
Hey Simon, Awesome job mate... :D As others have said: we could really get more value out of your project if you took the time to set up a GitHub repo for example. It's working as expected in my case, though. Thanks for sharing this piece of code! LenielAnonymous
May 07, 2012
This has been very helpful. I use the RequiredIf, but I've also adapted and built on top of your ConditionalAttributeBase to make other validators like NotEqual (making sure one field does NOT match the value of another). It's helped me to learn a lot about MVC validation and how it works. I tell everyone about it. Thanks!!!Anonymous
June 22, 2012
The comment has been removedAnonymous
July 15, 2012
this is really helpful, much thanks. but when I migrated some of my project to ".Net 4.5" it didn't compile. i'm not good with the subject but I'm guessing they dropped a class. you might want to check it out.Anonymous
July 28, 2012
Hi Simon, It's been almost a year since this post of yours. Only found about it recently. I tried your library. It compiles well, it works good but somehow, whenever i use them, the clientside validation don't trigger. Only server-side validation works. That is, if any of the conditional validators fail on submit, the form still submits. I'm using jquery 1.7. I have included the toolkit js files as well.Anonymous
August 07, 2012
This definitely adds a missing piece to the MVC validation puzzle. My first major roadblock is that I have a case where a field is required if another field is a certain value. OR a different field is a certain value. But you can only have a single RequiredIf attribute. (Additionally, you might have a case where BOTH fields match certain values, making a third field required. Or required empty ;0)Anonymous
August 07, 2012
The other major roadblock for me is just that client-side validation is not throwing any errors, but it isn't firing for the conditional required fields. The form is submitting and coming back with server-side validation. Could this be because I'm using a ViewModel, and the fields get named <ViewModelChild>.<FieldName>?Anonymous
August 28, 2012
Thank you so much. This was extremely helpful. I made a few changes that I think you might like. First I wanted to make it so I could pass multiple target values. In ConditionalAttributeBase.cs I changed ShouldRunValidation to this in order to make it so I could pass a comma delimited list of target values. protected bool ShouldRunValidation( object value, string dependentProperty, object targetValue, ValidationContext validationContext) { var dependentvalue = GetDependentFieldValue(dependentProperty, validationContext); if (targetValue.GetType() == typeof(string)) { char[] comma = new char[1]; comma[0] = ','; string[] targetValues = ((string)targetValue).Split(comma, StringSplitOptions.RemoveEmptyEntries); //I'm adding the ability to specify a list of targetvalues // compare the value against the target value bool shouldIt = false; foreach (object o in targetValues) { if ((dependentvalue == null && targetValue == null) || (dependentvalue != null && dependentvalue.Equals(o))) { shouldIt = true; break; } } return shouldIt; } else { // compare the value against the target value return ((dependentvalue == null && targetValue == null) || (dependentvalue != null && dependentvalue.Equals(targetValue))); } } Then in conditional-validation.js I changed mvcvtkrequiredif to this: /* "Required If" Validation *** mvcvtkrequiredif *** / $.validator.addMethod('mvcvtkrequiredif', function (value, element, parameters) { var id = '#' + parameters['dependentproperty']; // get the target value (as a string, // as that's what actual value will be) var targetvalue = parameters['targetvalue']; targetvalue = (targetvalue == null ? '' : targetvalue).toString(); // get the actual value of the target control // note - this probably needs to cater for more // control types, e.g. radios var control = $(id); var controltype = control.attr('type'); var actualvalue = controltype === 'checkbox' ? control.attr('checked').toString() : control.val(); //alert(id + ' ' + targetvalue + ' ' + actualvalue); // if the condition is true, reuse the existing // required field validator functionality var targetValues = targetvalue.split(","); //I'm adding the ability to specify a list of targetvalues // compare the value against the target value var shouldIt = false; for (var i = 0; i < targetValues.length; i++) { if (targetValues[i] === actualvalue) { //alert(id + ' ' + targetValues[i] + ' ' + actualvalue); shouldIt = true; break; } } if (shouldIt === true) { //alert(id + ' ' + targetvalue + ' ' + actualvalue + ' ' + element.id); return $.validator.methods.required.call( this, value, element, parameters); } return true; } ); $.validator.unobtrusive.adapters.add( 'mvcvtkrequiredif', ['dependentproperty', 'targetvalue'], function (options) { options.rules['mvcvtkrequiredif'] = { dependentproperty: options.params['dependentproperty'], targetvalue: options.params['targetvalue'] }; options.messages['mvcvtkrequiredif'] = options.message; } ); That worked great. Then I noticed that if I had a property that I needed to do a compare or regex validation but only if one of the other properties was set to a target value I couldn't just add the compare or regex validation to the property as well because they would trump the required if. For example with the compare validation it would say it wasn't required but it still needed to match the other property, so if they were both empty it was fine, but if the other property wasn't empty then it couldn't be either. So I made a CompareIfAttribute. Here is CompareIfAttribute.cs: using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Web.Mvc; using System.ComponentModel.DataAnnotations; namespace Mvc.ValidationToolkit { public class CompareIfAttribute : ConditionalAttributeBase, IClientValidatable { private CompareAttribute _innerAttribute = new CompareAttribute("Temp"); public string DependentProperty { get; set; } public string OtherProperty { get; set; } public object TargetValue { get; set; } public CompareIfAttribute(string dependentProperty, object targetValue, string OtherProperty) : this(dependentProperty, targetValue, OtherProperty, null) { } public CompareIfAttribute(string dependentProperty, object targetValue, string otherProperty, string errorMessage) : base(errorMessage) { this._innerAttribute = new CompareAttribute(otherProperty); this.OtherProperty = otherProperty; this.DependentProperty = dependentProperty; this.TargetValue = targetValue; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { // check if the current value matches the target value if (ShouldRunValidation(value, this.DependentProperty, this.TargetValue, validationContext)) { // match => means we should try validating this field //For some reason I go an error when I tried to use the _innerAttribute IsValid method, so I replaced it with this. //It uses reflection to do the same thing. As long as both can safely be converted to strings it shouldn't be a problem. if (validationContext.ObjectInstance.GetType().GetProperty(OtherProperty).GetValue(validationContext.ObjectInstance, null).ToString() != value.ToString())// !_innerAttribute.IsValid(value)) // validation failed - return an error return new ValidationResult(FormatErrorMessage(validationContext.DisplayName), new[] { validationContext.MemberName }); } return ValidationResult.Success; } public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context) { var rule = new ModelClientValidationRule() { ErrorMessage = FormatErrorMessage(metadata.GetDisplayName()), ValidationType = "mvcvtkcompareif", }; ViewContext vc = context as ViewContext; string depProp = BuildDependentPropertyId(metadata, vc); string compProp = BuildOtherPropertyId(metadata, vc); // find the value on the control we depend on; // if it's a bool, format it javascript style // (the default is True or False!) string targetValue = (this.TargetValue ?? "").ToString(); if (this.TargetValue != null && this.TargetValue.GetType() == typeof(bool)) targetValue = targetValue.ToLower(); rule.ValidationParameters.Add("dependentproperty", depProp); rule.ValidationParameters.Add("targetvalue", targetValue); rule.ValidationParameters.Add("otherproperty", compProp); yield return rule; } private string BuildDependentPropertyId(ModelMetadata metadata, ViewContext viewContext) { return QualifyFieldId(metadata, this.DependentProperty, viewContext); } private string BuildOtherPropertyId(ModelMetadata metadata, ViewContext viewContext) { return QualifyFieldId(metadata, this.OtherProperty, viewContext); } public override string FormatErrorMessage(string name) { if (!String.IsNullOrEmpty(this.ErrorMessageString)) _innerAttribute.ErrorMessage = this.ErrorMessageString; return _innerAttribute.FormatErrorMessage(name); } } } And I added this to conditional-validation.js: / "Compare If" Validation *** mvcvtkcompareif *** */ $.validator.addMethod('mvcvtkcompareif', function (value, element, parameters) { var id = '#' + parameters['dependentproperty']; // get the target value (as a string, // as that's what actual value will be) var targetvalue = parameters['targetvalue']; targetvalue = (targetvalue == null ? '' : targetvalue).toString(); // get the actual value of the target control // note - this probably needs to cater for more // control types, e.g. radios var control = $(id); var controltype = control.attr('type'); var actualvalue = controltype === 'checkbox' ? control.attr('checked').toString() : control.val(); //alert(id + ' ' + targetvalue + ' ' + actualvalue); // if the condition is true, reuse the existing // required field validator functionality var targetValues = targetvalue.split(","); //I'm adding the ability to specify a list of targetvalues // compare the value against the target value var shouldIt = false; for (var i = 0; i < targetValues.length; i++) { if (targetValues[i] === actualvalue) { //alert(id + ' ' + targetValues[i] + ' ' + actualvalue); shouldIt = true; break; } } if (shouldIt === true) { //alert(id + ' ' + targetvalue + ' ' + actualvalue + ' ' + element.id); var otherid = '#' + parameters['otherproperty']; var othercontrol = $(otherid); var othercontroltype = othercontrol.attr('type'); var otheractualvalue = othercontroltype === 'checkbox' ? othercontrol.attr('checked').toString() : othercontrol.val(); //alert(otherid + ' ' + otheractualvalue + ' ' + element.id + ' ' + element.value); if (otheractualvalue === element.value) return true; else return false; // return $.validator.methods.compare.call( // this, value, element, parameters); } return true; } ); $.validator.unobtrusive.adapters.add( 'mvcvtkcompareif', ['dependentproperty', 'targetvalue', 'otherproperty'], function (options) { options.rules['mvcvtkcompareif'] = { dependentproperty: options.params['dependentproperty'], targetvalue: options.params['targetvalue'], otherproperty: options.params['otherproperty'] }; options.messages['mvcvtkcompareif'] = options.message; } ); CompareIf works great for me. I haven't written RegularExpressionIf yet, but you get the idea.