apprt-binding

The apprt-binding bundle provides an API that lets you glue two models together in a declarative way. The binding synchronizes the declared model properties in either one or both directions.

As an example, consider programming a bundle containing UI elements. We recommend to separate the user interface from the underlying business logic by implementing the MVVM pattern using the apprt-vue bundle. The binding keeps a ViewModel and Model in sync without you having to write much glue code yourself.

This documentation provides an overview on how to use the binding and it explains some extended use cases.

See also the bundle's API documentation for more details.

Requirements

In order to support synchronization, apprt-binding/Binding requires objects that implement the appropriate methods of the apprt-binding/Bindable interface. The interface is described in detail in the API documentation for Bindable. To put it briefly, the "source" side of a synchronization must provide the get and watch methods to support reactivity, and the "target" side must provide the set method. If two-way synchronization between two objects is desired, both objects must implement the full interface.

All instances of apprt-core/Mutable or esri/core/Accessor (for example, layers or views) already implement this interface and can be used without any additional work. An example for a custom model that can serve as the source side of a synchronization is available here.

Usage

First, create an apprt-binding/Binding passing two apprt-binding/Bindable instances as left and right side of the binding.

import Binding from "apprt-binding/Binding";

let binding = Binding.for(mutableModelInstance, accessorModelInstance);

To synchronize one or more properties that have the same name in both models use the syncAll() method. Always call the enable() method to make the binding start working:

binding.syncAll("xCoord", "yCoord" /* , ... */);
binding.enable(); // This is omitted in all following code samples!

To sync properties that have different names in each model, use syncAll():

binding.syncAll({
    mapCenter: "center",
    mapTilt: "tilt" /*,
    ... */
});

Alternatively use the sync() method in the following way:

binding.sync("mapCenter", "center");
binding.sync("mapTilt", "tilt");

To update properties from one model to another (in only one direction), use syncToRight() and syncToLeft(). left and right refer to the order of the parameters of your binding declaration.

// the models properties: leftModel.mapCenter, rightModel.center
let binding = Binding.for(leftModel, rightModel);
// binding.sync("mapCenter", "center") is equivalent to:
binding.syncToRight("mapCenter", "center");
binding.syncToLeft("center", "mapCenter");

To sync multiple properties in only one direction, use syncAllToLeft and syncAllToRight that have the same arguments as syncAll.

To keep the connection between models intact, but stop synchronizing values, use disable() on a binding:

binding.disable();

To release the connections between two models, use unbind():

binding.unbind();

Extended Use Cases

Synchronize values initially

To ensure that model properties are directly visible within a widget, force the initial synchronization of properties by using the methods syncToRightNow and syncToLeftNow. Decide which of the sides (left or right) is the leading source and only use one of the syncTo*Now methods.

// initially synchronize model properties to the widget
binding.bindTo(model, widget).syncToRightNow();

// initially synchronize widget properties with a model
binding.bindTo(model, widget).syncToLeftNow();

Converting values before they are set

To convert properties during synchronization to control what is set to the other model, add converter functions to the sync() method:

binding.sync(
    "mapCenter",
    "center",
    (mapCenterValue, context) => {
        // return transformed mapCenter value;
        // this is equivalent to syncToRight()
    },
    (centerValue, context) => {
        // return transformed center value;
        // this is equivalent to syncToLeft()
    }
);

The one way versions syncToRight() and syncToLeft() can be used with a converter function as well:

binding.syncToRight("mapCenter", "center", (mapCenterValue, context) => {
    // return transformed mapCenter value;
});
binding.syncToLeft("center", "mapCenter", (centerValue, context) => {
    // return transformed center value;
});

To access the target object and more, use the context parameter, which is always the last parameter of the converter. See the API documentation for more details.

Syncing multiple values with one single value and conversely

In some cases more than one property must be synchronized to a single property in the other model. If no converter function is provided, default functions convert the values - in practice that means joining or splitting multiple values respectively:

binding.sync(["firstName", "lastName" /*, ...*/], "fullName");

In most cases though, you may want to define your own functions to convert values like explained above. In the case of multiple properties on one side, the according converter function provides an Array of property values in the same order; and on the other side your converter function needs to return an Array of properties:

binding.sync(
    ["firstName", "lastName" /*, ...*/],
    "fullName",
    ([firstNameValue, lastNameValue], context) => {
        // return transformed value for fullName
    },
    (fullNameValue, context) => {
        // return Array of transformed values for
        // ["firstName", "lastName" /*, ...*/]
    }
);

The one way versions syncToLeft() and syncToRight() can be used with the notation for multiple properties as well:

