UPDATE1: Started using ngProgress, but not giving required effect in IE.
Final Update: Best solution found. See last answer below.
The AngularJS application ha
This is the final version, and the best in terms of performance.
It is based on the following:
ngModelController.$validators
property.directive
and $compile
completely Following is the code that will be used to load the validation rules:
//Define general function to add/remove error message from the title attribute
var conErrMsgSep = " | ";
var conErrMsgSepTrim = conErrMsgSep.trim();
function addRemoveValidationMessage(elem, isValid, errMsg) {
if (!isValid && elem.get(0).title.indexOf(errMsg) === -1){
//Add message
if (elem.get(0).title.trim()) {
elem.get(0).title += conErrMsgSep;
} else {
elem.get(0).title = "";
}
elem.get(0).title += errMsg;
} else
if (isValid && elem.get(0).title.indexOf(errMsg) !== -1) {
//Remove message
elem.get(0).title = (elem.get(0).title.replace(errMsg, "")).trim();
if (elem.get(0).title.endsWith(conErrMsgSepTrim)) {
elem.get(0).title = elem.get(0).title.substring(0, elem.get(0).title.length-1).trim();
}
if (elem.get(0).title.startsWith(conErrMsgSepTrim)) {
elem.get(0).title = elem.get(0).title.substring(1, elem.get(0).title.length).trim();
}
}
}
//Define Class/Object to handle adding/removing error messages
//This will be used to save the last error message used, and update 'title' correctly.
function AddRemoveValidationMessage(elem, validatorKey) {
this.elem = elem;
//validatorKey is the rule key. For now, not yet used.
this.validatorKey = validatorKey;
this.isValid = true; //Default is always valid
this.errMsg = "";
this.addRemoveMessage = function(isValid, errMsg) {
if (isValid === undefined) {
isValid = true;
}
if ((!this.isValid && !isValid) || isValid) {
//Last was invalid, and now also invalid, must reomve the old saved error message from 'title'
//and, if now valid, must remove the old saved error message also
addRemoveValidationMessage(this.elem, true, this.errMsg); //Remove message from 'title'
}
if (!isValid) {
//Add new error message if invalid
addRemoveValidationMessage(this.elem, false, errMsg, this.validatorKey);
//Save error message if invalid.
this.errMsg = errMsg;
} else {
//Clear error message if valid
this.errMsg = "";
}
//Save last validation status
this.isValid = isValid;
}
}
function addRequriedValidation(elem, elemModel, elemScope, isRequiredExp) {
var result;
if (!elemModel.$validators.required &&
isRequiredExp.toLowerCase() !== "false" && isRequiredExp !== '*skip*') {
elemModel.$validators.required = function (modelValue, viewValue) {
var errMsg = "Fill in the required value.";
var isValid;
var theElem = elem;
var theExpr = isRequiredExp;
var theVal = modelValue || viewValue;
var isRequiredExpVal = elemScope.$eval(theExpr);
isValid = !isRequiredExpVal || !elemModel.$isEmpty(theVal);
addRemoveValidationMessage(elem, isValid, errMsg)
return isValid;
}
}
}
function addReadonlyRule(elem, elemModel, elemScope, readonlyExp) {
var result=false;
var conSkip = "*skip*"
if (readonlyExp.toLowerCase() === "true" && readonlyExp !== conSkip && readonlyExp) {
elem.attr('readonly', true);
result = true;
} else
//Add readonly validation
if (readonlyExp && readonlyExp.toLowerCase() !== "false" && readonlyExp != conSkip) {
elemScope.$watch(readonlyExp, function(newVal){
var theElem = elem;
theElem.attr('readonly', newVal);
})
result = true;
//angular.element(child).attr('ng-readonly', readonlyExp);
}
return result;
}
function addMaxlengthValidation(elem, elemModel, elemScope, maxLenExp) {
var result;
if (!elemModel.$validators.maxlength && maxLenExp) {
elemModel.$validators.maxlength = function (modelValue, viewValue) {
var errMsg = "Number of characters should not exceeded '{0}' characters.";
var isValid;
var theElem = elem;
var theExpr = maxLenExp;
var maxLenExpVal = elemScope.$eval(theExpr);
isValid = (maxLenExpVal < 0) || elemModel.$isEmpty(viewValue) || (viewValue.length <= maxLenExpVal);
addRemoveValidationMessage(elem, isValid, errMsg.format(maxLenExpVal))
return isValid;
}
}
}
function addMinlengthValidation(elem, elemModel, elemScope, minLenExp) {
var result;
if (!elemModel.$validators.minlength && minLenExp) {
elemModel.$validators.minlength = function (modelValue, viewValue) {
var errMsg = "Number of characters should not be less than '{0}' characters.";
var isValid;
var theElem = elem;
var theExpr = minLenExp;
var minLenExpVal = elemScope.$eval(theExpr);
isValid = elemModel.$isEmpty(viewValue) || viewValue.length >= minLenExpVal;
addRemoveValidationMessage(elem, isValid, errMsg.format(minLenExpVal))
return isValid;
}
}
}
function addPatternValidation(elem, elemModel, elemScope, patternExp, validatorKey, errMsgMask) {
var result;
validatorKey = validatorKey || 'pattern';
if (!elemModel.$validators[validatorKey] && patternExp) {
//Use closures and self invoking function to maintain static value for the related validation function.
elemModel.$validators[validatorKey] = function () {
errMsgMask = errMsgMask || "The entered value '{0}' doesn't match the validation pattern '{1}'.";
var oAddRemoveValidationMessage;
var errMsg;
return function(modelValue, viewValue) {
//This is the actual validation function
var isValid;
var theElem = elem;
var theElemModel = elemModel;
var theExpr = patternExp.replaceAll("\\", "\\\\");
var patternExpVal = elemScope.$eval(theExpr);
if (angular.isString(patternExpVal) && patternExpVal.length > 0) {
patternExpVal = eval(patternExpVal) //new RegExp('^' + patternExpVal + '$');
}
if (patternExpVal && !patternExpVal.test) {
errMsg = 'Expected {0} to be a RegExp but was {1}. Element ID: {2}';
throw Error(errMsg.format(theExpr, patternExpVal, theElem[0].id));
}
patternExpVal = patternExpVal || undefined;
isValid = theElemModel.$isEmpty(viewValue) || angular.isUndefined(patternExpVal) || patternExpVal.test(viewValue);
//Create object to deal with adding and removing error messages from the element 'title' attribute.
//This object is saved within the 'elemModel' and will be used to save the last error message generated.
//This will allow the same object to remove the error message when the status becomes valid.
oAddRemoveValidationMessage =
oAddRemoveValidationMessage || (new AddRemoveValidationMessage(elem, validatorKey));
if (!isValid) {
errMsg = errMsgMask.format(viewValue, patternExpVal)
}
oAddRemoveValidationMessage.addRemoveMessage(isValid, errMsg);
return isValid;
}
}(); //Self invoking function
}
}
function addCAPostalCodeValidation(elem, elemModel, elemScope, isCAPostalCode) {
var result;
var errMsg = "The entered value '{0}' must be a valid Canadian Postal Code.";
var patternExp = "'/^([A-Z]\\d[A-Z] *\\d[A-Z]\\d)$/i'";
var validatorKey = 'caPostalCode';
isCAPostalCode = isCAPostalCode.toLowerCase();
if (isCAPostalCode === "true") {
addPatternValidation(elem, elemModel, elemScope, patternExp, validatorKey, errMsg)
} else
if (isCAPostalCode && isCAPostalCode !== "false") {
elemScope.$watch(isCAPostalCode, function(newVal){
if (elemModel.$validators[validatorKey]) {
delete elemModel.$validators[validatorKey];
}
if (newVal) {
addPatternValidation(elem, elemModel, elemScope, patternExp, validatorKey)
}
});
}
}
//Implement Load Validation Rules.
// - doReadonly - if true, it will only load readonly rules
// if false, it will only load validation rules
theService.loadValidationRules = function (doReadonly) {
var validationList;
var validationListKeys;
var validationKey;
var elem;
var elemModel;
var elemScope;
var validationRule;
var conLoaded = '*loaded*';
doReadonly = doReadonly || false;
validationList = formView.getRequiredField();
if (!validationList) {
console.error("Unexpected error in 'loadValidationRules()': 'validationList' is not initialized.")
return;
}
validationListKeys = Object.keys(validationList);
for (var idx=0; idx < validationListKeys.length; idx++) {
validationKey = validationListKeys[idx];
if (validationKey.startsWith('$')) {
continue;
}
elem = angular.element('#'+validationKey);
if (!elem.length) {
continue;
}
elemModel = elem.controller('ngModel');
if (!elemModel && !doReadonly) {
//We don't need 'ngMode' for readonly
console.warn("'ngModel' was not defined for element ID '%s'.", validationKey);
continue;
}
elemScope = elem.scope() || scope;
validationObjects = validationList[validationKey];
if (validationObjects === "") {
//This means field is required.
if (elemModel.$isEmpty(elemModel.$viewValue)){
elem.addClass('ng-invalid');
result = false;
}
} else
if (angular.isArray(validationObjects)) {
//Loop through validation rules, and flag invalid field by adding the relevant class
for (var ruleIdx=0; ruleIdx < validationObjects.length; ruleIdx++){
validationRule = validationObjects[ruleIdx];
var test = validationRule.test || "true"; //if not exist, it means the rule should always be applied
if (test) {
var testEval = elemScope.$eval(test);
if (testEval) {
var readonlyExp = ((validationRule.readonly || "").toString().trim()) || "false";
if (!doReadonly) {
var isRequiredExp = validationRule.required || "false";
var isRequiredExpVal;
var minLenExp = (validationRule.minlen || "").toString().trim();
var maxLenExp = (validationRule.maxlen || "").toString().trim();
var pattern = (validationRule.pattern || "").toString().trim();
var isCAPostalCode = (validationRule.isCAPostalCode || "false").toString().trim();
isRequiredExp = (angular.isString(isRequiredExp)?isRequiredExp:isRequiredExp.toString()).trim();
//Required Validation: add attributes only if needed
addRequriedValidation(elem, elemModel, elemScope, isRequiredExp);
addMaxlengthValidation(elem, elemModel, elemScope, maxLenExp);
addMinlengthValidation(elem, elemModel, elemScope, minLenExp);
addCAPostalCodeValidation(elem, elemModel, elemScope, isCAPostalCode);
} else
if (readonlyExp && readonlyExp !== conLoaded){
var readonlyLoaded;
readonlyLoaded = addReadonlyRule(elem, elemModel, elemScope, readonlyExp);
if (readonlyLoaded) {
validationRule.readonly = conLoaded;
}
}
}
}
//For now, just evaluate the first rule;
break;
}
} else {
console.error("Unexpected error in 'loadValidationRules()': type of 'validationObjects' is unknown with value = %o", validationObjects);
}
}
}
And the code below can be used to load and trigger validation:
function ngProcessReviewCore() {
//Add css class for invalid radion buttons
var appUrl = getAppURL();
var isFormValid = false;
if (BusinessLogic.isValidationDynamic()) {
isFormValid = $scope.mainForm.$valid;
} else {
isFormValid = $scope.mainForm.$valid;
}
if(isFormValid) {
//Submit to server here
} else {
$timeout(function () {
$scope.addValidationClassRadio();
});
popUpMsg("infoPopUp", "Please fill the required field and clear validation errors!");
}
}
//Integrated with Angular.
//This is needed to ensure validation is integrated with Angular.
//Implement manual validation
$scope.ngProcessReview = function () {
$scope.startExecValidations();
if (BusinessLogic.isValidationManual()) {
//Load validation rules before start of validation
BusinessLogic.loadValidationRules();
}
//Use timeout to give time for validations to be reflected
$timeout(function(){
ngProcessReviewCore();
}, 100)
}
and, to load the readonly
rules, you need to run this code when done rendering all elements:
$scope.runWhenDone = function () {
console.log('Load only readonly rules...');
var loadOnlyReadonlyRules = true;
BusinessLogic.loadValidationRules(loadOnlyReadonlyRules)
}
And you can use the directive when-rendering-done
as defined in this solutions:
<body ng-app="myApp" ng-controller="formMainController as MainController" when-rendering-done="runWhenDone()">
...
...
</body>
From what you have said the slow loading is due to AngularJS working as opposed to data loading [as evidenced by the fact its slower in IE than Chrome]. If this is true then a loading indicator wont help as it'll just freeze too.
You are far better off following normal performance techniques in angular such as:
{{::vm.name}}
<div ng-bind="::vm.name"></div>
rather than handle bars {{::vm.name}}
Here is my solution based on solution by @andrew above and using ngProgress Bar component.
CSS:
#ngProgress-container.block-editing {
pointer-events: all;
z-index: 99999;
border: none;
/* margin: 0px; */
padding: 0px;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
cursor: wait;
position: fixed;
background-color: rgba(0, 0, 0, 0.33);
margin-top:10px;
#ngProgress {
margin-top:-9px;
width:5px; /* Force display progress as early as possible */
opacity:1; /* Force display progress as early as possible */
}
}
JS - in the beginning:
$scope.progressbar = ngProgressFactory.createInstance();
//To force display of progress bar as early as possible
$scope.progressbar.setParent(document.getElementsByTagName("BODY")[0]);
$scope.progressbar.set(1);
$scope.progressbar.getDomElement().addClass('block-editing');
$scope.stopProgressbar = $timeout(function(){
$scope.progressbar.setParent(document.getElementsByTagName("BODY")[0]);
},10);
$timeout(function(){
$scope.progressbar.start();
},100);
JS - in the end:
//Stop progress bar
$interval.cancel($scope.stopProgressbar);
$timeout(function(){
//JIRA: NE-2984 - un-block editing when page loading is done
$($scope.progressbar.getDomElement()).fadeOut(2000, function() {
$($scope.progressbar.getDomElement()).removeClass('block-editing');
});
$scope.progressbar.complete();
}, 3000);
The problem got worse after reaching 1000+ fields. IE 11 took 3+ minutes to complete loading. I did further optimization and now the results are as follows for the time to complete loading:
It is confirmed that the bottleneck is in the loop that will load the validation rules and apply them on the elements, then it will perform compile using $compile
service.
The validation rules are stored in DB using json format and retrieved using requiredFieldsPromise
. See code sample below.
Following is the new updated code for directive check-if-required
:
app.directive('checkIfRequired', function($compile, $parse, $interpolate, $timeout, $q, BusinessLogic){
return {
priority: 100,
terminal: true,
restrict: 'A',
require: '?^form',
link: function (scope, el, attrs, ngForm) {
var saveIsValidationRequired;
var mainElmID = $interpolate(el[0].id)(scope);
var resolvedPromise;
var getChildren = function() {
var resultChildren;
//Return list of elements which were not compiled before 'compiled === undefined'
resultChildren = $(':input', el);
//Use code below just in case we want to extract the elements which are not compiled.
/*resultChildren = $(':input', el).filter(function(){
var result;
result =
($(this).attr('compiled') === undefined)
return result;
});*/
//Use $interpolate to get the final result for each ID...
for (var i=0; i < resultChildren.length; i++) {
if (resultChildren[i].id) {
resultChildren[i].id = $interpolate(resultChildren[i].id)(scope);
}
}
return resultChildren;
}
//User resolvedPromise when no such promise is available.
resolvedPromise = $q.when('resolved');
//Code improvement to make this directive more general
// Since this directive can be used from within an isolated scope directive such as 'photo-list-upload', then
// additional parameters are required to make it work properly.
// Make sure all required functions are defined or report warning.
// If the function is not defined within 'scope' it will be looked up from within 'BusinessLogic.getScope()'.
// If not found at all, default is used, and warning is reported.
scope.getIsValidationRequired = scope.getIsValidationRequired ||
(BusinessLogic.getScope().getIsValidationRequired) ||
(console.warn("Directive 'check-if-required' element '%s' - function 'scope.getIsValidationRequired()' is not defined. It will always be false.", mainElmID),
function () {
return false;
}
);
//The promise 'requiredFieldPromise' is used to retrieve list of validation rules from DB
//Break Point Condition: scope.listData.photosFormName == "subjectPhotos"
scope.stopExecValidations = scope.stopExecValidations || BusinessLogic.getScope().stopExecValidations ||
(console.warn("Directive 'check-if-required' element '%s' - function 'scope.stopExecValidations()' is not defined. Dummy function is used instead.", mainElmID),
function () {
//Dummy
}
);
scope.requiredFieldsPromise =
scope.requiredFieldsPromise || (BusinessLogic.getScope().requiredFieldsPromise) ||
(console.warn("Directive 'check-if-required' element '%s' - function 'scope.requiredFieldsPromise' is not defined. Resolved promise will be used.", mainElmID),
resolvedPromise);
//If needed, stop validation while adding required attribute
//Save current flag value
saveIsValidationRequired = scope.getIsValidationRequired();
scope.stopExecValidations();
//remove the attribute `check-if-required` to avoid recursive calls
el.removeAttr('check-if-required');
// NE-2808 - Define function to add validation message using $watch
// As soon as an error is detected, then 'title' will be set to the error
// Parameters:
// - ngForm: Angualr Form
// - elm: The HTML element being validated
// - errAttr: the name of the error attribute of the field within ngForm:
// ngFormName.FieldName.$error.errAttributeName
// - errMsg: The error message to be added to the title
// - msgVar1: optional substitution variable for the error message
var addValidationMessage = function (ngForm, elm, errAttr, errMsg, msgVar1) {
//Use $timeout to ensure validation rules are added and compiled.
//After compile is done then will start watching errors
$timeout(function(){
var elmModel;
var ngModelName="";
//Get the name of the 'ng-model' of the element being validated
elmModel = angular.element(elm).controller('ngModel');
if (elmModel && elmModel.$name) {
ngModelName = elmModel.$name;
}
if (!ngModelName) {
ngModelName = angular.element(elm).attr('ng-model');
}
if (ngModelName) {
scope.$watch(ngForm.$name + '.' + ngModelName + '.$error.' + errAttr,
function (newValue, oldValue){
//console.log("elm.id =", elm.id);
//The validation error message will be placed on the element 'title' attribute which will be the field 'tooltip'.
//newValue == true means there is error
if (newValue) {
var msgVar1Val;
//Perform variable substitution if required to get the final text of the error message.
if (msgVar1) {
msgVar1Val = scope.$eval(angular.element(elm).attr(msgVar1));
errMsg = errMsg.format(msgVar1Val);
}
//Append the error to the title if neeeded
if (elm.title) {
elm.title += " ";
} else {
elm.title = "";
}
elm.title += errMsg;
} else {
//Remove the error if valid.
//child.removeAttribute('title');
if (elm.title) {
//Remplace the error message with blank.
elm.title = elm.title.replace(errMsg, "").trim();
}
}
});
} else {
//console.warn("Warning in addValidationMessage() for element ID '%s' in ngForm '%s'. Message: 'ng-model' is not defined.", elm.id, ngForm.$name)
}
}, 1000);
}
function doApplyValidation(scope, el, attrs, ngForm) {
var children;
children = getChildren();
mainElmID = $interpolate(el[0].id)(scope);
validationList=formView.getRequiredField();
for (var subformIdx=0; subformIdx < Object.keys(validationList).length; subformIdx++) {
var keySubform = Object.keys(validationList)[subformIdx];
var subform = validationList[keySubform];
var lastFieldID;
lastFieldID = Object.keys(subform)[Object.keys(subform).length-1];
for (var childIdx=0; childIdx < Object.keys(subform).length; childIdx++) {
var childID = Object.keys(subform)[childIdx];
var validObjects;
var childElm;
var child;
var elmScope;
var elmModel;
childID = childID.trim();
//Find the element with id = childID within the 'el' section.
//Use 'getChildren()' since the result list has ID values which are interpolated.
childElm = children.filter('#'+childID);
if (childElm.length) {
//Validation rule for 'childID': related element was found, and now will apply validation rule.
validObjects = subform[childID];
child = childElm.get(0);
elmScope = angular.element(child).scope() || scope;
elmModel = angular.element(child).controller('ngModel');
var maxlength = scope.$eval(angular.element(child).attr('ng-maxlength'));
//var errMsg = ("Number of characters entered should not exceed '{0}' characters.").format(maxlength);
// NE-2808 - add validation message if length exceeds the max
var errMsg = "Number of characters entered should not exceed '{0}' characters.";
addValidationMessage(ngForm, child, 'maxlength', errMsg, 'ng-maxlength'); //Check if the element is not in "Required" list, and it has an expression to control requried, then
//... add the attribute 'ng-required' with the expression specified to the element and compile.
if (!angular.element(child).prop('required') && child.attributes.hasOwnProperty("check-if-required-expr")) {
console.error("Unexpected use for attribute 'check-if-required-expr' in directive 'check-if-required' for element ID '%s'. Will be ignored.", childID);
}
if (validObjects === "") {
//This means the field is required
angular.element(child).attr('ng-required', "true");
}
else if (angular.isArray(validObjects)) {
//This means that there is a list of validation rules
for (var idx=0; idx < validObjects.length; idx++) {
var validObject = validObjects[idx];
var test = validObject.test || "true"; //if not exist, it means the rule should always be applied
var minLenExp = validObject.minlen;
var maxLenExp = validObject.maxlen;
var isRequiredExp = validObject.required || false;
var readonlyExp = validObject.readonly || null;
var pattern = validObject.pattern || "";
var isCAPostalCode = validObject.isCAPostalCode || false;
isRequiredExp = angular.isString(isRequiredExp)?isRequiredExp:isRequiredExp.toString();
if (test) {
var testEval = scope.$eval(test, elmScope);
if (testEval) {
if (minLenExp) {
angular.element(child).attr('ng-minlength', minLenExp);
}
if (maxLenExp) {
angular.element(child).attr('ng-maxlength', maxLenExp);
}
//If the "required" expression is '*skip*' then simply skip.
//If '*skip*' is used, this means the required validation is already defined in code
//and no need to replace it.
if (isRequiredExp && isRequiredExp != '*skip*') {
angular.element(child).attr('ng-required', isRequiredExp);
}
// NE-3211 - add readonly validation
if (readonlyExp && readonlyExp != '*skip*') {
angular.element(child).attr('ng-readonly', readonlyExp);
}
if (pattern) {
angular.element(child).attr('ng-pattern', pattern);
}
if (isCAPostalCode) {
angular.element(child).attr('ng-pattern', "/^([A-Z]\\d[A-Z] *\\d[A-Z]\\d)$/i");
// NE-2808 - add validation message if postal code does not match the RegEx
addValidationMessage(ngForm, child, 'pattern', "Invalid postal code.");
}
//delete the validation rule after it is implemented to improve performance
delete subform[childID]
//TODO: Apply only the first matching validation rule
// May required further analysis if more that one rule will be added.
break;
}
}
}
}
}
} // for loop
} // for loop
//After done processing all elements under 'el', compile the parent element 'el'.
$compile(el, null, 100)(scope);
//If saved flag value is true, enable back validation
if (saveIsValidationRequired) {
scope.startExecValidations();
}
}
function applyValidationTimeout() {
//Execute 'doApplyValidation()' in the next cycle, to ensure the child elements have been rendered.
$timeout(function(){
//console.log('applyValidationTimeout', mainElmID);
doApplyValidation(scope, el, attrs, ngForm);
}, 100)
}
scope.requiredFieldsPromise.then(function(success) {
//Apply validation when the Required Fields and Validation Rules have been loaded.
applyValidationTimeout();
}, function(prmError){
console.warn("Error occured in 'check-if-required' directive while retrieving 'requiredFieldsPromise' for element '%s': %s", mainElmID, prmError);
});
}
}
});
Though the performance now is much better now, however, I realized that the problem is in using $compile
, therefore, I am now thinking to find a solution by avoiding use of $compile
. Here is my plan.
Instead of modifying the element HTML by adding 'ng-required' directive, then compile, instead, I can skip HTML and use the ngModel.NgModelController
of the related HTML Element, then access the $validators
to perform validation using code. If you read the code above, you will see that I have already accessed the ngModel.NgModelController
for each element in variable elmModel
. I think this variable will provide access to $validators
which can be used to add validation to the element. Since the rules are now available in validationList
variable, I will write a function to perform validation by looking up this list and apply the available validation on-the-fly.
This will be the improvement in the future sprints.
If you have any feedback, please let me know.
Tarek
Finally, I was able to achieve the best acceptable performance for Chrome and IE.
Following are the main changes that fixed the problem in the previous code:
//delete validationList[childID]
required='required'
instead of ng-required='true'
.Following is the updated code for directive check-if-required
:
app.directive('checkIfRequired', function($compile, $parse, $interpolate, $timeout, $q, BusinessLogic){
return {
priority: 100,
terminal: true,
restrict: 'A',
require: '?^form',
link: function (scope, el, attrs, ngForm) {
var saveIsValidationRequired;
var mainElmID = $interpolate(el[0].id)(scope);
var resolvedPromise;
var getChildren = function(el, doInterpolate) {
var resultChildren;
doInterpolate = (doInterpolate===undefined)?true:doInterpolate;
//Return list of elements which were not compiled before 'compiled === undefined'
resultChildren = $(':input', el);
//Use code below just in case we want to extract the elements which are not compiled.
//Use $interpolate to get the final result for each ID...
if (doInterpolate) {
for (var i=0; i < resultChildren.length; i++) {
if (resultChildren[i].id) {
resultChildren[i].id = $interpolate(resultChildren[i].id)(scope);
}
}
}
//resultChildren = resultChildren.filter("[id!='']");
return resultChildren;
}
//User resolvedPromise when no such promise is available.
resolvedPromise = $q.when('resolved');
//Code improvement to make this directive more general
// Since this directive can be used from within an isolated scope directive such as 'photo-list-upload', then
// additional parameters are required to make it work properly.
// Make sure all required functions are defined or report warning.
// If the function is not defined within 'scope' it will be looked up from within 'BusinessLogic.getScope()'.
// If not found at all, default is used, and warning is reported.
scope.getIsValidationRequired = scope.getIsValidationRequired ||
(BusinessLogic.getScope().getIsValidationRequired) ||
(console.warn("Directive 'apply-validation' element '%s' - function 'scope.getIsValidationRequired()' is not defined. It will always be false.", mainElmID),
function () {
return false;
}
);
//The promise 'requiredFieldPromise' is used to retrieve list of validation rules from DB
//Break Point Condition: scope.listData.photosFormName == "subjectPhotos"
scope.stopExecValidations = scope.stopExecValidations || BusinessLogic.getScope().stopExecValidations ||
(console.warn("Directive 'apply-validation' element '%s' - function 'scope.stopExecValidations()' is not defined. Dummy function is used instead.", mainElmID),
function () {
//Dummy
}
);
scope.requiredFieldsPromise =
scope.requiredFieldsPromise || (BusinessLogic.getScope().requiredFieldsPromise) ||
(console.warn("Directive 'apply-validation' element '%s' - function 'scope.requiredFieldsPromise' is not defined. Resolved promise will be used.", mainElmID),
resolvedPromise);
//If needed, stop validation while adding required attribute
//Save current flag value
saveIsValidationRequired = scope.getIsValidationRequired();
scope.stopExecValidations();
//remove the attribute `check-if-required` to avoid recursive calls
el.removeAttr('check-if-required');
//Define function to add validation message using $watch
// As soon as an error is detected, then 'title' will be set to the error
// Parameters:
// - ngForm: Angualr Form
// - elm: The HTML element being validated
// - errAttr: the name of the error attribute of the field within ngForm:
// ngFormName.FieldName.$error.errAttributeName
// - errMsg: The error message to be added to the title
// - msgVar1: optional substitution variable for the error message
var addValidationMessage = function (ngForm, elm, errAttr, errMsg, msgVar1, elmScope, elmModel) {
//Use $timeout to ensure validation rules are added and compiled and that the 'elmModel' is available.
//After compile is done then will start watching errors
$timeout(function(){
var ngModelName="";
//Get the name of the 'ng-model' of the element being validated
elmScope = elmScope || scope;
elmModel = elmModel || angular.element(elm).controller('ngModel');
if (elmModel && elmModel.$name) {
ngModelName = elmModel.$name;
}
if (!ngModelName) {
ngModelName = angular.element(elm).attr('ng-model');
}
if (ngModelName) {
elmScope.$watch(ngForm.$name + '.' + ngModelName + '.$error.' + errAttr,
function (newValue, oldValue){
//console.log("elm.id =", elm.id);
//The validation error message will be placed on the element 'title' attribute which will be the field 'tooltip'.
//newValue == true means there is error
if (newValue) {
var msgVar1Val;
//Perform variable substitution if required to get the final text of the error message.
if (msgVar1) {
msgVar1Val = elmScope.$eval(angular.element(elm).attr(msgVar1));
errMsg = errMsg.format(msgVar1Val);
}
//Append the error to the title if neeeded
if (elm.title) {
elm.title += " ";
} else {
elm.title = "";
}
elm.title += errMsg;
} else {
//Remove the error if valid.
//child.removeAttribute('title');
if (elm.title) {
//Replace the error message with blank.
elm.title = elm.title.replace(errMsg, "").trim();
}
}
});
} else {
//console.warn("Warning in addValidationMessage() for element ID '%s' in ngForm '%s'. Message: 'ng-model' is not defined.", elm.id, ngForm.$name)
}
}, 1000);
}
//Refactor - apply validation rule for a given element with `childID`
function applyValidationElement(childID, child, childElm, elmScope, elmModel, validationList) {
//Validation rule for 'childID': related element was found, and now will apply validation rule.
var validObjects;
var errMsg;
validObjects = validationList[childID];
//Check if the element is not in "Required" list, and it has an expression to control requried, then
//... add the attribute 'ng-required' with the expression specified to the element and compile.
//No longer use `check-if-required-expr`, must report error if used.
if (!angular.element(child).prop('required') && child.attributes.hasOwnProperty("check-if-required-expr")) {
console.error("Unexpected use for attribute 'check-if-required-expr' in directive 'apply-validation' for element ID '%s'. Will be ignored.", childID);
}
if (validObjects === "") {
//This means the field is required
angular.element(child).attr('required', 'required');
}
else if (angular.isArray(validObjects)) {
//This means that there is a list of validation rules
for (var idx=0; idx < validObjects.length; idx++) {
var validObject = validObjects[idx];
var test = validObject.test || "true"; //if not exist, it means the rule should always be applied
var minLenExp = validObject.minlen;
var maxLenExp = validObject.maxlen;
var isRequiredExp = validObject.required || "false";
var readonlyExp = (validObject.readonly || "").toString().trim();
var pattern = validObject.pattern || "";
var isCAPostalCode = (validObject.isCAPostalCode || "false").toString().trim();
isRequiredExp = (angular.isString(isRequiredExp)?isRequiredExp:isRequiredExp.toString()).trim();
if (test) {
var testEval = scope.$eval(test, elmScope);
if (testEval) {
if (minLenExp) {
angular.element(child).attr('ng-minlength', minLenExp);
}
if (maxLenExp) {
angular.element(child).attr('maxlength', maxLenExp);
//For now, to improve performance, do not add validation message - if length exceeds the max
//errMsg = "Number of characters entered should not exceed '{0}' characters.";
//addValidationMessage(ngForm, child, 'maxlength', errMsg, 'ng-maxlength', elmScope, elmModel);
}
//Add attributes only if needed
if (isRequiredExp.toLowerCase() === "true") {
angular.element(child).attr('required', 'required');
} else
//If the "required" expression is '*skip*' then simply skip.
//If '*skip*' is used, this means the required validation is already defined in code
//and no need to replace it.
if (isRequiredExp && isRequiredExp.toLowerCase() !== "false" && isRequiredExp !== '*skip*') {
angular.element(child).attr('ng-required', isRequiredExp);
}
if (readonlyExp.toLowerCase() === "true" && readonlyExp != '*skip*') {
angular.element(child).attr('readonly', 'readonly');
} else
//Add readonly validation
if (readonlyExp && readonlyExp.toLowerCase() !== "false" && readonlyExp != '*skip*') {
angular.element(child).attr('ng-readonly', readonlyExp);
}
if (pattern) {
angular.element(child).attr('ng-pattern', pattern);
}
if (isCAPostalCode.toLowerCase() === "true") {
angular.element(child).attr('ng-pattern', "/^([A-Z]\\d[A-Z] *\\d[A-Z]\\d)$/i");
//Add validation message if postal code does not match the RegEx
addValidationMessage(ngForm, child, 'pattern', "Invalid postal code.", null, elmScope, elmModel);
}
//TODO: delete the validation rule after it is implemented to improve performance
// verify if deleting the key is OK and will not distroy the for-loop index
//delete validationList[childID]
//TODO: Apply only the first matching validation rule
// May required further analysis if more that one rule will be added.
break;
}
}
}
}
}
//Check of trim for Field ID is done - not needed if field ID is already timmed in `validationList`.
//BusinessLogic.getScope().mainVM.validFieldIDTrimDone = BusinessLogic.getScope().mainVM.validFieldIDTrimDone || false;
//var validFieldIDTrimDone;
function doApplyValidation(scope, el, attrs, ngForm) {
var children;
var fieldValidList;
var validationStructOpt;
var mainElmID;
children = getChildren(el, true); //Do run interpolation of elements IDs
//children = resultChildren = $(':input', el); //getChildren(el, false); //Do not run interpolation of elements IDs
mainElmID = $interpolate(el[0].id)(scope);
validationList=formView.getRequiredField();
//Get 'validationStructOpt' option:
// Option = 'onelayer' means there is no 'subform' layer
// Option = 'twolayers' means there is a 'subform' layer which is the default
validationStructOpt = validationList.$structureOpt || 'twolayers';
if (validationStructOpt === 'onelayer') {
//for (var fldIdx=0; fldIdx < Object.keys(validationList).length; fldIdx++) {
//console.log("One layer. Number of rules: ", Object.keys(validationList).length)
for (var fldIdx=0; fldIdx < children.length; fldIdx++) {
var childElm;
var child;
var childID;
var validObjects;
var elmScope;
var elmModel;
//childID = Object.keys(validationList)[fldIdx];
//if (childID.startsWith('$')) {
// continue;
//}
//childElm = children.filter('#'+childID);
//child = childElm.get(0);
childElm = children.eq(fldIdx);
child = childElm.get(0);
child.id = $interpolate(child.id)(scope);
childID = child.id;
//if (childElm.length) {
if (childID && (childID in validationList)) {
//Validation rule for 'childID': related element was found, and now will apply validation rule.
validObjects = validationList[childID];
elmScope = angular.element(child).scope() || scope;
elmModel = angular.element(child).controller('ngModel');
applyValidationElement(childID, child, childElm, elmScope, elmModel, validationList);
}
}
} else
if (validationStructOpt === 'twolayers') {
//angular.forEach(Object.keys(validationList), function(keySubform, subformIdx){
for (var subformIdx=0; subformIdx < Object.keys(validationList).length; subformIdx++) {
var keySubform = Object.keys(validationList)[subformIdx];
if (!keySubform.startsWith('$')) {
var subform = validationList[keySubform];
for (var childIdx=0; childIdx < Object.keys(subform).length; childIdx++) {
var childID = Object.keys(subform)[childIdx];
var validObjects;
var childElm;
var child;
var elmScope;
var elmModel;
//console.log(subform, validObjects, childID);
//Find the element with id = childID within the 'el' section.
//childElm = $('#'+childID, el);
//Use 'getChildren()' since the result list has ID values which are interpolated.
childElm = children.filter('#'+childID);
//console.log(el[0].id, childID);
if (childElm.length) {
//Validation rule for 'childID': related element was found, and now will apply validation rule.
validObjects = subform[childID];
child = childElm.get(0);
elmScope = angular.element(child).scope() || scope;
elmModel = angular.element(child).controller('ngModel');
applyValidationElement(childID, child, childElm, elmScope, elmModel, validationList[keySubform]);
}
}
}
} //Object.keys(validationList).length
//});
}
//After done processing all elements under 'el', compile the parent element 'el'.
$compile(el, null, 100)(scope);
//If saved flag value is true, enable back validation
if (saveIsValidationRequired) {
scope.startExecValidations();
}
}
function applyValidationTimeout() {
//Execute 'doApplyValidation()' in the next cycle, to ensure the child elements have been rendered.
$timeout(function(){
//console.log('applyValidationTimeout', mainElmID);
doApplyValidation(scope, el, attrs, ngForm);
}, 100)
}
scope.requiredFieldsPromise.then(function(success) {
//Apply validation when the Required Fields and Validation Rules have been loaded.
applyValidationTimeout();
}, function(prmError){
console.warn("Error occured in 'apply-validation' directive while retrieving 'requiredFieldsPromise' for element '%s': %s", mainElmID, prmError);
});
}
}
});
Following are the performance results for loading 1000+ fields and validation rules: