Sign up ×
Stack Overflow is a community of 4.7 million programmers, just like you, helping each other. Join them; it only takes a minute:

I want to create a directive that will behave as follows... On the HTML side:

<input data-ng-model="modelLogistics.inputValue1" 
       data-currency="{decSep:','   ,    thSep:'.'}">

Angular-wise, on the user side of code we would have something like:

controllerLogistics(...) {
    $scope.modelLogistics = {};
    $scope.modelLogistics.inputValue1 = 1234.23;
    ...
}

Now for the tough part: I want the input control to behave in two ways, depending on whether it has the focus or not:

  • If the control has the focus, then it should display the number using only the decimal separator (decSep) and ignoring the thousand separator (thSep) - so the 1234.23 would appear in the input text that the user edits as "1234,23" (because decSep is set to ',' in the HTML directive).
  • If the control loses the focus, then it should display the number using both the decimal separator (decSep) and the thousand separator (thSep) - so the 1234.23 would appear in the input text that the user sees as "1.234,23" (thSep is set to '.' in the HTML directive).

My code so far is this:

function currency() {
    return {
        require: '?ngModel',
        link: function(scope:ng.IScope, element, attrs, ngModel) {
            if(!ngModel) return; // do nothing if no ng-model

            var options = scope.$eval(attrs.currency);
            if (options === undefined)          options = {};
            if (options.decSep === undefined)   options.decSep = ',';
            if (options.thSep === undefined)    options.thSep = '.';

            element.blur(function(e) {
                var parts = (ngModel.$viewValue || '').split(options.decSep);
                parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, options.thSep);
                element.val( parts.join(options.decSep));
            });

            ngModel.$render = () => {
                element.val(ngModel.$viewValue || '');
            }
       }
  }

...and it works - provided that (a) my model is a string, not a number, and (b) I initialize the model with a "valid" number as per the directive specs in the HTML - that is, using model values like "1234,23" and not the number 1234.23