binding.syncToRight(["firstName", "lastName" /*, ...*/], "fullName", ([firstNameValue, lastNameValue], context) => {
    // return transformed value for fullName
});
binding.syncToLeft("fullName", ["firstName", "lastName" /*, ...*/], (fullNameValue, context) => {
    // return Array of transformed values for
    // ["firstName", "lastName" /*, ...*/]
});

Easily convert value types

As mentioned preceding, converter functions can be used to convert values before set to one of the models. This also lets you use built-in objects like String or Number. The following sample shows how numeric strings could be converted to numbers and back:

Binding.for(left, right).sync("leftText", "rightNumber", Number, String);

Use async converter functions

If a converter function returns a Promise, the binding waits until the Promise resolves and sets the resolved value onto the target instance.

Sample: The converter makes a request and the returned value is stored in "rightNumber".

Binding.for(left, right).syncToRight("leftText", "rightNumber", text => {
    return fetch("http://....?param=" + text).then(Number);
});

To synchronize Promises as values, use the context.promiseValue method:

Binding.for(left, right).syncToRight("leftText", "rightPromise", (promise, context) => {
    return context.promiseValue(fetch("http://....?param=" + text).then(Number));
});

NOTE: a side effect of the special behavior for promises is following:

// right.rightValue is the result of the left promise, after resolving.
Binding.for(left, right).syncToRight("leftPromise", "rightValue");
// disable this default behavior by following
Binding.for(left, right).syncToRight("leftPromise", "rightPromise", Binding.promiseValue);

Separate binding declaration from actually binding it to its models

If one or both models are not present when declaring the binding, use create() to create an "empty" binding that has no models defined:

let binding = Binding.create()
    .sync("leftText", "rightText")
    .enable();

When the models are present, use bindTo() to establish the connection between them using the previously declared properties:

binding.bindTo(leftModel, rightModel);

This method can also be useful when models need to be re-bound during the lifecycle of a binding.

Use the API "fluently"

As you may already have noticed, the Binding API has a fluent interface. That means, that you can call functions on it like writing a sentence:

Binding.for(left, right)
    .sync("leftText", "rightNumber")
    .syncToRight("someLeftProp", "someRightProp")
    .syncToLeft("anotherRightProp", "anotherLeftProp")
    .enable();

Directly accessing binding properties

You have access to the following binding properties:

let binding = Binding.for(leftModel, rightModel).enable();
binding.left; // leftModel (readonly)
binding.right; // rightModel (readonly)
binding.enabled; // true

Transformation helpers

Next to the Binding the bundle contains a helpful Transformers module. This module provides helpers intended to be used in combination with custom transformer functions.

The following sample shows the usage pattern of one of the provided functions:

import { ifDefined } from "apprt-binding/Transformers";

Binding.for(a, b)
    // the property "name" is only synced from 'a' to 'b'
    // if the value is not null and not undefined
    .syncToRight("name", ifDefined())
    .enable()
    .syncToRightNow();

Implementing custom Bindables

Using apprt-core/Mutable should be sufficient for most use cases. When advanced control is desired, or if a third-party datasource must be integrated, a custom model can be created by implementing the apprt-binding/Bindable interface.

The following example implements a simple model class with a "time" property that can be updated manually. Whenever an update() is triggered, all watchers (that is, the watching bindings) on that property will be notified about the change. Because the model implements get and watch, it can serve as the source object of a synchronization.

// A very simple bindable with a single virtual property ("time") that changes
// when "update" is invoked.
class TimeModel {
    constructor() {
        this._watches = [];
        this._currentTime = this._getCurrentTime();
    }

    // Updates the time value manually and triggers all watch callbacks.
    // This could also be done in a periodic action, e.g. once a second.
    // Note that this function is not part of the Bindable interface.
    update() {
        this._currentTime = this._getCurrentTime();
        this._watches.forEach(callback => callback());
    }

    // Does nothing, this model provides read only access.
    set() {}

    // Returns the current value of the requested property to bindings.
    get(propName) {
        if (propName === "time") {
            return this._currentTime;
        }
        return undefined;
    }

    // Called by bindings in order to subscribe to property updates.
    watch(propName, callback) {
        const watches = this._watches;
        if (propName === "time") {
            watches.push(callback);

            // The remove() function will unregister the callback. It is called by bindings
            // when they are unbound or destroyed.
            return {
                remove() {
                    const i = watches.indexOf(callback);
                    if (i !== -1) {
                        watches.splice(i, 1);
                    }
                }
            };
        }
    }

    _getCurrentTime() {
        return new Date().getTime().toString();
    }
}

Usage:

const left = new TimeModel();
const right = someOtherBindable;
const binding = Binding.for(left, right)
    .syncToRight("time", "currentTime")
    .syncToRightNow()
    .enable();

left.update(); // Will propagate the time to `right` asynchronously.