0

I have an observable that I bind to a input type="text" HTML element. The observable holds floating point numbers. I want the value in the textbox to be displayed to 2 decimal places. So 1 would be 1.00, 1.2 would be 1.20 and so on. I created a custom binding that I think works for outputting the formatted value but doesn't capture the user input:

ko.bindingHandlers.numericValue = {
    init: function (element, valueAccessor, allBindingsAccessor, viewModel) {
    },
    update: function (element, valueAccessor, allBindingsAccessor) {
        var value = ko.utils.unwrapObservable(valueAccessor()),
            precision = ko.utils.unwrapObservable(allBindingsAccessor().precision) || ko.bindingHandlers.numericValue.defaultPrecision;

        var formattedValue = '';
        if (value) {
            if (typeof value === "string") {
                value = parseFloat(value);
            }
            formattedValue = value.toFixed(precision);
        } else {
            value = 0;
            formattedValue = value.toFixed(precision);
        }
        $(element).val(formattedValue);
    },
    defaultPrecision: 1
};

Binding:

<input type="text" maxlength="6" class="bb width-100p" data-bind="numericValue: marketRate, precision: 2" />

Observable on model:

    self.marketRate = ko.observable(formatNumber(dc.marketRate, 2)).extend({
        required: { message: 'Required', onlyIf: function() { return self.isSelected(); }},
        min: { params: 0, onlyIf: function () { return self.isSelected(); } },
        max: { params: 999999.99, onlyIf: function() { return self.isSelected(); } },
        pattern: { message: 'Maximum 2 decimal places', params: /^\d*(\.\d{1,2})?$/, onlyIf: function () { return self.isSelected(); } }
    });

function formatNumber(value, places) {
        value = ko.utils.unwrapObservable(value);
        if (value) {
            if (typeof value === "string") {
                value = parseFloat(value);
            }
            return value.toFixed(places);
        }
        value = 0;
        return value.toFixed(places);
    }

Do I need something to update the observable with the value that the user has entered? This code never updates the observable. My guess is that I need to call into the Knockout value binding code.

DaveB
  • 9,470
  • 4
  • 39
  • 66

2 Answers2

1

As I understand it there are a couple of options. You can set up an event binding in your init function that will directly handle updating the observable when the value changes. That looks something like this

init: function (element, valueAccessor, allBindings) {
    var valueObservable = valueAccessor();

    ko.utils.registerEventHandler(element, "change", function () {
      valueObservable($(element).val());
    });
}

The other option is to piggyback on the existing value binding by turning your format function into a writable computed, and then using the following in an init binding to assign the "value" binding to that computed. You don't need an update function in this case.

ko.applyBindingAccessorsToNode(element, { value: formattingComputed }, bindingContext);

In your particular example that might look like this:

ko.bindingHandlers.numericValue = {
  init: function (element, valueAccessor, allBindingsAccessor, data, bindingContext) {
    var formattingComputed = ko.computed({
        read: function(){
          var value = ko.utils.unwrapObservable(valueAccessor()),
              precision = ko.utils.unwrapObservable(allBindingsAccessor().precision) || ko.bindingHandlers.numericValue.defaultPrecision;

          var formattedValue = '';
          if (value) {
              if (typeof value === "string") {
                  value = parseFloat(value);
              }
              formattedValue = value.toFixed(precision);
          } else {
              value = 0;
              formattedValue = value.toFixed(precision);
          }
          formattedValue = value.toFixed(precision);
          return formattedValue;
        },
        write: function(value){
            ko.expressionRewriting.writeValueToProperty(valueAccessor(), allBindingsAccessor, 'value', value);
        }
    });
    ko.applyBindingAccessorsToNode(element, { value: function(){return formattingComputed;} }, bindingContext);
  }
}

jsFiddle

