ASP.NET Core 2.2 - BootstrapNative Client Validation
This article will demonstrate the vanilla JavaScript client-side validation for the ASP.NET Core 2.2 - Bootstrap Native Project. I will assume you have created a new ASP.NET Core 2.2 Razor Pages project with Individual User Accounts, updated the database with the CreateIdentitySchema migration, scaffolded the Identity UI and removed jQuery. Registered users can download the free ASP.NET Core 2.2 - Bootstrap Native Project from Manage > Assets. I was able to restore, build and run the project with VS 2017, VS 2019 and VS Code.
Let's start by copying a register model with data attributes and the form to the Index page.
Edit Index.cshtml.cs, add the InputModel and binding:
[BindProperty] public InputModel Input { get; set; } public class InputModel { [Required] [RegularExpression(@"[a-zA-Z0-9._@+-]{6,100}", ErrorMessage = "The {0} must be 6 to 100 valid characters which are any digit, any letter and -._@+.")] [Display(Name = "Login Name")] public string UserName { get; set; } [Required] [Display(Name = "Email")] public string Email { get; set; } [Required] [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W]).{8,}$", ErrorMessage = "The {0} does not meet requirements.")] [DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } [DataType(DataType.Password)] [Display(Name = "Confirm password")] [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] public string ConfirmPassword { get; set; } [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the Terms of Service.")] public bool TOSAgree { get; set; } }
Edit Index.cshtml, add the Register form:
<div class="row mb-2"> <div class="col-12"> <form class="col-md-6" id="ValidationFormId" method="post" action="/?handler=FormValidation"> <h4>Create a new account.</h4> <hr /> <div asp-validation-summary="All" class="text-danger"></div> <div class="form-group"> <label asp-for="Input.UserName"></label> <input asp-for="Input.UserName" class="form-control" /> <span asp-validation-for="Input.UserName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.Email"></label> <input asp-for="Input.Email" class="form-control" /> <span asp-validation-for="Input.Email" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.Password"></label> <input asp-for="Input.Password" class="form-control" /> <span asp-validation-for="Input.Password" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.ConfirmPassword"></label> <input asp-for="Input.ConfirmPassword" class="form-control" /> <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span> </div> <div class="form-group"> <div class="custom-control custom-checkbox"> <input class="custom-control-input" asp-for="Input.TOSAgree" /> <label class="custom-control-label" asp-for="Input.TOSAgree"> I accept the Terms of Service. </label> </div> <span asp-validation-for="Input.TOSAgree" class="text-danger"></span> </div> <button type="submit" class="btn btn-primary">Register</button> </form> </div> </div>
Notice the asp-page-handler="FormValidation".
Edit Index.cshtml.cs, add the OnPostFormValidation page handler:
public IActionResult OnPostFormValidation() { if (!ModelState.IsValid) { return Page(); } // Form passed validation. Do save or update. return Page(); }
Put a breakpoint at if (!ModelState.IsValid). Build, run and Register without the required fields. The breakpoint is hit and the ModelState automatically adds the validation errors to the page. Notice the Required attribute takes precedence.
I have searched for a vanilla JavaScript client validation library which works with the razor model data attributes but have not found one. If you know of any, please let me know. If you look at how the form is rendered, you see the data attributes.
Rendered Register form:
<div class="row mb-2"> <div class="col-12"> <form class="col-md-6" method="post" action="/?handler=MockRegister"> <h4>Create a new account.</h4> <hr> <div class="text-danger validation-summary-valid" data-valmsg-summary="true"> <ul> <li style="display:none"></li> </ul> </div> <div class="form-group"> <label for="Input_UserName">Login Name</label> <input class="form-control" type="text" data-val="true" data-val-regex="The Login Name must be 6 to 100 valid characters which are any digit, any letter and -._@+." data-val-regex-pattern="[a-zA-Z0-9._@+-]{6,100}" data-val-required="The Login Name field is required." id="Input_UserName" name="Input.UserName" value=""> <span class="text-danger field-validation-valid" data-valmsg-for="Input.UserName" data-valmsg-replace="true"></span> </div> <div class="form-group"> <label for="Input_Email">Email</label> <input class="form-control" type="text" data-val="true" data-val-required="The Email field is required." id="Input_Email" name="Input.Email" value=""> <span class="text-danger field-validation-valid" data-valmsg-for="Input.Email" data-valmsg-replace="true"></span> </div> <div class="form-group"> <label for="Input_Password">Password</label> <input class="form-control" type="password" data-val="true" data-val-regex="The Password does not meet requirements." data-val-regex-pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W]).{8,}$" data-val-required="The Password field is required." id="Input_Password" name="Input.Password"> <span class="text-danger field-validation-valid" data-valmsg-for="Input.Password" data-valmsg-replace="true"></span> </div> <div class="form-group"> <label for="Input_ConfirmPassword">Confirm password</label> <input class="form-control" type="password" data-val="true" data-val-equalto="The password and confirmation password do not match." data-val-equalto-other="*.Password" id="Input_ConfirmPassword" name="Input.ConfirmPassword"> <span class="text-danger field-validation-valid" data-valmsg-for="Input.ConfirmPassword" data-valmsg-replace="true"></span> </div> <div class="form-group"> <div class="custom-control custom-checkbox"> <input class="custom-control-input" type="checkbox" data-val="true" data-val-range="You must accept the Terms of Service." data-val-range-max="True" data-val-range-min="True" data-val-required="The TOSAgree field is required." id="Input_TOSAgree" name="Input.TOSAgree" value="true"> <label class="custom-control-label" for="Input_TOSAgree"> I accept the Terms of Service. </label> </div> <span class="text-danger field-validation-valid" data-valmsg-for="Input.TOSAgree" data-valmsg-replace="true"></span> </div> <button type="submit" class="btn btn-primary">Register</button> <input name="__RequestVerificationToken" type="hidden" value="CfDJ8MlRg4vo8eBCgcoKZAm6joR8J96qFEs5G79XSJ1JeTeoCeOuhQpwPPegahQAiHlG3z2Jp6BncPf-XcF8qU1W6lzWeMv-lZ1a-knvthQ7DQbItX89ZLH1LFGBsiWgUcVIWXRwb3Jqqr3jEb4mjGKHWcg"><input name="Input.TOSAgree" type="hidden" value="false"> </form> </div> </div>
I developed a form validation function for the data attributes. It does not validate all available model attributes but it is a good start.
Edit Index.cshtml, add the JavaScript functions:
@section Scripts { <script> function addValidationError(message, element, summary) { if (summary) { var li = document.createElement('li'); li.textContent = message; summary.appendChild(li); } // 09/21/2019 Update - check for element if (element.type === 'checkbox') { if (element.parentElement !== null && element.parentElement.nextElementSibling !== null && element.parentElement.nextElementSibling.tagName === 'SPAN') { element.parentElement.nextElementSibling.textContent = message; } } else { if (element.nextElementSibling !== null && element.nextElementSibling.tagName === 'SPAN') { element.nextElementSibling.textContent = message; } } } function validateForm(form) { var returnValue = true; var message, pattern, regex, matchValue, min, max, i; var summary = null; var summaryParent = form.querySelector('[data-valmsg-summary="true"]'); if (summaryParent) summary = summaryParent.firstElementChild; if (summary) summary.innerHTML = ''; var elements = form.querySelectorAll('[data-val]'); for (i = 0; i < elements.length; i++) { // clear existing validation message // 09/21/2019 Update - check for element if (elements[i].type === 'checkbox') { if (elements[i].parentElement !== null && elements[i].parentElement.nextElementSibling !== null && elements[i].parentElement.nextElementSibling.tagName === 'SPAN') { elements[i].parentElement.nextElementSibling.textContent = ''; } } else { if (elements[i].nextElementSibling !== null && elements[i].nextElementSibling.tagName === 'SPAN') { elements[i].nextElementSibling.textContent = ''; } } // 09/21/2019 Update - replaced checkRequired function if (elements[i].getAttribute('data-val-required') && elements[i].value.length === 0) { message = elements[i].getAttribute('data-val-required'); addValidationError(message, elements[i], summary); returnValue = false; } else if (elements[i].getAttribute('data-val-equalto-other')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/CompareAttribute.cs var nameAttribute = elements[i].getAttribute('name'); var other = elements[i].getAttribute('data-val-equalto-other'); var otherName = nameAttribute.split('.')[0] + '.' + other.split('.')[1]; var otherValue = document.querySelector('[name="' + otherName + '"]').value; if (elements[i].value !== otherValue) { message = elements[i].getAttribute('data-val-equalto'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-range')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/RangeAttribute.cs min = elements[i].getAttribute('data-val-range-min').toLowerCase(); max = elements[i].getAttribute('data-val-range-max').toLowerCase(); var valid = false; if (elements[i].type === 'checkbox') { if (elements[i].checked.toString() === min && elements[i].checked.toString() === max) { valid = true; } } else { if (elements[i].value > min && elements[i].value < max) { valid = true; } } if (!valid) { message = elements[i].getAttribute('data-val-range'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-email')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/EmailAddressAttribute.cs pattern = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?$/; regex = new RegExp(pattern); matchValue = elements[i].value.toLowerCase(); if (matchValue.match(regex) === null) { message = elements[i].getAttribute('data-val-email'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-regex')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/RegularExpressionAttribute.cs regex = new RegExp(elements[i].getAttribute('data-val-regex-pattern')); matchValue = elements[i].value; if (matchValue.match(regex) === null) { message = elements[i].getAttribute('data-val-regex'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-required')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/RequiredAttribute.cs if (elements[i].value.length === 0) { message = elements[i].getAttribute('data-val-required'); addValidationError(message, elements[i], summary); returnValue = false; } } else { message = 'Unknown data attributtes: '; var data = elements[i].dataset; for (var a in data) message += a + ' : ' + data[a] + ', '; addValidationError(message, elements[i], summary); returnValue = false; } } return returnValue; } document.addEventListener('DOMContentLoaded', function () { document.querySelector('#ValidationFormId').addEventListener('submit', function (e) { if (!validateForm(this)) { e.preventDefault(); } }, false); }, false); </script> }
Build, run and Register without the required fields. You get the same result without hitting the breapoint. Let's add some new model properties with different attributes.
Edit Index.cshtml.cs, add properties to the InputModel:
[MinLength(5, ErrorMessage = "The {0} must be at least {1} characters long.")] [Display(Name = "Min Length")] public string MinLength { get; set; } = "12345"; [MaxLength(10, ErrorMessage = "The {0} must be at max {1} characters long.")] [Display(Name = "Max Length")] public string MaxLength { get; set; } = "1234567890"; [StringLength(20, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] public string Description { get; set; } = "123456"; [CreditCard] [Display(Name = "Credit Card")] public string CreditCard { get; set; } = "214949361265341"; [Phone] public string Phone { get; set; } = "(954) 555-1212"; [Url] public string Url { get; set; } = "https://referencesource.microsoft.com/#System.ComponentModel.DataAnnotations/DataAnnotations/UrlAttribute.cs"; [DataType(DataType.Date)] [Display(Name = "Birth Date")] public DateTime BirthDate { get; set; } = new DateTime(2000, 01, 01); [Range(.99, 999.99)] public decimal Price { get; set; } = 1; [Range(typeof(bool), "false", "false", ErrorMessage = "You must uncheck {0}.")] [Display(Name = "Must Be False")] public bool MustBeFalse { get; set; } = true;
Edit Index.cshtml, add the form inputs:
<div class="form-group"> <label asp-for="Input.MinLength"></label> <input asp-for="Input.MinLength" class="form-control" /> <span asp-validation-for="Input.MinLength" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.MaxLength"></label> <input asp-for="Input.MaxLength" class="form-control" /> <span asp-validation-for="Input.MaxLength" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.Description"></label> <input asp-for="Input.Description" class="form-control" /> <span asp-validation-for="Input.Description" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.CreditCard"></label> <input asp-for="Input.CreditCard" class="form-control" /> <span asp-validation-for="Input.CreditCard" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.Phone"></label> <input asp-for="Input.Phone" class="form-control" /> <span asp-validation-for="Input.Phone" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.Url"></label> <input asp-for="Input.Url" class="form-control" /> <span asp-validation-for="Input.Url" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.BirthDate"></label> <input asp-for="Input.BirthDate" class="form-control" /> <span asp-validation-for="Input.BirthDate" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Input.Price"></label> <input asp-for="Input.Price" class="form-control" /> <span asp-validation-for="Input.Price" class="text-danger"></span> </div> <div class="form-group"> <div class="custom-control custom-checkbox"> <input class="custom-control-input" asp-for="Input.MustBeFalse" /> <label class="custom-control-label" asp-for="Input.MustBeFalse"> Must be False </label> </div> <span asp-validation-for="Input.MustBeFalse" class="text-danger"></span> </div>
Notice the default values for the new properties. All are valid except for MustBeFalse which demonstrates a true default value. You will need to initialize the Input property to load the default values.
Edit Index.cshtml.cs, update OnGet:
public IActionResult OnGet() { Input = new InputModel(); return Page(); }
The else condition for elements with data-type="true" and unknown data attributes lists the attribute's name and value as an error.
I developed conditions to handle these attributes but I have not covered all. You can remove the else condition to use the model validation on the server. I had to escape the @ character with @@ in the EmailAddress Regular Expression for use in a razor page. If you want to move this JavaScript to site.js, you will have to unescape the @ character and you may want to add the rule "no-control-regex": 0 to the .eslintrc file in the user's root folder. Javascript Unexpected control character(s) in regular expression
Edit Index.cshtml, add conditions to the validateForm function:
else if (elements[i].getAttribute('data-val-minlength')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/MinLengthAttribute.cs min = elements[i].getAttribute('data-val-minlength-min'); if (elements[i].value.length < min) { message = elements[i].getAttribute('data-val-minlength'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-maxlength')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/MaxLengthAttribute.cs max = elements[i].getAttribute('data-val-maxlength-max'); if (elements[i].value.length > max) { message = elements[i].getAttribute('data-val-maxlength'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-length')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/StringLengthAttribute.cs min = elements[i].getAttribute('data-val-length-min'); max = elements[i].getAttribute('data-val-length-max'); if (elements[i].value.length < min || elements[i].value.length > max) { message = elements[i].getAttribute('data-val-length'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-creditcard')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/CreditCardAttribute.cs var ccValue = elements[i].value; ccValue = ccValue.replace("-", ""); ccValue = ccValue.replace(" ", ""); var checksum = 0; var evenDigit = false; // http://www.beachnet.com/~hstiles/cardtype.html var ccReverse = ccValue.split('').reverse(); var ccl = ccReverse.length; var digit = ''; for (var d = 0; d < ccl; d++) { digit = ccReverse[d]; if (digit < '0' || digit > '9') { message = elements[i].getAttribute('data-val-creditcard'); addValidationError(message, elements[i], summary); returnValue = false; } var digitNum = parseInt(digit, 10); var digitValue = digitNum * (evenDigit ? 2 : 1); var singlesum = 0; while (digitValue >= 10) { singlesum = 0; while (digitValue > 0) { var rem; rem = digitValue % 10; singlesum = singlesum + rem; digitValue = parseInt(digitValue / 10); } digitValue = singlesum; } checksum += digitValue; evenDigit = !evenDigit; } if (checksum % 10 !== 0) { message = elements[i].getAttribute('data-val-creditcard'); addValidationError(message, elements[i], summary); returnValue = false; } } else if (elements[i].getAttribute('data-val-phone')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/PhoneAttribute.cs // Commented because it throws SyntaxError with Firefox v69.0.1 - invalid regexp group // pattern = /^(\+\s?)?((?<!\+.*)\(\+?\d+([\s\-\.]?\d+)?\)|\d+)([\s\-\.]?(\(\d+([\s\-\.]?\d+)?\)|\d+))*(\s?(x|ext\.?)\s?\d+)?$/; // regex = new RegExp(pattern); // matchValue = elements[i].value; // if (matchValue.match(regex) === null) { // message = elements[i].getAttribute('data-val-phone'); // addValidationError(message, elements[i], summary); // returnValue = false; // } } else if (elements[i].getAttribute('data-val-url')) { // https://referencesource.microsoft.com/ // #System.ComponentModel.DataAnnotations/DataAnnotations/UrlAttribute.cs pattern = /^(https?|ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@@)|\/|\?)*)?$/; regex = new RegExp(pattern); matchValue = elements[i].value.toLowerCase(); if (matchValue.match(regex) === null) { message = elements[i].getAttribute('data-val-url'); addValidationError(message, elements[i], summary); returnValue = false; } }
You can bypass the client validation for an attribute by using an empty condition.
else if (elements[i].getAttribute('data-val-url')) { // Let the server validate this attribute. }
Update 09/21/2019
I updated the validateForm function to fix a couple of issues. I have updated the ASP.NET Core 2.2 - Bootstrap Native Project to v1.0.6 which moves the validateForms function to site.js and implements client-side validation for Identity forms.
Comments(0)