I am having difficulty figuring out how to change the implementation to have an underlying number (not a string) and automatically using the two modes (edit/view). I have seen the angular filters (i.e. the '|' syntax in things like '{{model.value | something}}' but I am not sure whether it fits with what I am trying to do...

Any help most appreciated.

EDIT

I have seen other currency solutions that use $formatters and $parsers - but in my case, I can't use this pattern, because the $viewValue depends not just on the $modelValue, but also on whether the control has the focus or not. That is, if I just add a formatter that checks whether the element is in focus or not, that will work the first time only - when the user clicks on some other component and the focus is lost, the model hasn't changed - yet the view needs to be updated.

share|improve this question

1 Answer 1

up vote 0 down vote accepted

After a day's worth of work... I have it.

As I noted in the EDIT above, $formatters and $parsers simply don't work here, because the view state depends not just on the model state, but also on whether the component has the focus or not.

I maintain the state myself, assigning explicitly to ngModel.$modelValue, ngModel.$viewValue and element.val().

Here's the fullcode, in case it helps some poor soul out there - it zaps invalid input back to the latest valid one, and pops up a Bootstrap popover if the value is invalid:

function currency($timeout) {
    return {
        // We will change the model via this directive
        require: '?ngModel',

        link: function(scope:ng.IScope, element, attrs, ngModel) {
            if(!ngModel) return; // do nothing if no ng-model

            // Read the options passed in the directive
            var options = scope.$eval(attrs.currency);
            if (options === undefined)          options = {};
            if (options.min === undefined)      options.min = Number.NEGATIVE_INFINITY;
            if (options.max === undefined)      options.max = Number.POSITIVE_INFINITY;
            if (options.decimals === undefined) options.decimals = 0;
            if (options.decSep === undefined)   options.decSep = ',';
            if (options.thSep === undefined)    options.thSep = '.';

            // cache the validation regexp inside our options object (don't compile it all the time)
            var regex = "^[0-9]*(" + options.decSep + "([0-9]{0," + options.decimals + "}))?$";
            options.compiledRegEx = new RegExp(regex);

            // Use a Bootstrap popover to notify the user of erroneous data
            function showError(msg:string) {
                if (options.promise !== undefined) {
                    // An error popover is already there - cancel the timer, destroy the popover
                    $timeout.cancel(options.promise);
                    element.popover('destroy');
                }
                // Show the error
                element.popover({
                    animation:true, html:false, placement:'right', trigger:'manual', content:msg
                }).popover('show');
                // Schedule a popover destroy after 3000ms
                options.promise = $timeout(function() { element.popover('destroy'); }, 3000);
            }

            // Converters to and from between the model (number) and the two state strings (edit/view)

            function numberToEditText(n:number):string {
                if (!n) return ''; // the model may be undefined by the user
                return n.toString().split(localeDecSep).join(options.decSep);
            }

            function numberToViewText(n:number):string {
                if (!n) return ''; // the model may be undefined by the user
                var parts = n.toString().split(localeDecSep);
                // Using SO magic:  http://stackoverflow.com/questions/17294959/how-does-b-d3-d-g-work-for-adding-comma-on-numbers
                parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, options.thSep);
                return parts.join(options.decSep);
            }

            function editTextToNumber(t:string):number {
                return parseFloat(t.replace(options.thSep, '').replace(options.decSep, localeDecSep));
            }

            function viewTextToNumber(t:string):number {
                return parseFloat(t.replace(options.decSep, localeDecSep));
            }

            // For debugging
            //function log() {
            //    console.log('oldModelValue:' + options.oldModelValue);
            //    console.log('modelValue:' + ngModel.$modelValue);
            //    console.log('viewValue:' + ngModel.$viewValue);
            //}

            // On keyup, the element.val() has the input's new value - 
            // which may be invalid, violating our restrictions:
            element.keyup(function(e) {
                var newValue:string = element.val();
                if (!options.compiledRegEx.test(newValue)) {
                    // it fails the regex, it's not valid
                    //console.log('This is invalid due to regex: ' + newValue);
                    $timeout(function() {
                        // schedule a call to render, to reset element.val to the last known good value
                        ngModel.$render(true);
                    }, 0);
                    // Show a bootstrap popever error window, which will autohide after 3 seconds
                    showError(' Μόνο ' + options.decimals + ' δεκαδικά και μία υποδιαστολή (' + options.decSep +')');
                    return;
                }
                var newValueNumber:number = scope.$eval(newValue.replace(options.decSep, localeDecSep));
                if (newValueNumber>options.max || newValueNumber<options.min) {
                    // it fails the range check
                    //console.log('This is invalid due to range: ' + newValue);
                    $timeout(function() {
                        // schedule a call to render, to reset element.val to the last known good value
                        ngModel.$render(true);
                    }, 0);
                    // Show a bootstrap popever error window, which will autohide after 3 seconds
                    showError(' Από ' + options.min + ' έως ' + options.max);
                    return;
                }
                // The input may be empty - set the model to undefined then
                // ('unset' is a valid result for our model - think of SQL 'NULL')
                if (newValue === '') {
                    ngModel.$modelValue = undefined;
                    options.oldModelValue = undefined;
                } else {
                    // The new input value is solid - update the $modelValue
                    ngModel.$modelValue = editTextToNumber(newValue);
                    // ...and keep this as the last known good value
                    options.oldModelValue = ngModel.$modelValue;
                    //console.log("oldModelValue set to " + options.oldModelValue);
                }

                // If we reached here and a popover is still up, waiting to be killed,
                // then kill the timer and destroy the popover
                if (options.promise !== undefined) {
                    $timeout.cancel(options.promise);
                    element.popover('destroy');
                }
            });

            // schedule a call to render, to reset element.val to the last known good value
            element.focus(function(e) { ngModel.$render(true); });

            element.blur(function(e) { ngModel.$render(false); });

            // when the model changes, Angular will call this:
            ngModel.$render = (inFocus) => {
                // how to obtain the first content for the oldModelValue that we will revert to
                // when erroneous inputs are given in keyup() ?
                // simple: just copy it here, and update in keyup if the value is valid.
                options.oldModelValue = ngModel.$modelValue;
                //console.log("oldModelValue set to " + options.oldModelValue);
                if (!ngModel.$modelValue) {
                    element.val('');
                } else {
                    // Set the $viewValue to a proper representation, based on whether
                    // we are in edit or view mode.
                    // Initially I was calling element.is(":focus") here, but this was not working
                    // properly - so I hack a bit: I know $render will be called by Angular
                    // with no parameters (so inFocus will be undefined, which evaluates to false)
                    // and I only call it myself with true from within 'element.focus' above.
                    var m2v = inFocus?numberToEditText:numberToViewText;
                    var viewValue = m2v(ngModel.$modelValue);
                    ngModel.$viewValue = viewValue;
                    // And set the content of the DOM element to the proper representation.
                    element.val(viewValue);
                }
            }

            // we need the model of the input to update from the changes done by the user,
            // but only if it is valid - otherwise, we want to use the oldModelValue
            // (the last known good value).
            ngModel.$parsers.push(function(newValue) {
                if (newValue === '')
                    return undefined;
                if (!options.compiledRegEx.test(newValue))
                    return options.oldModelValue;
                var newValueNumber:number = scope.$eval(newValue.replace(options.decSep, localeDecSep));
                if (newValueNumber>options.max || newValueNumber<options.min)
                    return options.oldModelValue;
                // The input was solid, update the model.
                return viewTextToNumber(newValue);
            });
        }
    };
}
share|improve this answer

Your Answer

 
discard

By posting your answer, you agree to the privacy policy and terms of service.

Not the answer you're looking for? Browse other questions tagged or ask your own question.