NML

Three Useful Knockout Extenders

By

I had to write some KnockoutJS code recently. The requirement was simple:

Extend an observable so that the observable:

  • can suppress alpha characters in a numeric input textbox;
  • formats percentages as “12.13%” when displayed, but stores the actual value as a number (not a string);
  • formats currency as “R123 456.00″, with spaces for thousand separators.

Credit must be given to Bryan Posas and Zeal for parts of this code that was sourced and subsequently mangled from here and here.

Disclaimer: there might be a simpler way to do this. (If there is, please let me know!)

First, I will explain how to use the percentage extender. It will be clear then how to use the others too. First, extend your observable property with the new extender as follows:

  self.percent = ko.observable(50).extend({ addPercentageFormatted:2});

The percentage observable will now store its default value (50) as a plain-old number, and the extender will add a ‘.formatted’ property on top of the normal observable.

Bind to the formatted property on the front-end. This is to always show the formatted value, and for invalid characters to be stripped out automatically:

<input type="text" data-bind="value: percent.formatted"/>

It’s as simple as that. Here is the percentage extender:

//Add formatted observable to the target observable.
ko.extenders.addPercentageFormatted = function(target, decimals) {
    target.formatted = ko.computed({
        read: function() {
            var val = target();
            return val.toFixed(decimals) + '%';
        },
        write: function(newValue) {
            var current = target();
            var valueToWrite = formatToNumber(newValue, decimals);
            if (valueToWrite !== current) {
                target(valueToWrite);
            } else {
                if (newValue !== current) {
                    target.notifySubscribers(valueToWrite);
                }
            }
        }
    });
    return target;
};

function formatToNumber(str, decimals) {
    var roundingMultiplier = Math.pow(10, decimals); 
    //Strip out everything except numbers and decimals (also strip out '%' and 'R')
    var newValueAsNum = newString(str).replace(/[^0-9.]/g, '');
    if (isNaN(newValueAsNum)) { 
        //can happen with two decimals.
        newValueAsNum = 0;
    }

    var valueToWrite = Math.round(newValueAsNum * roundingMultiplier) / roundingMultiplier;
    return valueToWrite;
}

You would use the currency extender in a similar way:

self.amount = ko.observable(50).extend({ addCurrencyFormatted:2});
<input type="text" data-bind="value: amount.formatted"/>

Here is the currency extender:

//Add formatted observable to the target observable.
ko.extenders.addCurrencyFormatted = function(target, decimals) {
    target.formatted = ko.computed({
        read: function() {
            var val = target(); //Insert 1000 space.var formattedValue =('R '+ val).replace(/B(?=(d{3})+(?!d))/g," ");return formattedValue;},
            write: function(newValue) {
                var current = target();
                var valueToWrite = formatToNumber(newValue, decimals); //only write if it changedif(valueToWrite !== current){
                target(valueToWrite);
            } else {
                if (newValue !== current) {
                    target.notifySubscribers(valueToWrite);
                }
            }
        }
    });
    return target;
};

Finally, the ensureNumeric extender just ensures that the non-numeric input is stripped from the user input. This extender does not add a ‘formatted’ property; it simply cleans the value in-place:

self.plainNumber = ko.observable(12).extend({ ensureNumeric:2});
<input type="text" data-bind="value: plainNumber"/>

The ensureNumeric extender looks as follows:

//Intercept to ensure only numbers and '.' are entered.
ko.extenders.ensureNumeric = function(target, precision) {
    //create a writeable computed observable to intercept writes to our observable
    var result = ko.computed({
        read: target,
        //always return the original observables value
        write: function(newValue) {
            var current = target();
            var valueToWrite = formatToNumber(newValue, precision);
            //only write if it changed
            if (valueToWrite !== current) {
                target(valueToWrite);
            } else {
                if (newValue !== current) {
                    target.notifySubscribers(valueToWrite);
                }
            }
        }
    }).extend({
        notify: 'always'
    }); //initialize with current value to make sure it is rounded appropriately
    result(target());
    //return the new computed observable
    return result;
};

These extenders make our viewModels a lot smaller and easier to understand. Normal knockoutjs validation using the validation plugin will continue to work, and will only kick in after the ‘cleansed’ value has been written by the extender. So you can still do additional required-field or min/max amount validation if required.

An alternative approach – if you prefer not to use an extender – is to extend knockout itself so that you can use a function as follows:

self.totalAmountToInvest = ko.observable(0).addFormattedCurrency(2);

The addFormattedCurrency function is defined by adding to knockout’s “ko.subscribable.fn” object. When you refer to “this” within the function, you are actually referring to the observable property:

ko.subscribable.fn.addFormattedCurrency = function(decimals) {
    var target = this;
    target.formatted = ko.computed({
        read: function() {
            var val = target();
            val = formatToNumber(val, decimals);
            var formattedValue = ('R ' + val).replace(/B(?=(d{3})+(?!d))/g, " ");
            return formattedValue;
        },
        write: function(newValue) {
            var current = target();
            var valueToWrite = formatToNumber(newValue, decimals);
            //only write if it has changed
            if (valueToWrite !== current) {
                target(valueToWrite);
            } else {
                if (newValue !== current) {
                    target.notifySubscribers(valueToWrite);
                }
            }
        }
    });
    return this;
}

And that’s that!

Over and (knock)out.