Angular dynamic required validation of group of fi

2019-09-05 04:47发布

问题:

UPDATE 1: the question will be enhanced based on the feedback from the comments.
UPDATE 2: Some progress has been made. Need additinoal help to get through. Please read below.
UPDATE 3: Bug fixing in provided sample code causing duplication in table rows when compiling the element using $compile(el[0])(scope);.

On page load, a list of field names is retrieved from Database which indicates what fields are required using ajax call getRequiredFieldInfo(). This call must be completed successfully before executing the related angular code under the directive check-if-required to manipulate the required attribute. This directive must loop over all input fields and mark them as required based on the list which is retrieved from the Database.

I did some research and found this post which seems to be the closest to my requirements:

https://stackoverflow.com/a/28207652/4180447

and finally found a working jsfiddle version here (updated):

http://jsfiddle.net/tarekahf/d50tr99u/

I can use the following simple approach:

<input name="firstName" type="text" foo ng-required="isFieldRequired('firstName')" />

The function isFieldRequired() will check if the passed field name is found in the list, it will return true.

The problem with this approach is that I have to add this function to each and every field which might be required.

Also, will have to pass the field name each time. To be more efficient, I will have to use a directive on the parent element div or fieldset which will allow me to access all child elements, and process the required attributes for the all the input elements.

This directive need to be changed as follows:

  • To be added to the parent element of the group of fields whose required attribute will be processed and modified if needed.

  • Compare the element name against the list of fields to set as required and apply the change accordingly.

The updated code (as I am researching the solution):

STYLE

input.ng-invalid, li.ng-invalid {
    background:#F84072;
    border: 2px red solid;
}

HTML - NAVIGATION TABS:

<ul  class="nav nav-pills">
    <li ng-class="{'ng-invalid':mainForm.homeForm.$invalid && mainPromiseResolved}" class="active"><a data-toggle="pill" href="#home"><%=homeTabName%></a></li>
    <li ng-class="{'ng-invalid':mainForm.clientForm.$invalid && mainPromiseResolved}"><a data-toggle="pill" href="#menu1"><%=clientTabName%></a></li>
    <li ng-class="{'ng-invalid':mainForm.appraiserForm.$invalid && mainPromiseResolved}"> <a data-toggle="pill" href="#menu2"><%=appraiserTabName%></a></li>
    <li ng-class="{'ng-invalid':mainForm.propertyForm.$invalid && mainPromiseResolved}"><a data-toggle="pill" href="#menu3"><%=propertyTabName%></a></li>
    <li ng-class="{'ng-invalid':mainForm.serviceForm.$invalid && mainPromiseResolved}"><a data-toggle="pill" href="#menu4"><%=servicesTabName%></a></li>
    <li ng-class="{'ng-invalid':mainForm.constructionStage.$invalid && mainPromiseResolved}"><a data-toggle="pill" href="#menu5"><%=constructionStageTabName%></a></li>    
    <li ng-class="{'ng-invalid':mainForm.costForm.$invalid && mainPromiseResolved}"><a data-toggle="pill" href="#menu6"><%=costTabName%></a></li>  
    <li ng-class="{'ng-invalid':mainForm.certificationForm.$invalid && mainPromiseResolved}" ng-click="redrawCanvas()"><a data-toggle="pill" href="#menu7"><%=certificationTabName%></a></li>
    <li ng-class="{'ng-invalid':mainForm.photosForm.$invalid && mainPromiseResolved}"><a data-toggle="pill" href="#menu8"><%=photoTabName%></a></li>
    <li ng-class="{'ng-invalid':mainForm.mapForm.$invalid && mainPromiseResolved}"><a data-toggle="pill" href="#menu9"><%=locationTabName%></a></li>    
</ul>

HTML - Form

<div id="menu2" class="tab-pane fade" ng-form="appraiserForm">
    <fieldset ng-disabled="isAppraiserSigned()" check-if-required>
        <input type="text" id="appraiser_name" name="appraiser_name" ng-model="sigRoles.appraiser.roleNameModel" style="width: 536px; ">
        <input type="text" id="appraiser_company" style="width: 536px; ">
        ... 
        ... 
    </fieldset>
</div>

Javascrip:

