Angular 1.6 Custom validation directive with $setValidity

2.5k views Asked by At

I'm attempting to write a custom directive that validates a input field against other values that also needs to available inside the directive. I do this by using isolated scope with scope variables. More specifically I would like to compare the customer-price of a product (i.e. its netprice) with the purchase price and if the differece is negative (with the exception of the customer price being set to 0) I'd like to make the customer-price input (and its surrounding form) invalid. Here's my directive:

export class CheckMarkupDirective implements ng.IDirective {
    public static create(): ng.IDirective {
        return {
            restrict: "A",
            require: "ngModel",
            scope: {
                netPrice: "<",
                markupAmount: "<"
            },
            link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, ngModelCtrl: ng.INgModelController) => {

                let netPrice: number;
                let markupAmount: number;
                scope.$watchGroup(["netPrice", "markupAmount"], (newValues, oldValues) => {

                    [netPrice, markupAmount] = newValues;

                    if (markupAmount >= 0) {
                        ngModelCtrl.$setValidity("markup", true);
                    } else {
                        ngModelCtrl.$setValidity("markup", netPrice === 0);
                    }
                    ngModelCtrl.$validate();
                });
            }
        };
    }
}

And this is how I'm using it inside a a ng-form div surrounded by a form-tag:

<input type="text" id="customer-price" name="customerPrice"
       ng-model="ctrl.product.customerPrice"
       ng-change="ctrl.customerPriceChangeDetected()" 
       check-markup markup-amount="ctrl.product.markupAmount"
       net-price="ctrl.product.netPrice" />

It works, after a fashion but the problem is that the validation part seems to be "timed wrong", meaning that if I enter a value that causes the "markup" to become negative the first time, then the form's $invalid value is set to false. But when next the input is negative then the validation will kick in and work. I think my problem is that I'm doing a lot of calculations in between the differen steps, but it's hard for me to know what's causing the validation to be so off. I guess I'd like someone with a deeper knowledge of the Angular JS mechanics to have gander and tell me if I'm doing something obviously wrong. Thanks in advance and sorry if my description is kind of vague.

Edit: Thought I'd also include the methods that are triggered on ng-change:

public customerPriceChangeDetected(): void {
    this.setNetPriceFromCustomerPrice();
    this.setMarkup();
    this.changeDetected();
}
private setNetPriceFromCustomerPrice(): void {
    let customerPrice = this.product.customerPrice;
    let vatRate = this.product.vatRate;
    let netPrice = (customerPrice / (1 + vatRate));
    this.product.netPrice = parseFloat(accounting.toFixed(netPrice, 2));
}
private setMarkup(): void {
    let purchasePrice = this.product.purchasePrice;
    let markupAmount = this.product.netPrice - purchasePrice;
    this.product.markupAmount = markupAmount;
    this.product.markupPercent = markupAmount / purchasePrice;
}
public changeDetected(): void {
    let isValid = this.validationService ? this.validationService.isValid : false;
    this.toggleSaveButton(isValid);
}

The validation service getter basically returns form.$valid and works perfectly fine for all my other custom validators.

Edit 2: Added screenshot that show that the surrounding ng-form tag seems to have its $invalid property set to true atleast: enter image description here

Edit 3: Here's the transpiled JS:

var CheckMarkupDirective = (function () {
function CheckMarkupDirective() {
}
CheckMarkupDirective.create = function () {
    return {
        restrict: "A",
        require: "ngModel",
        scope: {
            netPrice: "<",
            markupAmount: "<"
        },
        link: function (scope, element, attrs, ngModelCtrl) {
            var netPrice;
            var markupAmount;
            scope.$watchGroup(["netPrice", "markupAmount"], function (newValues, oldValues) {
                netPrice = newValues[0], markupAmount = newValues[1];
                if (!markupAmount || !netPrice)
                    return;
                if (markupAmount >= 0) {
                    ngModelCtrl.$setValidity("markup", true);
                }
                else {
                    ngModelCtrl.$setValidity("markup", netPrice === 0);
                }
                //ngModelCtrl.$validate();
            });
        }
    };
};
return CheckMarkupDirective; }());

