Search code examples
javascriptangularjsangularjs-directiveangularjs-rootscope

Custom expiration date directive throwing rootScope digest error


I have 2 input fields that need to get validated after both fields have been entered. It's a credit card expiration date, so theres a month and year. I'm using a third-party service to actually do the validation.

Therefore, I setup 3 directives: exp, expMonth, and expYear.

I use a $watch to validate the user input - however I would like to show an error if the validation is false. When I attempt to do ng-class with the exampleForm.$error.expiry, I get Error: [$rootScope:infdig] 10 $digest() iterations reached.

Here is a demo http://plnkr.co/edit/SSiSfLB8hEEb4mrdgaoO?p=preview

view.html

  <form name='exampleForm' novalidate>
    <div class="form-group" ng-class="{ 'is-invalid': exampleForm.$error.expiry }">
       <div class="expiration-wrapper" exp>
          <input type='text' ng-model='data.year' name='year' exp-month />
          <input type='text' ng-model='data.month' name='month' exp-year />
       </div>
    </div>

exp.directive.js

  angular.module('example')
    .directive('exp', ['ThirdPartyValidationService',
      function(ThirdPartyValidationService) {
        return {
          restrict: 'A',
          require: 'exp',
          link: function(scope, element, attrs, ctrl) {
            ctrl.watch();
          },
          controller: function($scope, $element, ThirdPartyValidationService) {
            var self = this;
            var parentForm = $element.inheritedData('$formController');
            var ngModel = {
              year: {},
              month: {}
            };

            var setValidity = function(exp) {
              var expMonth = exp.month;
              var expYear = exp.year;
              var valid = ThirdPartyValidationService.validateExpiry(expMonth, expYear);

              parentForm.$setValidity('expiry', valid, $element);
            };

            self.setMonth(monthCtrl) {
              ngModel.month = monthCtrl;
            };

            self.setYear(yearCtrl) {
              ngModel.year = yearCtrl;
            };

            self.watch = function() {
              $scope.$watch(function() {
                return {
                  month: ngModel.month.$modelValue,
                  year: ngModel.year.$modelValue
                };
              }, setValidity, true);
            };
          }
        };
      }]);

expMonth.directive.js

    angular.module('example')
      .directive('expMonth', [
        function() {
          return {
            restrict: 'A',
            require: ['ngModel', '^?exp'],
            compile: function(element, attributes) {
              return function(scope, element, attributes, controllers) {

                var formCtrl = controllers[0];
                var expMonthCtrl = controllers[1];

                expMonthCtrl.setMonth(formCtrl);
              };
            };
          };

        }]);

expYear.directive.js

    angular.module('example')
      .directive('expYear', [
        function() {
          return {
            restrict: 'A',
            require: ['ngModel', '^?exp'],
            compile: function(element, attributes) {
              return function(scope, element, attributes, controllers) {

                var formCtrl = controllers[0];
                var expYearCtrl = controllers[1];

                expYearCtrl.setYear(formCtrl);
              };
            };
          };

        }]);

Solution

  • The problem is that you are setting the validity error to a jquery selection with the line:

    parentForm.$setValidity('expiry', valid, $element);
    

    Then you are watching that jQuery element via ng-class when you have:

    ng-class="{ 'is-invalid' : exampleForm.$error.expiry }"
    

    in your markup. This causes strange exceptions to be thrown when angular tries to deep copy this value.

    Instead, change it to use the .length property of the jQuery object instead. If non-zero (truthy), the is-invalid class will be set correctly, otherwise it won't be.

    ng-class="{ 'is-invalid' : exampleForm.$error.expiry.length }"
    

    You could also do ...expiry.length > 0 if that makes more sense to you and future developers who have to look at your code.

    I forked your Plunkr so it works with this change.