app.controller('formMainController', ['$scope', '$timeout', '$q', function($scope, $timeout, $q) {

    $scope.runProcessAndInit = function () {
        var q = $q.defer();     //Create a promise controller
        angular.element(document).ready(function(){
            //perform all client updates here
            q.resolve('success');   //notify execution is completed successfully - inside document 'ready' event.
        })
    return q.promise;   //return the promise object.
    }
    //mainPromiseResolved is used to indicate all ajax calls and client updates are done.
    $scope.mainPromiseResolved = false;
    $scope.mainPromise = $scope.runProcessAndInit();
    $scope.mainPromise.then(function(success) {
        //debugger;
        $scope.$broadcast('event:force-model-update');
        //mainPromiseResolved is mainly used in angular validation to prevent showing errors until all client updates are done.
        $scope.mainPromiseResolved = true;
        return 'main promise done';
    })
    $scope.isFieldRequired = function (prmFieldName) {
        var isFound = false;
        var oRequiredField = formView.getRequiredField();
        findField: {
            for(var subformName in oRequiredField) {
                isFound = prmFieldName in oRequiredField[subformName];
                if (isFound) {
                    break findField;
                }
            }
        }
        return isFound;
    }    
    function getRequiredFieldInfo() {
        var q = $q.defer();
        var appUrl = getAppURL();   
        $.get(appUrl + "/servlet/..."
                    + "&timestamp="     + new Date().getTime(), 
                    function(data, status){
            //console.log("json fields:" + data);           
            var obj = JSON.parse(data);
            formView.setRequiredField(obj);
            q.resolve('success');
            // console.log(JSON.stringify(formView.getRequiredField()));            
        });
        return q.promise;
    }
    $scope.requiredFieldsPromise = getRequiredFieldInfo(); 
}]);

app.directive('checkIfRequired', ['$compile', function ($compile) {
    return {
        require: '?ngModel',
        link: function (scope, el, attrs, ngModel) {
            if (!ngModel) {
                //return;
            }
            //debugger;
            var children = $(":input", el);
            angular.element(document).ready(function (){
                scope.requiredFieldsPromise.then(function(success) {
                    //remove the attribute to avoid recursive calls
                    el.removeAttr('check-if-required');
                    //Comment line below as it caused duplication in table raws, and I don't know why.
                    //$compile(el[0])(scope);
                    angular.forEach(children, function(value, key) {
                        //debugger;
                        if (scope.isFieldRequired(value.id)) {
                            angular.element(value).attr('required', true);
                            //el.removeAttr('check-if-required');
                            $compile(value)(scope);
                        }
                    });
                })
            })
        }
    };
}]); 

I've already made some progress. However, I still need more help. Following is the status:

  • Done: get required fields list from DB and then execute code in directive to manipulate the required attribute.
  • Done: Loop over the child input elements from a given angular element el which is passed to link function function (scope, el, attrs, ngModel).

  • Done: Add required attribute to each child element if isFieldRequired(fieldName) is true?

  • Done: Use promise to ensure all ajax DB calls and client updates are done before executing angular code.

  • How to recursively loop over the child elements if they are nested inside another ng-form subform or div element?

  • How to ensure that each element has ngModel object?

  • How to restrict the directive to div, fieldsset or similar elements?

Tarek

回答1:

The following code will satisfy the main requirement, in addition, for each element under the div block, it will allow adding attribute check-if-required-expr. This new attribute may be used to call a scope boolean expression to decide the required attribute in case the field is not found the list of required fields.

I was wondering if there is a way to use the standard ng-required directive instead of the custom attribute check-if-required-expr which basically does the same as ng-required. The only problem if I use ng-required is that it might override the logic of the required field if it was specified in the list.

So the question here: is there a way to find out if the required attribute is set, and if yes, then do not check the required expression, otherwise, execute the ng-required expression.

HTML

<div id='signature-pad' class="m-signature-pad break" ng-class="{'ng-invalid':certificationForm[theRoleData.signatureBase64].$invalid && mainPromiseResolved}" check-if-required>
...
    <div class="m-signature-pad--body">
        <canvas id="appraiser_signature_section" redraw ng-signature-pad="signature" ng-hide="isSigned()">
        </canvas>
        <img ng-src="{{signatureDataURL()}}" ng-hide="!isSigned()" load-signature-image>
        <input id="{{theRoleData.signatureBase64}}" name="{{theRoleData.signatureBase64}}" type="text" ng-hide="true" ng-model="signatureBase64" check-if-required-expr="sigDetailsAvail(theRoleData)" force-model-update/>
    </div>