...and here's a cut down version of my html:

<form autocomplete="off" class="form-horizontal" role="form" name="productDetailsForm" novalidate data-ng-init="ctrl.setForm(this,'productDetailsForm')">
<div data-ng-form="section2">
    <div class="form-group">
        <label for="purchase-price" class="col-sm-4 control-label">Purchase price</label>
        <div class="col-sm-4">
            <input type="text" class="form-control" id="purchase-price" name="purchasePrice"
                   data-ng-model="ctrl.product.purchasePrice"
                   data-ng-change="ctrl.purchasePriceChangeDetected();"
                   data-decimal="Currency" />
        </div>
    </div>
    <div class="form-group">
        <label for="vat-rate" class="col-sm-4 control-label">VAT rate</label>
        <div class="col-sm-4">
            <select class="form-control" id="vat-rate"
                    data-ng-model="ctrl.product.vatRate"
                    data-ng-change="ctrl.vatRateChangeDetected()"
                    data-ng-options="vatRate.value as vatRate.text for vatRate in ctrl.vatRates"></select>
        </div>
    </div>
    <div class="form-group" data-has-error-feedback="productDetailsForm.section2.customerPrice">
        <label for="customer-price" class="col-sm-4 control-label">Customer price</label>
        <div class="col-sm-4">
            <input type="text" class="form-control" id="customer-price" name="customerPrice"
                   data-ng-model="ctrl.product.customerPrice"
                   data-ng-change="ctrl.customerPriceChangeDetected();"
                   data-decimal="Currency"
                   data-check-markup
                   data-markup-amount="ctrl.product.markupAmount"
                   data-net-price="ctrl.product.netPrice" />
            <invalid-feedback item="productDetailsForm.section2.customerPrice"></invalid-feedback>
            <validation-feedback type="markup" item="productDetailsForm.section2.customerPrice" data-l10n-bind="ADMINISTRATION.PRODUCTS.NET_PRICE.INVALID"></validation-feedback>
        </div>
        <div class="col-sm-4">
            <div class="form-group" style="margin-bottom: 0;">
                <label for="net-price" class="col-lg-5 col-md-5 col-sm-5 col-xs-5" style="font-weight: normal; margin-top: 7px;">
                    <span data-l10n-bind="ADMINISTRATION.PRODUCTS.NET_PRICE"></span>
                </label>
                <label class="col-lg-7 col-md-7 col-sm-7 col-xs-7" style="font-weight: normal; margin-top: 7px;">
                    <span id="net-price">{{ ctrl.product.netPrice | currency }}</span>
                </label>
            </div>
        </div>
    </div>
    <div class="form-group" data-has-error-feedback="productDetailsForm.section2.markup">
        <label for="markup-amount" class="col-sm-4 col-xs-4 control-label">Markup</label>
        <div class="col-sm-8 col-xs-8">
            <label id="markup-percent" class="control-label" data-ng-class="{'text-danger': ctrl.product.markupPercent < 0}">
                {{ ctrl.product.markupPercent * 100 | number: 2 }}%
            </label>
            <label id="markup-amount" class="control-label" data-ng-class="{'text-danger': ctrl.product.markupAmount < 0}">
                ({{ ctrl.product.markupAmount | currency }})
            </label>
        </div>
    </div>
</div>

I've put breakpoints inside the watch in the directive and for some weird reason the watch doesn't seem to trigger the first time I enter a new value into the customer-price input. Instead I find myself directly inside the changeDetected() method. I'm really confused now. I think the problem has something todo with the ng-change directive triggering before the validation. I probably has a faulty logic there which results in the isValid check of my validation service triggering before the directive has had time to actually alter the validity.

2

