knockout write binding without observable

Viewed 51

In knockout, say we have something like this:

var person = {
  name: "The H. Dude",
  telecom: [
    "mailto:dude@host.com",
    "tel:+1-987-654-3210"
  ]
}

and I have a data binding elements like this:

<label>Name: <input type="text" data-bind="value: name"/></label>
<label>Phone:<input type="text" data-bind="value: telecom.find(url => url.startsWith('tel:'))"/></label>
<label>Email:<input type="text" data-bind="value: telecom.find(url => url.startsWith('mailto:'))"/></label>

This works alright. However, this would hit the user over the head with the URL scheme prefix.

So, what we would like to do is something like this:

data-bind="value: telecom.find(url => url.startsWith('tel:')).substring('tel:'.length)"

and

data-bind="value: telecom.find(url => url.startsWith('mailto:')).substing('mailto:'.length)"

respectively.

And that works just fine for a read-only property, which I might just display. But when I type a new phone number in, the expression ends in a function call, which can't be written to, and of course substring function doesn't know how to work backwards to prepend the "tel:" or "mailto:" prefix before the user-entered value gets written to the object.

I have worked deep down in an XForms engine and before that I had made my own UI framework with a path language similar to XPath, used precisely for two-way data-binding. There I had invented the notion of a "conversion" which was a pair of functions, in this case you might say:

telPrefixConversion = {
  prefix: "tel:",
  forward:  function(value) { return value.substring(this.prefix.length); }
  backward: function(value) { return prefix + value; }
}

And I'm thinking this would be super-useful in knockout too. Then on the binding I could just say

data-bind="{value: telecom.find(url => url.startsWith('mailto:')), conversion: telPrefixConversion}"

and now knockout-3.5.0.debug.js line 2842 could do this:

            if (twoWayBindings[key] && (writableVal = getWriteableValue(val))) {
                // For two-way bindings, provide a write method in case the value
                // isn't a writable observable.
                var writeKey = typeof twoWayBindings[key] == 'string' ? twoWayBindings[key] : key;
                propertyAccessorResultStrings.push("'" + writeKey + "':function(_z){" + writableVal + "=_z}");
            }

that last line could change to

                propertyAccessorResultStrings.push("'" + writeKey + "':function(_z){" + conversion.backward(writableVal) + "=_z}");

Now I can already think of a way to do that by using a computed observable instead, but it seems heavy weight for something like that. This conversion principle is very powerful as it can also convert complex objects into UI string representations and on the way back it goes into the complex object again.

I am so tempted to implement that in knockout too, since I have done it twice already on other data binding UI tools. Should I go ahead? Or am I missing a simple feature like this?

ATTEMPT 1 - Computed Observable: I have since used computed observables to solve my immediate need, but I found out that this would work when you have more than one telephone number in some kind of repeated groups. For example, let's say you have a list of friends with name, email, and phone number adding computed scratchpad properties just to convert a value to and from string representation is not good.

The fist answer here also suggests computed observable, but both my initial attempt and what is suggested in that answer is too special. We want to have the ability to do it anywhere, regardless if it is a property of one or the other object no matter whether they are also repeated in an array.

So I am thinking of something like this:

class Conversion {
    constructor(object, property) {
        let self = this;
        object[property] = ko.computed({
            read:  function() { 
                return self.m2v(ko.util.unwrapObservable(this[property])); 
            },
            write: function(value) { 
                this[property](m2v(value)); // <<<< infinite recursion????
            },
            owner: object 
        });
    }
    model2view(modelValue) { throw new Error("undefined abstract function called"); }
    view2model(viewValue)  { throw new Error("undefined abstract function called"); }
}

These model2view and view2model functions can then be overridden to deal with my prefixes, or with date formats as in this other question, etc.

The problem I am stuck on is this:

  1. we replaced the actual value with the observable
  2. when assigning the view value to the observable we would be entering this cycle

We would still need some new property where we store the value for the view separate from the property that holds the actual model value. And that's what I'm trying to avoid by supplying this function pair instead to some other binding option somehow.

ATTEMPT 2 - Extender I found the observable extenders might almost do the trick:

ko.extenders.prefixConversion = function(target, prefix) {
  const read = function() { return target().substring(prefix.length); };
  const result = ko.pureComputed({
    read:  read,
    write: function(value) { target(prefix + value); }
  });
  result(read());
  return result;
}

this is used together with the following initializer, and also with these telecom things being objects with url elements, not just the urls as plain strings, example:

o = { ... telecom: [ { url: "tel:+1-987-654-3210" }, { url: "mailto:dude@host.com" }] ... }

then turning this into observables:

telecom
  .filter(t => t.url.match('^(tel|mailto):'))
  .forEach(t => {
     const prefix = t.url.substring(0, value.indexOf(':')+1);
     t.url = ko.observable(t.url).extend({prefixConversion: prefix});
   });

nice and general works for different prefixes. And here is how we ultimately bind them:

<label>Email:<input data-bind="value: telecom.find(t => t.url.startsWith('mailto:')).url"/></label>
<label>Phone:<input data-bind="value: telecom.find(t => t.url().startsWith('tel:')).url"/></label>