...

</div>

Basically, in the above HTML, the input field which has check-if-required-expr, indicates that if this field is not found in the list of required fields then execute the expression to decide if the field is required.

JavaScript

//Define directive check-if-required
//This directive will loop over all child input elements and add the required attributes if needed
app.directive('checkIfRequired', ['$compile', '$timeout', '$parse', function ($compile, $timeout, $parse) {
    return {
        /*require: '?ngModel',*/
        require: '?^form',
        link: function (scope, el, attrs, ngForm) {
            /*if (!ngModel) {
                return;
            }*/
            var saveIsValidationRequired;
            var children;
            saveIsValidationRequired = scope.isValidationRequired;  //Save current flag value
            scope.stopExecValidations();
            el.removeAttr('check-if-required');
            $timeout(function() {
                //Get all input elements of the descendants of `el` 
                children = $(":input", el);
                //Run the following as early as possible but just wait (using promise) until 
                //  the list of required fields is retrieved from Database
                //scope.requiredFieldsPromise.then(function(success) {
                scope.requiredFieldsPromise.then(function(success) {
                    //The line below caused duplication of the table in construction stage, so it is removed and no impact
                    //$compile(el[0])(scope);
                    angular.forEach(children, function(child, key) {
                        var elmScope;
                        var elmModel;
                        try {
                            if(child && child.id) {
                                elmScope = angular.element(child).scope() || scope;
                                elmModel = angular.element(child).controller('ngModel');
                                if (ngForm && elmModel && ngForm[elmModel.$name]) {
                                    scope.$watch(function(){
                                        //Watch the errors for the defined field - convert to JSON string.
                                        return JSON.stringify(ngForm[elmModel.$name].$error);
                                    }, function (newValue, oldValue){
                                        //The validation error message will be placed on the element 'title' attribute which will be the field 'tooltip'. 
                                        var maxlength;
                                        var minlength;
                                        if (angular.isDefined(newValue)) {
                                            if (ngForm[elmModel.$name].$error.maxlength) {
                                                //If invalid, add the error message if number of entered characters is more than the defined maximum
                                                maxlength = scope.$eval(angular.element(child).attr('ng-maxlength'));
                                                child.title = ("Number of characters entered should not exceed '{0}' characters.").format(maxlength);
                                            } else {
                                                //Remove the error if valid.
                                                child.removeAttribute('title');
                                            }
                                        }
                                    });
                                }
                                if (scope.isFieldRequired(child.id)) {
                                    angular.element(child).attr('ng-required', "true");
                                    $compile(child)(elmScope);
                                }
                                //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")) {
                                    var isRequiredExpr = child.attributes["check-if-required-expr"].child;
                                    angular.element(child).attr('ng-required', isRequiredExpr);
                                    $compile(child)(elmScope);
                                }
                                var validObjects = scope.getFieldValidation(child.id);
                                if (angular.isArray(validObjects)) {
                                    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;
                                        isRequiredExp = angular.isString(isRequiredExp)?isRequiredExp:isRequiredExp.toString();
                                        //scope.$evalAsync(function(){
                                            if (test && (minLenExp || maxLenExp || isRequiredExp)) {
                                                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);
                                                    }
                                                    //Change how '$compile()' is used.
                                                    //      After reserach, found there is bug in Angular which is causing the fillowing issues when using '$compile()':
                                                    //      1. Duplicate values for drop-down list items.
                                                    //      2. Inteference with dateppciker Angular UI Bootstrap control
                                                    //      If this still happes, more research is needed to resolve this problem.
                                                    //      This is still work-in-progress. More research is needed.
                                                    //The compile statement below will be replaced ...
                                                    $compile(child)(elmScope, function (clone) {
                                                        angular.element(child).after(clone);     
                                                        angular.element(child).remove();
                                                    });
                                                    //Apply only the first matching validation rule
                                                    break;
                                                }
                                            }
                                    }
                                }
                            }
                        } catch (e) {
                            console.error("Error occuured in 'checkIfRequired' directive while applying validation logic on element ID '%s'. Error is: '%s'", child.id, e);
                        }
                    });
                    //If saved flag value is ture, enable validation
                    if (saveIsValidationRequired) {
                        scope.startExecValidations();
                    }
                });
            });
            //})
        }
    };
}]);