Knockout Extension Issue - Better Solution?

93 views Asked by At

I'm new to knockout, and still learning how best to work with it. I have a few input fields in an app which are tied to a bunch of calculations that update in real time. The fields on their own work great, and all is fine...

EXCEPT, I need to format the input as the user enters it, for display only (the raw data must be retained for the calculations, but 3 should appear as 3% or in another field 3000000 should appear as 3,000,000 etc.). I have this somewhat working, but I think there's a major flaw with my solution as the result is consistently buggy and it's possible to break the input field entirely.

So, an example of one of the input fields, which ties to another field to always equal 100%:

<input id='sm' data-bind='textInput: s_smixe' readonly='true'>

Is bound to:

self.s_smixebase = ko.observable(30);
self.s_smixe = ko.pureComputed({
    read: function(){
        return this.s_smixebase();
    },
    write: function(value){
        if (parseFloat(value)<100) {
            var otherValue = 100 - parseFloat(value);
            this.s_smixebase(value);
            this.s_rmixebase(otherValue);
        } else {
            value = 100;
            this.s_smixebase(value);
            this.s_rmixebase(0);
        }
    },
    owner: this
}).extend({percent:{}});
self.s_smixeraw = self.s_smixe.raw;

Which is then extended by:

ko.extenders.percent = function(target) {
    var raw = ko.observable();
    var result = ko.computed({
        read: function() {
            var value = target();
            if (value.toString().indexOf('%')===-1){
                raw(parseFloat(value));
                value = value + '%';
                return value;
            } else {
                value = value.replace('%','');
                raw(parseFloat(value));
                value = value + '%';
                return value;
            }       
        },
        write: target
    }).extend({notify:'always'});
    result.raw = raw;
    return result;    
};

So, what happens here, is that the first character input by the user formats correctly, the second character input by the user disappears, and the third joins the first and formats correctly. This happens the same if the field is computed or a regular observable, and the computed code is working fine without the extension applied. So to input 77% you would have to type 7 - X - 7 (where X can be any value since it gets lost to the process somewhere).

It should also be noted that I am using a virtual javascript numeric keyboard in this app so I am adding values via javascript (though this has not affected any of the other functionality, so I'm not sure why it would here).

Can anyone offer suggestions on what I'm doing wrong? What am I missing that is causing the input to be so buggy? I'm really determined not to ditch this notion of real-time input formatting as it makes for much cleaner presentation, but I if I have to I'll just format on blur.

Thanks in advance for any suggestions.

2

There are 2 answers

0
Roy J On

Because it's tricky to position the cursor properly when the formatting function replaces what you're typing as you type, I'd recommend having a field that has two modes: one where you're typing in it, and the other where it's displaying the formatted value. Which displays depends on cursor focus.

<div data-bind="with:pctInput">
<label>Value</label>
<input class="activeInput" data-bind='textInput: base, event:{blur:toggle}, visible:editing, hasFocus:editing' />
<input data-bind='textInput: formatted, event:{focus:toggle}, visible:!editing()' readonly='true' />
</div>

A working example is here: http://jsfiddle.net/q473mu4w/1/

0
Jeremy Schevling On

So, for anyone who comes across this later, I ended up using a modified version of @RoyJ 's solution from the thread mentioned in the initial comments. I do need to come up with a way to make this scale if I'm ever going to use it in larger projects, but it's sufficient for something with a small number of inputs. Also, in my case there are many formatted fields calculating their values based on the inputs, hence the multPercent and multNumber computed values. I wanted to ensure that all the inputs were carrying over properly to calculations. Here's a sample of the code with a working jsfiddle below:

<input data-bind="textInput:textPercent" />
<div data-bind="text:multPercent"></div>
<input data-bind="textInput:textNumber" />
<div data-bind="text:multNumber"></div>

and the accompanying javascript:

function dataBindings() {
var self = this;

self.percent = function(str){
    var splice = str.toString().replace('%','');
    splice = splice + '%';
    return splice;
};

self.number = function(numStr){
    var formatted;
    if (Number(numStr) % 1) {
        var integer = numStr.toString().replace(/\.\d+/g,'');
        var decimal = numStr.toString().replace(/\d+\./g,'');
        integer = integer.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); //add comma formatting
        formatted = integer + '.' + decimal;
        console.log('formatted = '+formatted);
        return formatted;
    } else {
        formatted = numStr.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,");
        return formatted;
    }       
};

self.displayPercent = ko.observable('5%');
self.rawPercent = ko.observable(5);
self.formattedPercent = ko.computed({
    read: function() {
         return self.displayPercent();   
    },
    write: function(newValue) {
        if (newValue==='') {
            newValue = 0;
            self.rawPercent(0);
            var f = self.percent(newValue);
            self.displayPercent(f);
        } else {
            if (newValue.charAt(0)==='0') {
                newValue = newValue.slice(1);
            }
            self.rawPercent(parseFloat(newValue.toString().replace('%','')));
            var f = self.percent(newValue);
            self.displayPercent(f);
        }
    }
});

self.displayNumber = ko.observable('3,000');
self.rawNumber = ko.observable(3000);
self.formattedNumber = ko.computed({
    read: function(){
        return self.displayNumber();
    },
    write: function(newValue) {
        if (newValue==='') {
            newValue = 0;
            self.rawNumber(0);
            self.displayNumber('0');
        } else {
            if (newValue.charAt(0)==='0') {
               newValue = newValue.slice(1);
            }
            newValue = newValue.replace(/(,)+/g,'');
            self.rawNumber(parseFloat(newValue));
            var n = self.number(newValue);
            self.displayNumber(n);
        }
    }
});

self.multPercent = ko.computed(function(){
    return self.percent(self.rawPercent() * self.rawPercent());
});

self.multNumber = ko.computed(function(){
    return self.number(self.rawNumber() * self.rawNumber());
});    

return {
    textPercent: self.formattedPercent,
    multPercent: self.multPercent,
    textNumber: self.formattedNumber,
    multNumber: self.multNumber
};
}

ko.applyBindings(new dataBindings());

http://jsfiddle.net/jschevling/mwbzp55t/