Now this causes the telecom values to be wiped out, because it stores the values back with removed prefixes. It almost works, I can see how the read and write functions produce the correct value, write adds the prefix, and read removes it, but then somewhere, somehow, that value without the prefix gets written into the observable._state._latestValue and then we lose a hold of this altogether.

I don't know if this is a bug somewhere. I don't know even how that prefix-less value ever got written into the observable state.

But in the end, I find even this is too costly an approach anyway. I think the issue applies strictly to binding modes view and text and perhaps textInput, i.e., wherever the ultimate representation is a string. It only applies to the UI surface presentations, it is not about the represented object and its properties. No code should ever get the tel: and mailto: prefix removed, this is only for the user, therefore, this conversion could be bound to the binding handler.

MY OWN SOLUTION So I am resolving this the same way that I did it with the XForms framework once. I added v2t and t2v functions: v2t means value to text, and t2v means text to value. For example:

<input ... 
       data-bind="..."
       data-t2v="return t.indexOf('tel:') == 0 ? t : 'tel:' + t;" 
       data-v2t="var p = v.substring(0,4); return p == 'tel:' ? v.substring(4) : v;"
       .../>

These attributes get converted to functions during first initialization (or lazily when needed):

if(!(element.t2v === null || element.t2v)) {
  const att = element.getAttribute("t2v");
  element.t2v = att ? new Function("t", att) : null;
}

if(!(element.v2t === null || element.v2t)) {
  const att = element.getAttribute("v2t");
  element.v2t = att ? new Function("v", att) : null;
}

then in a few select places, I check and use the element.t2v and .v2t properties respectively before reading and writing respectively. Example:

element.value = element.v2t ? element.v2t(element, value) : value;

This is a solution I have been using for a decade and I think it is right. It is not used when the UI text interactions are more involved. Anything that needs keystroke-event granularity or is in other ways complex, such as requiring inputs from other objects would be handled differently.

I have this implemented and working now. But it is a change to knockout and the question is not moot, because perhaps the knockout people might have a different solution for that.

For example, I note that there is also ko.utils.domData, which I don't understand yet, but which could potentially provide an avenue for this. But my solution is tested in a different data binding framework for a long time and now implemented in knockout, and I think it is the right solution as, again, the test to value and value to text conversions are about the widget and how it renders model data values, not about the data.

2 Answers

You could achieve this without having to modify Knockout, using the built-in writable computed functionality.

Suppose you had a list of people, like so:

vm.person = [
    {
        name: ko.observable("The H. Dude"),
        telecom: [
            "mailto:dude@host.com",
            "tel:+1-987-654-3210"
        ]
    },
    {
        name: ko.observable("The I. Gal"),
        telecom: [
            "mailto:gal@host.com",
            "tel:+1-987-654-3211"
        ]
    }
];

Then you could define on your viewmodel a constructor function that returns a writable computed:

vm.getTelecom = function(personIndex, telIndex, prefix) {
    return ko.pureComputed({
        read() {
            return vm.person[personIndex].telecom[telIndex].substring(prefix.length);
        },
        write(newVal) {
            vm.person[personIndex].telecom[telIndex] = prefix + newVal;
        }
    });
}

And bind that to your inputs:

<!-- ko foreach: person -->
<div>
<label>Name: <input type="text" data-bind="textInput: name"/></label>
<label>Phone:<input type="tel" data-bind="textInput: $parent.getTelecom($index(), 1, 'tel:')"/></label>
<label>Email:<input type="email" data-bind="textInput:$parent.getTelecom($index(), 0, 'mailto:')"/></label>
</div>
<!-- /ko -->

Working demo: https://jsfiddle.net/thebluenile/ujb50vwn/

It feels to me like you're trying to do too much in the view, where knockout would prefer you to add more logic to your viewmodel.

Here's an example of a simple Person View Model that does two things:

  • Handle the model's (not ideal) format for email and phone explicitly both on the way in and out
  • Separate the binding values from the model values. The model values have the prefixes, the binding values hide that from the user.

ko.extenders.prefixConversion = function(target, prefix) {
  const newTarget = ko.pureComputed({
    read: () => target().substring(prefix.length),
    write: value => target(prefix + value)
  });
  
  return newTarget;
}

const Prefix = {
  mail: "mailto:",
  phone: "tel:"
};

const PersonVM = person => {
  const name = ko.observable(person.name);
  const email = ko.observable(
    person.telecom.find(str => str.startsWith(Prefix.mail))
  );
  const phone = ko.observable(
    person.telecom.find(str => str.startsWith(Prefix.phone))
  );

  const toJS = ko.pureComputed(() => ({
    name: name(),
    telecom: [
      email(),
      phone()
    ]
  }));

  return { 
    name,
    email: email.extend({ prefixConversion: Prefix.mail }),
    phone: phone.extend({ prefixConversion: Prefix.phone }),
    toJS
  };
}


const personVM = PersonVM({
  name: "The H. Dude",
  telecom: [
    "mailto:dude@host.com",
    "tel:+1-987-654-3210"
  ]
});

ko.applyBindings(personVM);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
<input data-bind="textInput: name" type="text">
<input data-bind="textInput: email" type="text">
<input data-bind="textInput: phone" type="text">


<pre data-bind="text: JSON.stringify(toJS(), null, 2)"></pre>

Related