There are 2 answers

1
georgeawg On

Try removing the isolate scope and evaluate the attributes directly:

export class CheckMarkupDirective implements ng.IDirective {
    public static create(): ng.IDirective {
        return {
            restrict: "A",
            require: "ngModel",
            /* REMOVE isolate scope
            scope: {
                netPrice: "<",
                markupAmount: "<"
            },
            */
            link: (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes, ngModelCtrl: ng.INgModelController) => {

                let netPrice: number;
                let markupAmount: number;
                //scope.$watchGroup(["netPrice", "markupAmount"],
                //WATCH attributes directly
                scope.$watchGroup([attrs.netPrice, attrs.markupAmount], (newValues, oldValues) => {

                    [netPrice, markupAmount] = newValues;

                    if (markupAmount >= 0) {
                        ngModelCtrl.$setValidity("markup", true);
                    } else {
                        ngModelCtrl.$setValidity("markup", netPrice === 0);
                    }
                    ngModelCtrl.$validate();
                });
            }
        };
    }
}

The input, ng-model, and ng-change directives expect an element without scope. This removes the one-time binding watchers and the complications of an isolate scope fighting those directives.

7
Walfrat On

I have reproduce what I think your form is doing and I have no problem if I add the ng-change on all fields (vatRate, purchasePrice, customerPrice).

Can you check if what I did match do what your typescript give ? If not can you try to show us the result as javascript ?

angular.module('test',[]).directive('checkMarkup', [function(){
  return {
            restrict: "A",
            require: "ngModel",
            scope: {
                netPrice: "<",
                markupAmount: "<"
            },
            link: (scope, element, attrs, ngModelCtrl) => {
                var netPrice;
                var markupAmount;
                scope.$watchGroup(["netPrice", "markupAmount"], (newValues, oldValues) => {
                    netPrice= newValues[0];
                    markupAmount = newValues[1];
                    if (markupAmount >= 0) {
                        ngModelCtrl.$setValidity("markup", true);
                    } else {
                        ngModelCtrl.$setValidity("markup", netPrice === 0);
                    }
                    ngModelCtrl.$validate();
                });
            }
        };
}]).controller('ctrl', ['$scope', function($scope){
  $scope.customerPriceChangeDetected = function(){
    setNetPriceFromCustomerPrice();
    setMarkup();
    
};
function setNetPriceFromCustomerPrice() {
    var customerPrice = $scope.product.customerPrice;
    var vatRate = parseFloat($scope.product.vatRate);
    var netPrice = (customerPrice / (1 + vatRate));
    $scope.product.netPrice = netPrice;
};
function setMarkup(){
    var purchasePrice = $scope.product.purchasePrice;
    var markupAmount = $scope.product.netPrice - purchasePrice;
    $scope.product.markupAmount = markupAmount;
    $scope.product.markupPercent = markupAmount / purchasePrice;
}
}]);
 <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.1/angular.min.js"></script>
<div ng-app="test" ng-controller="ctrl">
  <form name="form">
      purchasePrice : <input type="text"  name="purchasePrice"
       ng-model="product.purchasePrice"
       ng-change="customerPriceChangeDetected()" 
        />  <br/>
   vatRate : <input type="text"  name="vatRate"
       ng-model="product.vatRate"
       ng-change="customerPriceChangeDetected()" 
        />  <br/>
    
  Customer price : <input type="text" id="customer-price" name="customerPrice"
       ng-model="product.customerPrice"
       ng-change="customerPriceChangeDetected()" 
       check-markup markup-amount="product.markupAmount"
       net-price="product.netPrice" /> <br/>
  </form>
  markupAmount : {{product.markupAmount}} <br/>
  netPrice : {{product.netPrice}} <br/>
  vatRate : {{$scope.product.vatRate}}
   customerPrice invalid : {{form.customerPrice.$invalid}}<br/>
  form invalid : {{form.$invalid}}
</div>