1

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.

  • I made a [simple fiddle with your code](http://jsfiddle.net/Retsam19/5q5010ve/)... it seems to function properly, though I'm not doing the virtual keyboard thing. So unless I'm misrepresenting what you're trying to do, in that fiddle, it looks like the issue might be in the keyboard? – Retsam May 27 '15 at 15:44
  • @Retsam That's almost the same, only the input fields in this app are also the display fields (so s_smixebase is not displayed, and s_smixe is bound to the active input field), so I'm doing this formatting to the input as it is being entered in the field. But that's extremely helpful to know it works in isolation! Thanks for that! I'll play around and see if the keyboard is the issue. – Jeremy Schevling May 27 '15 at 15:56
  • This should not be done in an extender, it should be a binding handler. Extenders should be used when you want to extend the capability of an observable. Binding handlers should be used when you want to change the way you interact with them. – Jeff Mercado May 27 '15 at 15:57
  • @JeffMercado Ah-ha! Ok. A question on that, then. Since I need the features of textInput (the immediate updates, etc.) how best do I augment that/what is best practice? – Jeremy Schevling May 27 '15 at 16:05
  • What you want to do is very much like what is discussed in this question: http://stackoverflow.com/questions/30406535/knockout-textinput-and-maskedinput-plugin/30406735 Note the difficulty that cursor position can present when you're reformatting what you are typing in. – Roy J May 27 '15 at 16:45
  • @RoyJ Actually, since the app only allows numeric input, and the keyboard is a custom keyboard I created for the app, the cursor position is always at the end (think calculator display) so the solution in that thread might just do the trick. The user clears the field and starts over if they want to change something. Also, your solution below will be helpful in other scenarios, so thank you for both! I appreciate the input. – Jeremy Schevling May 27 '15 at 18:52
  • Also, if anyone can explain what's happening within Knockout that causes my original code to work the way it does (with one round of input being absorbed by some process somewhere) it'd be very helpful. I haven't had much of a chance to review the internals of Knockout yet. – Jeremy Schevling May 28 '15 at 15:43

2 Answers2

0

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/

Roy J
  • 42,522
  • 10
  • 78
  • 102
0

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/