Jason Spake
  • 4,293
  • 2
  • 14
  • 22
  • Both of these solutions work partially for me. They don't respect the Knockout Validation that I have on the observable, is there any way to have the formatting and the validation? The second snipped doesn't update the underlying observable for me. – DaveB Feb 06 '17 at 19:56
  • In the last line try wrapping the computed in a function. ko.applyBindingAccessorsToNode(element, { value: function(){return formattingComputed;} }, bindingContext); I've updated my answer to match. – Jason Spake Feb 06 '17 at 20:20
  • What does your validation setup look like? Is that using the knockout-validation plugin? – Jason Spake Feb 06 '17 at 20:22
  • If so there's a method in ko.validation to link to your custom binding. ko.validation.makeBindingHandlerValidatable('yourBindingName'); – Jason Spake Feb 06 '17 at 20:58
  • Thanks. I've updated my question with the observable and validation on it. – DaveB Feb 06 '17 at 21:09
  • Enter 1.5999 on your jsFiddle and it gets formatted to 1.60 with the message of maximum 2 decimal places when it is 2 decimal places. I think that's going to be confusing to as user. – DaveB Feb 06 '17 at 22:42
  • Well how do you want it to work? That's more a question of your application's overall design than just getting a binding to function. Knockout-validation requires the observable to be updated in order to trigger its computed function to update the validations so if you want to prevent the update from firing you'll have to use an intermediary observable. One to hold the user-entered value for validation, and one to hold the real value that only updates when the user entered value passes validation. – Jason Spake Feb 06 '17 at 23:22
  • Add an invalid value, the validation message displays without changing the entered value. If the value entered is valid, no change is needed. When writing to the backing observable, the value will be formatted to 2 decimal places. The point of all this is to present values like 0 as 0.00, 1.6 as 1.60 and the validation will handle the constraint of the entry, 0, 1.6 are both valid but don't really get presented with 2 decimal places until the data is re-bound to the view. Thanks for all your efforts, your code has been very helpful. – DaveB Feb 07 '17 at 20:20
  • Here's an example of what I meant by intermediary observable. With some tweaking you could probably build it into the binding itself instead of having to define two upfront. You'd have to move your validation extension parameters into the binding as well though. https://jsfiddle.net/jlspake/yt1m7ebe/ – Jason Spake Feb 07 '17 at 22:07
1

Per the official docs, the "update" callback is called once when the binding is first applied to an element and again whenever any observables/computeds that are accessed change.

Also, you don’t actually have to provide both init and update callbacks — you can just provide one or the other if that’s all you need.

In the snippet below I've added a "value" binding to "marketRate" VM observable so whenever this observable is changed the "update" callback is triggered.

ko.bindingHandlers.numericValue = {
    update: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
        var allBindings = ko.utils.unwrapObservable(allBindings()); 

        if (allBindings.value()) {
            // use informed precision or default value
            var precision = allBindings.numericValue.precision || 1;                    

            // prevent rounding
            var regex = new RegExp('^-?\\d+(?:\.\\d{0,' + precision + '})?');   

            // update observable
            allBindings.value(parseFloat(allBindings.value().match(regex)[0]).toFixed(precision));                     
        }
    }
};

function MyViewModel() {
    // applying some validation rules
    this.marketRate = ko.observable().extend({
        required: true,
        min: 5
     });
}

var vm = new MyViewModel();
ko.applyBindings(vm);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout-validation/2.0.3/knockout.validation.min.js" type="text/javascript"></script>

<input type="text" data-bind="value: marketRate, numericValue: { precision: 2 }" />

Note the "allBindings" argument which gives us access to the other bindings applied to the element (e.g. value) along with the parameters from the custom binding itself, in this case just the property "precision".

For using the custom binding with its default precision you could use numericValue: { } or numericValue: true

Rafael Companhoni
  • 1,780
  • 1
  • 15
  • 31
  • Nice idea and works pretty good. I have a regex Knockout validation rule on the observable that limits decimal places to 2. With this solution, if 1.599 is entered, instead of the validation rule getting triggered, the value is rounded to 1.60. I would like it if the validation rules worked with the formatting. Possible? – DaveB Feb 06 '17 at 20:12
  • Yes, changing your observable value within the custom binding should trigger the validation rules -- just added some simple validation rules to demonstrate that – Rafael Companhoni Feb 06 '17 at 21:27
  • Enter 1.5999 on your example and it gets formatted to 1.60 with the message of maximum 2 decimal places when it is 2 decimal places. I think that's going to be confusing to as user. I think if the value entered is not valid, if should not get formatted. Seems like there should be a isValid check? – DaveB Feb 06 '17 at 22:45
  • Within the custom binding you can add any rules you wish -- just added one to prevent rounding (from http://stackoverflow.com/a/11818658/1942895) – Rafael Companhoni Feb 06 '17 at 23:10