Skip to content

Flexible three-way data-binding for Meteor (db-VM-V; also, Blaze-friendly)

Notifications You must be signed in to change notification settings

convexset/meteor-three-way

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ThreeWay

ThreeWay is a Meteor package that provides three-way data-binding. In particular, database to view model to view.

Learn more at the demo/guide site.

The objective of writing this package is to provide a powerful (e.g.: dynamic data-binding), flexible (e.g.: transformations of view model data for presentation, ancestor/descendant/sibling data-access) and Blaze-friendly tool for doing data-binding (i.e.: no tip-toeing around the package).

Database to view model connectivity is provided by Meteor methods (with signatures function(id, value)), with "interface transforms" for server-to-client and client-to-server. Actually, it is richer than that. One may configure fields for data-binding with wild cards and send the data back with meteor methods that take more arguments (e.g.: methods with signature function(id, value, param1, param2, ...)). The user is responsible for ensuring the right subscriptions are in place so ThreeWay can retrieve records from the local database cache.

The data binding responds to changes in the DOM. So Blaze can be used to generate and change data bindings.

Presentation of data is facilitated by "pre-processors" which map values (display-only bindings) and may do DOM manipulation when needed (e.g.: with Semantic UI dropdowns and certain animations). This feature allows for great flexibility in displaying data, enabling one to "easily" (and typically declaratively) translate data to display.

Table of Contents

Somehow links in Atmosphere get messed up. Navigate this properly in GitHub.

Install

This is available as convexset:three-way on Atmosphere. (Install with meteor add convexset:three-way.)

If you get an error message like:

WARNING: npm peer requirements not installed:
 - package-utils@^0.2.1 not installed.
          
Read more about installing npm peer dependencies:
  http://guide.meteor.com/using-packages.html#peer-npm-dependencies

It is because, by design, the package does not include instances of these from npm to avoid repetition. (In this case, meteor npm install --save package-utils will deal with the problem.)

See this or this for more information.

Now, if you see a message like

WARNING: npm peer requirements not installed:
underscore@1.5.2 installed, underscore@^1.8.3 needed

it is because you or something you are using is using Meteor's cruddy old underscore package. Install a new version from npm. (And, of course, you may use the npm version in a given scope via require("underscore").)

The Demo/Guide Site

The repository contains the source for the demo/guide site as well as the package proper.

The site uses semantic:ui which requires a bit of initialization. Start Meteor, do a trivial edit of client/lib/semantic-ui/custom.semantic.json, and save it to generate Semantic UI.

It provides a view of the database via an #each block iterating over a cursor

Usage

Here are some an example set-ups. Some of this will be clear immediately, the rest will become clear soon.

Basic Set Up

Here are some an example set-ups. Some of this will be clear immediately, the rest will become clear soon.

Let's start with a simple vanilla set-up.

ThreeWay.prepare(Template.DemoThreeWay, {
    // The relevant Mongo.Collection
    collection: DataCollection,  // alt: string with collection name or null

    // Meteor methods for updating the database
    // The keys being the respective fields/field selectors for the database
    // The method signature for these methods being
    // function(_id, value, ...wildCardParams)
    updatersForServer: {
        'name': 'update-name',
        'tags': 'update-tags',
    },

    // (Global) initial values for fields that feature only in the local view model
    // and are not used to update the database
    viewModelToViewOnly: {
        'vmOnlyValue': '',
    },
});

One then proceeds to bind input and display elements like so:

<ul>
    <li>Name: <input type="text" data-bind="value: name"></li>
    <li>Name in View Model: <span data-bind="html: name"></span></li>
    <li>vmOnlyValue: <input type="text" data-bind="value: vmOnlyValue"></li>
    <li>vmOnlyValue in View Model: <span data-bind="text: vmOnlyValue"></span></li>
</ul>

Once ready to bind to a document with id _id in the database on a template instance instance, simply call:

instance._3w_.setId(_id);

... or instantiate the relevant template like so:

{{> DemoThreeWay _3w_id="the-relevant-item-id"}}

or

{{> DemoThreeWay _id="the-relevant-item-id"}}

or

<!-- assuming someObject is an object with an _id property -->
{{> DemoThreeWay someObject}}

... and things will happen.

Note: When a document _id is passed in through the data context, _3w_id takes precedence over _id for "reasonable reasons". For example, an existing template might take as input an object with an _id property, but that might not be the desired _id since data passed in is typically "static" and probably comes from another collection.

Here is a sample data-binding string:

<input data-bind="value: email"></div>

... and here is one which gives a sense of typical use...

<input data-bind="value: email; style: {color: emailValidationErrorText|trueIfNonEmpty|redIfTrue}"></div>
<div data-bind="html: emailValidationErrorText; visible: emailValidationErrorText|trueIfNonEmpty" style="color: red;"></div>

this will be elaborated upon later.

"Intermediate-level" Set Up

Now here are more of the settings, including:

  • data transformations from view model to server and from server to view model
  • helper functions that "display"-type (one-way) bindings like html, visible and disabled, as well as the class, style and attr bindings can use for input
  • pre-processors for values in "input" elements and for "display" elements
ThreeWay.prepare(Template.DemoThreeWay, {
    // The relevant Mongo.Collection
    collection: DataCollection,  // alt: string with collection name or null

    // Meteor methods for updating the database
    // The keys being the respective fields/field selectors for the database
    // The method signature for these methods being
    // function(_id, value, ...wildCardParams)
    updatersForServer: {
        'name': 'update-name',
        'emailPrefs': 'update-emailPrefs',
        'personal.particulars.age': 'update-age',
        'tags': 'update-tags',
        'personal.someArr.*': 'update-some-arr',
        'personal.someArr.1': 'update-some-arr-1',  // More specific than the previous, will be selected
        'personal.otherArr.*.*': 'update-other-arr',
    },

    // Transformations from the server to the view model
    // In this example, "tags" are stored in the view model as a comma
    // separated list in a string, while it is stored in the server as
    // an array
    dataTransformToServer: {
        tags: x => x.split(',').map(y => y.trim())
    },

    // Transformations from the view model to the server
    // (Transform and call the updater Meteor method)
    // In this example, "tags" are stored in the view model as a comma
    // separated list in a string, while it is stored in the server as
    // an array
    dataTransformFromServer: {
        tags: arr => arr.join && arr.join(',') || ''
    },

    // Pre-processors for data pre-render (view model to view)
    preProcessors: {
        // this takes a string of comma separated tags, splits, trims then
        // joins them to make the result "more presentable"
        tagsTextDisplay: x => (!x) ? '' : x.split(',').map(x => x.trim()).join(', '),

        // this maps a key to the corresponding long form description
        mapToAgeDisplay: x => ageRanges[x],

        // this maps an array of keys to the corresponding long form
        // descriptions and then joins them
        // emailPrefsAll is of the form {"1_12": "1 to 12", ...})
        mapToEmailPrefs: prefs => prefs.map(x => emailPrefsAll[x]).join(", "),

        // These processors support visual feedback for validation
        // e.g.: the red "Invalid e-mail address" text that appears when
        // an invalid e-mail address has been entered
        trueIfNonEmpty: x => x.length > 0,
        grayIfTrue: x => (!!x) ? "#ccc" : '',
        redIfTrue: x => (!!x) ? "red" : '',
    },

    // (Global) initial values for fields that feature only in the local view
    // model and are not used to update the database
    // Will be overridden by value tags in the rendered template of the form:
    // <data field="sliderValue" initial-value="50"></data>
    viewModelToViewOnly: {
        sliderValue: "0",
        "tagsValidationErrorText": '',
    },
});

Returning to the previous example:

<input data-bind="value: email; style: {color: emailValidationErrorText|trueIfNonEmpty|redIfTrue}"></div>
<div data-bind="html: emailValidationErrorText; visible: emailValidationErrorText|trueIfNonEmpty" style="color: red;"></div>

... the "pipes" take data (from helpers or data fields) and pass them through a pipeline of pre-processors. For "display bindings" like html and text, the final value is displayed.

Suppose emailValidationErrorText were "" then piping it through trueIfNonEmpty would lead to false, and piping that through redIfTrue would return ''. So in the event of "no validation error", the color of the text would remain the default inherited value.

For "value bindings" like value and checked, the pipelines are only used to do DOM manipulation. This is useful for custom elements such as Semantic UI dropdowns.

More on this in a bit.

Set Up: The Full Parameter Set

At this point, one might have a look at the full parameter set. Which will include:

  • injection of default values for selected fields
  • data validation
  • event bindings
  • database update settings
  • suppressing and reporting the update of a focused field

Further elaboration is available in the documentation below.

ThreeWay.prepare(Template.DemoThreeWay, {
    // The relevant Mongo.Collection
    collection: DataCollection,  // alt: string with collection name or null

    // Meteor methods for updating the database
    // The keys being the respective fields/field selectors for the database
    // The method signature for these methods being
    // function(_id, value, ...wildCardParams)
    updatersForServer: {
        'name': 'update-name',
        'emailPrefs': 'update-emailPrefs',
        'personal.particulars.age': 'update-age',
        'tags': 'update-tags',
        'personal.someArr.*': 'update-some-arr',
        'personal.someArr.1': 'update-some-arr-1',  // More specific than the previous, will be selected
        'personal.otherArr.*.*': 'update-other-arr',
    },

    // Inject default values if not in database record
    injectDefaultValues: {
        'name': 'Unnamed Person',
        // for wildcard fields, the last part of the field name cannot be a wildcard
        'personal.otherArr.*.a': '100',
    },

    // Transformations from the server to the view model
    // In this example, "tags" are stored in the view model as a comma
    // separated list in a string, while it is stored in the server as
    // an array
    dataTransformToServer: {
        tags: x => x.split(',').map(y => y.trim())
    },

    // Transformations from the view model to the server
    // (Transform and call the updater Meteor method)
    // In this example, "tags" are stored in the view model as a comma
    // separated list in a string, while it is stored in the server as
    // an array
    dataTransformFromServer: {
        tags: arr => arr.join && arr.join(',') || ''
    },

    // Validators under validatorsVM consider view-model data
    // Useful for making sure that transformations to server values do not fail
    // Validators under validatorsServer consider transformed data (for the server)
    //
    // validators have method signature:
    //   function(value, matchInformation, vmData)
    // success/failure call backs have signature:
    //   function(value, matchInformation, vmData)
    // all are called with the template instance as context
    //
    // matchInformation takes the form:
    //   {
    //      "fieldPath": "personal.otherArr.0.a",
    //      "match": "personal.otherArr.*.*",
    //      "params": ["0","a"]
    //   }
    validatorsVM: {
        // tags seems to be a decent candidate for one here
        // but see below
    },
    validatorsServer: {
        tags: {
            validator: function(value, matchInformation, vmData) {
                // tags must begin with "tag"
                return value.filter(x => x.substr(0, 3).toLowerCase() !== 'tag').length === 0;
            },
            success: function(value, matchInformation, vmData) {
                var instance = this;
                instance._3w_.set('tagsValidationErrorText', '');
            },
            failure: function(value, matchInformation, vmData) {
                var instance = this;
                instance._3w_.set('tagsValidationErrorText', 'Each tag should begin with \"tag\".');
            },
        },
    },
    // determines whether to re-validate repeated values
    validateRepeats: false,  // (default: false)

    // Helper functions that may be used as input for display-type bindings
    // Order of search: three-way helpers, then template helpers, then data
    // Called with this bound to template instance
    // (be aware that arrow functions are lexically scoped)
    helpers: {
        altGetId: function() {
            return Template.instance()._3w_.getId()
        }
    }

    // Pre-processors for data pre-render (view model to view)
    preProcessors: {
        // this takes a string of comma separated tags, splits, trims then
        // joins them to make the result "more presentable"
        tagsTextDisplay: x => (!x) ? '' : x.split(',').map(x => x.trim()).join(', '),

        // this maps a key to the corresponding long form description
        mapToAgeDisplay: x => ageRanges[x],

        // this maps an array of keys to the corresponding long form
        // descriptions and then joins them
        // emailPrefsAll is of the form {"1_12": "1 to 12", ...})
        mapToEmailPrefs: prefs => prefs.map(x => emailPrefsAll[x]).join(", "),

        // These processors support visual feedback for validation
        // e.g.: the red "Invalid e-mail address" text that appears when
        // an invalid e-mail address has been entered
        trueIfNonEmpty: x => x.length > 0,
        grayIfTrue: x => (!!x) ? "#ccc" : '',
        redIfTrue: x => (!!x) ? "red" : '',
    },

    // (Global) initial values for fields that feature only in the local view
    // model and are not used to update the database
    // Will be overridden by value tags in the rendered template of the form:
    // <data field="sliderValue" initial-value="50"></data>
    viewModelToViewOnly: {
        sliderValue: "0",
        "tagsValidationErrorText": '',
    },

    // Event Handlers bound like
    // <input data-bind="value: sliderValue; event: {mousedown: dragStartHandler, mouseup: dragEndHandler|saySomethingHappy}" type="range">
    eventHandlers: {
        dragStartHandler: function(event, template, vmData) {
            console.info("Drag Start at " + (new Date()), event, template, vmData);
        },
        dragEndHandler: function(event, template, vmData) {
            console.info("Drag End at " + (new Date()), event, template, vmData);
        },
        saySomethingHappy: function() {
            console.info("Let\'s chill. (Second mouseup event to fire.)");
        },
    },

    // Database Update Parameters
    // "Debounce Interval" for Meteor calls; See: http://underscorejs.org/#debounce
    debounceInterval: 300, // default: 500
    // "Throttle Interval" for Meteor calls; See: http://underscorejs.org/#throttle ; fields used for below...
    throttleInterval: 500, // default: 500
    // Fields for which updaters are throttle'd instead of debounce'd
    throttledUpdaters: ['emailPrefs', 'personal.particulars.age'],

    // Reports updates of focused fields
    // default: null (i.e.: update focused field and do nothing;
    //                feel free to do something and update the field anyway
    //                via instance._3w_set)
    // fieldMatchParams take the form like:
    //      {
    //          fieldPath: "personal.otherArr.0.a",
    //          match: "personal.otherArr.*.*",
    //          params: ["0", "a"]
    //      }
    updateOfFocusedFieldCallback: function(fieldMatchParams, newValue, currentValue) {
        console.info("Update of focused field to", newValue, "from", currentValue, "| Field Info:", fieldMatchParams);
    },

    // Reactively update _id of document with the return value of this
    // function (default: null; to not use this feature)
    idGetter: function reactiveIdGetter(c) {
        // return something based on data in collections
        if (Template.instance().subscriptionsReady()) {
            /* return something here? */
        }

        // watch path and set _id (here's a FlowRouter example)
        // it is recommended to give _id params in routes different names from
        //   _id, such as "userId", "itemId", ...
        return FlowRouter.getParam('itemId');

        // to avoid setting (changing) the _id
        // return null;

        // c is a Tracker.Computation, and can be stopped via
        // c.stop(), which means that following the return of this function
        // the _id of the bound document will no longer be reactively updated
    }
});

And we are back to that example:

<input data-bind="value: email; style: {color: emailValidationErrorText|trueIfNonEmpty|redIfTrue}"></div>
<div data-bind="html: emailValidationErrorText; visible: emailValidationErrorText|trueIfNonEmpty" style="color: red;"></div>

... the example options above are a little disjoint from this snippet, but it should be clear that defining a validator will inform ThreeWay of whether input is valid or not, and then set emailValidationErrorText to a non-empty string if there is a validation error. So when there is a validation error, the error message will be displayed and the color of the input field will be set to red.

Going Into Production

To prevent debug and preparation functions from being misused by end users, one can block their use. One way is to call "irreversible stopping functions" after all ThreeWay.prepare calls are done. Meteor.startup works in this case:

Meteor.startup(function() {
    ThreeWay._preventSubsequentPrepareCalls(true);
    ThreeWay.utils._preventInstanceEnumeration(true);
});

If one wants to do this adaptively, then pass in a function that returns a truthy value. For instance, a function that checks Meteor.settings and returns true (to block subsequent uses) and false (to allow, for instance, in development).

But the simplest way to do this would be something like...

    Meteor.startup(function() {
        if (Meteor.isClient) {
            if (Meteor.settings.public.appName.indexOf('(Development)') !== -1) {
                ThreeWay._preventSubsequentPrepareCalls(false);
                ThreeWay.utils._preventInstanceEnumeration(false);
            } else {
                ThreeWay._preventSubsequentPrepareCalls(true);
                ThreeWay.utils._preventInstanceEnumeration(true);
            }
        }
    });

Documentation

Referring to Fields in Documents

Consider the following Mongo document. The relevant fields may be referred to with the identifiers in the comments:

{
    topLevelField: 'xxx',  // "topLevelField"
    topLevelArray: [  // "topLevelArray"
        'a',  // "topLevelArray.0"
        'b',  // "topLevelArray.1"
        'c',  // "topLevelArray.2"
    ],
    topLevelObject: {  // "topLevelObject"
        nestedField: 'xxx',  // "topLevelObject.nestedField"
        nestedArray: [  // "topLevelObject.nestedArray"
            1,  // "topLevelObject.nestedArray.0"
            2,  // "topLevelObject.nestedArray.1"
            3,  // "topLevelObject.nestedArray.2"
        ],
    }
}

Here is an example of binding to one of them: <span data-bind="value: topLevelObject.nestedArray.2"></span>.

However, it would be clunky to have to specify each of "topLevelObject.nestedArray.0" thru "topLevelObject.nestedArray.2" (or more) in the set-up options. Therefore, options.updatersForServer accepts wildcards (in key names) such as topLevelObject.nestedArray.* where * matches numbers (for arrays) and "names" (for objects; but of course, it's all objects anyway).

Note that in the case of multiple matches, the most specific match will be used, enabling "catch-all" updaters (which can be somewhat dangerous if not managed properly).

Note that having a more specific match is a signal to distinguish a field (or family of fields) from the more generic matches. This means that the more generic validators will not be used. There is no "fall through"... at the moment...

Default Values

If certain fields require a value but it is possible that database entries have such missing fields, they can be "injected" (on the view model side) with defaults. These may be specified in the set up as follows:

// Inject default values if not in database record
injectDefaultValues: {
    'name': 'Unnamed Person',
    // for wildcard fields, the last part of the field name cannot be a wildcard
    'personal.otherArr.*.a': '100',
},

As indicated, for wildcard fields, the last part of the field name cannot be a wildcard. Otherwise, it would be impossible to determine exactly what field to add. The "non-tail" part of existing fields are used to figure out if there is missing data.

Dynamic Data-Binding

The data binding responds to changes in the DOM. So Blaze can be used to generate and change data bindings. For example:

{{#each fields}}
    <div>{{name}}: <input data-bind="value: particulars.{{field}}"></div>
{{/each}}

... might generate...

<div>Name: <input data-bind="value: particulars.name"></div>
<div>e-mail: <input data-bind="value: particulars.email"></div>
<div>D.O.B.: <input data-bind="value: particulars.dob"></div>

... and should a new field be added, data binding will take effect.

Using Dynamic Data Binding with Multiple ThreeWay instances

Dynamic data binding works without a hitch (hopefully) when a template is operating in a vacuum. Multiple ThreeWay instances (See: "Family Access": Ancestor and Descendant Data for more information) work fine in the absence of dynamic data binding. But when DOM elements (to be data bound) are being added and removed dynamically, it is important to create certainty about which ThreeWay instance a given DOM element should be bound to.

Briefly, the start of the template life cycle for a template and a child template is as follows: (i) parent created, (ii) child created, (iii) child rendered, (iv) parent rendered. Monitoring call backs work on a "first-come-first-bound" basis, with child nodes getting the first pick.

To ensure proper bindings, it is advisable for every template to be wrapped in a div element which will be watched for changes within. A design decision was made to not require such a root node, but to leave it to the user to handle this matter. In the absence of a root node, all individual DOM elements in the template are observed for changes. (This is why having a wrapping element is useful.)

In the event that the user would (inexplicably) like to observe disjoint parts of a template for changes, the _3w_setRoots(selectorString) method should be used to select root nodes (via a template-level jQuery selector). This might be done in an onRendered hook or after rendering completes.

For even more specificity, the restrict-template-type attribute can be set (with a comma separated list of template names) on DOM elements to specify which ThreeWay-linked template types should be used to data bind individual elements.

Updaters to the Server

Data is sent back to the server via Meteor methods. This allows one to control matters like authentication and the like. What they have in common is method signatures taking the _id of the document, the updated value next, and a number of additional parameters equal to the number of wildcards in the field specification.

The keys of options.updatersForServer are the respective fields (or fields specified through wild cards) for the database. The method signature for these methods is function(_id, value, ...wildCardParams).

In the event that a method is associated with a "wildcard match" field name, such as "ratings.3.rating" matched to "ratings.*.rating", then the matching wildCardParams will be passed into the method as well. In that example, one would end up with a call like:

Meteor.call('update-ratings.*.rating', _id, newValue, "3");

(Don't mind the string representation of array indices, it doesn't really matter because Mongo field specifiers are strings.)

Here are more examples:

updatersForServer: {
    'x': 'update-x',
    'someArray.*': 'update-someArray.*',
    'anotherArray.*.properties.*': 'update-anotherArray.*.properties.*'
},

... which might be associated with the following methods:

Meteor.methods({
    'update-x': function(id, value) {
        if (someAuthCheck(this.userId)) {
            DataCollection.update(id, {
                $set: {
                    x: value
                }
            });
        }
    },
    'update-someArray.*': function(id, value, k) {
        var updater = {};
        updater['someArray.' + k] = value;
        DataCollection.update(id, {
            $set: updater
        });
    },
    'update-anotherArray.*.properties.*': function(id, value, k, fld) {
        var updater = {};
        updater['anotherArray.' + k + '.properties.' + fld] = value;
        DataCollection.update(id, {
            $set: updater
        });
    }
});

(Also, please don't ask for regular expressions... Do that within your own updaters.)

To use callbacks or otherwise deviate from the use of Meteor methods, use the following Extended Notation for Updaters.

Extended Notation for Updaters

Aside from plain string names Also allowed are updater descriptions of the following form:

{
    'name': function(id, value) {
        console.info('[update-name]', id, "to", value);
        Meteor.call('update-name', id, value);
    },
    'personal.someArr.1': {
        method: 'update-personal.someArr.1',
        callback: function(err, res, info) {
            console.info('[update-personal.someArr.1]', err, res, info);
        }
    },
}

In the latter case, info takes the form:

{
    instance: instance,  // template instance
    id: _id,   // _id
    value: v,  // update value
    params: params,  // wildcard params
    methodName: methodName,  // method name
    updateTime: updateTime,  // time update started
    returnTime: returnTime,  // time update completed
};

... admittedly, that is a little excessive.

Database Update Parameters

Update methods are, by default, debounced. These can be customized. The example below should be reasonably self-explanatory:

{
    ...
    // Database Update Parameters
    // "Debounce Interval" for Meteor calls; See: http://underscorejs.org/#debounce
    debounceInterval: 300, // default: 500
    // "Throttle Interval" for Meteor calls; See: http://underscorejs.org/#throttle ; fields used for below...
    throttleInterval: 500, // default: 500
    // Fields for which updaters are throttle'd instead of debounce'd
    throttledUpdaters: ['emailPrefs', 'personal.particulars.age'],
    ...
}

Transforms: Translation from/to Database to/from View Model

The format that data is stored in a database might not be the most convenient for use in the view model (e.g.: sparse representation "at rest"), as such it may be necessary to do some translation between database and view model.

Consider the following example:

dataTransformFromServer: {
    tags: arr => arr.join && arr.join(',') || ''
},
dataTransformToServer: {
    tags: x => x.split(',').map(y => y.trim())
},

In this example, for some reason, tags is stored in the view model as a string-ified comma separated list, while it is stored as an array on the server. When the underlying observer registers a change to the database, the new value is converted and placed into the view model. When the database is to be updated, the view model value is transformed back into an array before it is sent back via the relevant Meteor method.

Note that transformations actually take two parameters, the first being the value in question and the second being all the view model data. Thus the complete method signature is function(value, vmData).

Binding to the View

When the template is rendered, the reactive elements are set up using data-bind attributes in the "mark up" part of the template.

Binding: html and text

For example, <span data-bind="html: name"></span>, binds the "name" field to the innerHTML property of the element. Also, <span data-bind="text: something"></span>, binds the "something" field to the text property of the element.

But "pre-processors" can be applied to view model data to process content before it is rendered. For example,

<span data-bind="html: emailPrefs|mapToEmailPrefs"></span>
<span data-bind="text: emailPrefs|mapToEmailPrefs"></span>

where mapToAgeDisplay was described as x => ageRanges[x] (or, equivalently, function(x) {return ageRanges[x];}) and ageRanges is a dictionary (object) mapping keys to descriptions.

Pre-processors actually take up-to four arguments, (value, elem, vmData, dataSourceInfomation) and return a value to be passed into the next pre-processor, or rendered on the page. This actually features in ThreeWay.preProcessors.updateSemanticUIDropdown, used in the demo, where the element itself has to be manipulated (see: this) to achieve the desired result. More on pre-processors later.

Binding: value

There's usually nothing much to say about this simple binding...

<input name="name" data-bind="value: name">

(It works with input and textarea tags.)

Binding: checked

This one too, although the helper does bear some explaining. repackageDictionaryAsArray takes a dictionary (object) and maps it into an array of key-value pairs. That is, an array with elements of the form {key: "key", value: "value"}. So the below example lays out the various options as checkboxes and binds checked to an array.

{{#each repackageDictionaryAsArray emailPrefsAll}}
    <div class="ui checkbox">
        <input type="checkbox" name="emailPrefs" value="{{key}}" data-bind="checked: emailPrefs">
        <label>{{value}}</label>
    </div>
{{/each}}

In the case of radio buttons, checked is bound to a string.

<div class="inline fields">
    {{#each repackageDictionaryAsArray ageRanges}}
        <div class="ui radio checkbox">
            <input type="radio" name="age" value="{{key}}" data-bind="checked: age">
            <label>{{value}}</label>
        </div>
    {{/each}}
</div>

Binding: ischecked

The ischecked binding is similar to the checked binding. However, it relates to individual elements rather than the collection of radio buttons or checkboxes. Checked elements map to true values and unchecked elements map to false values.

Binding Modifiers for value and checked

One can apply certain modifiers to value and checked bindings such as:

<input name="name" data-bind="value#donotupdateon-input: name">
<input name="comment" data-bind="value#throttle-1000: name">

By default, value bindings update the view model on change and input. But the latter can be suppressed with a donotupdateon-input option. In the first example, the view model is only updated on change such as a loss of focus after a change is made.

For the comment input element, updates can happen as one is typing (due to updates being made on input), however, those updates to the view model are throttled with a 1 second interval.

The following modifiers are available and are applied in the form <binding>#<modifier>-<option>#<modifier>-<option>: <view model field>:

  • updateon: also updates the view model when a given event fires (e.g. updateon-<event name>)
  • donotupdateon: do not update the view model when a given event fires (e.g. donotupdateon-<event name>); the only valid option is input and this only applies to value bindings.
  • throttle: throttles (e.g. throttle-<interval in ms>); does not apply to checked bindings
  • debounce: (e.g. debounce-<interval in ms>); does not apply to checked bindings

(For checked bindings, it would be rather sketchy to apply throttling or debouncing due multiple elements forming a composite checked widget.)

Bindings: visible and disabled (modern necessities)

visible and disabled can be bound to any boolean (or truthy) variable, and stuff disappears/gets disabled when it is set to false (false-ish).

<div data-bind="visible: something">...</div>
<div data-bind="disabled: something">...</div>

Bindings: focus

focus deals with whether an element is focused. (Personally not all too keen on this one.)

<input type="text" name="name" data-bind="focus: nameHasFocus">
<div data-bind="visible: nameHasFocus">name has focus</div>

Style, Attribute and Class Bindings

Style bindings are done via: data-bind="style: {font-weight: v1|preProc, font-size: v2|preProc; ...}". Things work just like the above html binding.

Attribute bindings are done via: data-bind="attr: {disabled: v1|preProc, ...}". Things also work just like html bindings. Set a value to null or undefined to ensure that an attribute is not present at all.

Class bindings are done via: data-bind="class: {class1: bool1|preProc; ...}". However, things work more like the visible and disabled bindings in that the values to be bound to will be treated as boolean-ish.

Alternatively, one can bind each of the above directly to a single object. This can be done as follows:

  • data-bind="styles: stylesObject" where stylesObject might be
    {
        "font-family": "Courier New",
        "font-size": "200%",
    }
    
  • data-bind="classes: classesObject" where classes might be
    {
        "red": false,
        "loading": true,
    }
    
  • data-bind="attributes: attributesObject" where attributesObject might be
    {
        "disabled": true,
        "data-id": "xxxxxx",
    }
    
    As above, set a value to null or undefined to get rid of the respective attribute.

Helpers, Template Helpers and Binding

Helper functions may be used as input for display-type bindings. Such bindings include html, visible, disabled, as well as the class, style and attr bindings.

For such bindings, the order of search is helpers first, then template helpers, then data. (so ThreeWay helpers shadow template helpers)

Helpers are called with this bound to template instance, and Template.instance() is also accessible. (Note: Be careful of lexically scoped arrow functions that overrides call/apply/bind.)

It is useful to highlight _3w_hasData, which is automatically added to the set of template helpers.

<div data-bind="visible: _3w_hasData">...</div>
<button data-bind="disabled: _3w_hasData">...</button>

One might find it to be particularly useful.

A tenuous design decision has been made not to phase out helpers. A less tenuous design decision is to not unify helpers with pre-processors based on their different method signatures.

Helpers may be inherited.

Multi-variable Display Bindings

Sometimes one variable alone is not enough to determine the state of a DOM property. For example, to determine whether a phone number is valid, might depend both on the number and on the country. On the other hand, that example is faulty since a validation callback can do the relevant computations with full access to the view model.

But anyway, usefulness aside, this is one example of such a binding:

<div data-bind="style: {background-color: colR#colG#colB|makeRGB}">

... it is also an example that you might find in the demo. (Look for the bit asking you to some nodes to the DOM via the console.)

Event Bindings

Event bindings may be achieved via: data-bind="event: {change: cbFn, keyup:cbFn2|cbFn3, ...}" where callbacks like cbFn1 have signature function(event, template, vmData) (vmData being, of course, the data in the view model).

The event handlers may be specified in the set up as follows:

eventHandlers: {
    dragStartHandler: function(event, template, vmData) {
        console.info("Drag Start at " + (new Date()), event, template, vmData);
    },
    dragEndHandler: function(event, template, vmData) {
        console.info("Drag End at " + (new Date()), event, template, vmData);
    },
    saySomethingHappy: function() {
        console.info("Let\'s chill. (Second mouseup event to fire.)");
    },
},

... and bound as follows:

<input data-bind="value: sliderValue; event: {mousedown: dragStartHandler, mouseup: dragEndHandler|saySomethingHappy}" type="range">

Event handlers may be inherited.

View Model to View Only Elements

Template-level defaults may be specified in the configuration like so:

// (Global) initial values for fields that feature only in the local view model
// and are not used to update the database
viewModelToViewOnly: {
    'vmOnlyValue': 'something',
},

... or a generic customization via the template instance (fired at onCreated)...

viewModelToViewOnly: function (templateInstance) {
    return {
        'vmOnlyValue': someComputation(templateInstance.data.something),
    };
},

However, since those are "template-level defaults", it may be useful at times to customize (update) them at instantiation. This may be achieved through template instance data:

{{> DemoThreeWay _3w_additionalViewModelOnlyData=helperWithAdditionalData}}

Instance Methods

The following methods are crammed onto each template instance in an onCreated hook. They may be accessed via instance._3w_ (where instance is the relevant template instance).

Organizing the DOM

  • setRoots(selectorString): selects the root of the ThreeWay instance using a selector string (Template.instance().$ will be used); child nodes of the single node (the method throws an error if more than one node is matched), present and forthcoming, will be watched for changes (and the respective data bindings updated); See Using Dynamic Data Binding with Multiple ThreeWay instances for more information

My Data

  • get3wInstanceId(): gets the instance id of the ThreeWay instance

  • getId(): gets the id of the document bound to

  • setId(id): sets the id of the document to bind to

  • get(prop): gets a property

  • getWithDefault(prop, defaultValue): gets a property and returns defaultValue if undefined.

  • set(prop, value): sets a property

  • get_NR(prop): gets a property "non-reactively"

  • getAll_NR: gets all the data "non-reactively"

  • withArray(prop, methodName, ...args): invokes the method with name methodName (e.g.: push) on the array in a property with arguments args and returns the result (throws if no array is found)

    • e.g.: instance._3w_.withArray('numbers', 'push', 5); /* returns resulting length */
    • e.g.: instance._3w_.withArray('numbers', 'pop', 5); /* returns 5 */
  • mapData(prop, mapFunction, ...additionalArgs): invokes the function mapFunction with the data of a property as the first argument and additional arguments additionalArgs, sets the property to the result, and returns the result

    • e.g.: instance._3w_.mapData('someNumber', (x,y) => x+y, 5); /* increments someNumber by 5 */
  • isPropVMOnly(prop): gets all view-model only data "non-reactively"

  • getAll_VMOnly_NR: gets all view-model only data "non-reactively"

  • fetch(prop): attempts to retrieve property prop from the view model and, failing that, attempts to read from the bound document

  • fetchExtended(prop): attempts to retrieve property prop in the following order of precedence:

    1. evaluating a ThreeWay helper
    2. evaluating a ThreeWay helper on (the closest) ancestor
    3. evaluating a Blaze helper
    4. the entire document (prop = "*")
    5. the entire view model (prop = "@")
    6. from some view model field
    7. from some document field
  • isSyncedToServer(prop): returns true if ThreeWay can be sure that data for the field with name prop has been received and written by the server.

  • allSyncedToServer: returns true if ThreeWay can be sure that all data has been received and written by the server.

  • isNotInvalid(prop): returns true if data is not invalid (i.e.: available validators return true).

  • expandParams(fieldSpec, params): takes a wild card field specification (like 'friends.*.name') and parameters (like [3]) to generate a field specifier (like friends.3.name).

  • focusedField(): returns the currently focused field.

  • focusedFieldUpdatedOnServer(prop): indicates whether field prop was updated on the server while the relevant field was in focus (and a updateOfFocusedFieldCallback callback was defined in options) and hence the field is out of sync

  • resetVMOnlyData: resets view-model only data to initial values (including those from the optional field _3w_additionalViewModelOnlyData of the data context)

Ancestor Data (and other possessions)

  • parentDataGet(p, levelsUp): returns property p from parent instance levelsUp levels up (default: 1)

  • parentDataGetAll(p, levelsUp): returns all data from parent instance levelsUp levels up (default: 1)

  • parentDataSet(p, v, levelsUp): sets property p on parent instance to v levelsUp levels up (default: 1)

  • parentDataGet_NR(p, levelsUp): (non-reactively) returns property p from parent instance levelsUp levels up (default: 1)

  • parentDataGetAll_NR(levelsUp): (non-reactively) returns all data from parent instance levelsUp levels up (default: 1)

  • getInheritedHelper(name): seeks out closest ancestor (self included) with helper having name name and returns it if available.

  • getInheritedEventHandler(name): seeks out closest ancestor (self included) with event handler having name name and returns it if available.

  • getInheritedPreProcessor(name): seeks out closest ancestor (self included) with pre-processor having name name and returns it if available.

Descendant Data

  • childDataGetId(childNameArray): returns id from descendant instance where childNameArray gives a sequential list of successive descendant names (alternatively a string in the special case of a direct child)

  • childDataSetId(id, childNameArray): sets id from descendant instance where childNameArray gives a sequential list of successive descendant names (alternatively a string in the special case of a direct child)

  • childDataGet(p, childNameArray): returns property p from descendant instance where childNameArray gives a sequential list of successive descendant names (alternatively a string in the special case of a direct child)

  • childDataGetAll(childNameArray): returns all data from descendant instance where childNameArray gives a sequential list of successive descendant names (alternatively a string in the special case of a direct child)

  • childDataSet(p, v, childNameArray): sets property p from descendant instance where childNameArray gives a sequential list of successive descendant names (alternatively a string in the special case of a direct child)

  • childDataGet_NR(p, childNameArray): (non-reactively) returns property p from descendant instance where childNameArray gives a sequential list of successive descendant names (alternatively a string in the special case of a direct child)

  • childDataGetAll_NR(childNameArray): (non-reactively) returns all data from descendant instance where childNameArray gives a sequential list of successive descendant names (alternatively a string in the special case of a direct child)

  • getAllDescendants_NR(levels): (non-reactively) returns all descendant instances and information on them as objects. For example...

[
    {
        id: "kiddy",
        instance: [Blaze.TemplateInstance],
        level: 1,
        templateType: "Template.DemoThreeWayChild",
    },
    {
        id: "grandkiddy",
        instance: [Blaze.TemplateInstance],
        level: 2,
        templateType: "Template.DemoThreeWayGrandChild",
    },
]

Sibling Data

  • siblingDataGet(p, siblingName): returns property p from sibling instance where siblingName gives the name of the relevant sibling

  • siblingDataGetAll(siblingName): returns all data from sibling instance where siblingName gives the name of the relevant sibling

  • siblingDataSet(p, v, siblingName): sets property p from sibling instance where siblingName gives the name of the relevant sibling

  • siblingDataGet_NR(p, siblingName): (non-reactively) returns property p from sibling instance where siblingName gives the name of the relevant sibling

  • siblingDataGetAll_NR(siblingName): (non-reactively) returns all data from sibling instance where siblingName gives the name of the relevant sibling

Generic Family Tree Access

  • getTemplateByPath(path): obtains the ThreeWay-linked template instance given the specified relative path (as an array), for example templateInstance._3w_.getTemplateByPath(['..', 'grand-child-2']) (use ".." to indicate going one level up)
  • callOnTemplateByPath(path, methodName, arg_1, arg_2, ..., arg_n): invokes the method with name methodName (with the relevant arguments) on the ThreeWay-linked template instance specified by the path path
  • applyOnTemplateByPath(path, methodName, args): invokes the method with name methodName (with the relevant arguments as an array args) on the ThreeWay-linked template instance specified by the path path

Additional Template Helpers

Pre-processor Pipelines

Presentation of data is facilitated by "pre-processors" which map values (display-only bindings) and may do DOM manipulation when needed (e.g.: with Semantic UI dropdowns). This feature allows for great flexibility in displaying data, enabling one to "easily" (and typically declaratively) translate data to display.

However, because of the impurity of side-effects is rather directly enabled, in principle, one could use pre-processor side-effects render a d3.js diagram responding to changes in data (but for simple charts there are easier ways to do things (e.g.: this and this).

Display (one-directional) bindings like html and visible (later class, style and attr) use pre-processing pipelines to generate items for display. Consider the following examples:

preProcessors: {
    mapToAgeDisplay: x => ageRanges[x],
    toUpperCase: x => x.toUpperCase(),
    alert: function(x) {
        alert(x);
        return x;
    },
    // This is something special to make the Semantic UI Dropdown work
    // More helpers will be written soon...
    updateSemanticUIDropdown: ThreeWay.preProcessors.updateSemanticUIDropdown
},

... a binding like <span data-bind="html: age|mapToAgeDisplay"></span> would, if in the view model age === '13_20' and ageRanges['13_20'] === '13 to 20' display the text "13 to 20".

... and a binding like <span data-bind="html: age|mapToAgeDisplay|alert|toUpperCase"></span> would, under the same circumstances, annoy the user with an alert with text "13 to 20" and then display the text "13 TO 20". (Please don't do something like that.)

Multi-way data-bindings such as value and checked use pre-processing pipelines to deal with DOM manipulation only (e.g.: Semantic UI dropdowns via ThreeWay.preProcessors.updateSemanticUIDropdown). Pipeline functions do not manipulate value.

Pre-processors have method signature function(value, elem, vmData, dataSourceInfomation) (function(v1, v2, ..., vn, elem, vmData, dataSourceInfomation) for the first pre-processor in a multi-variable binding) where value is the value in the view model, elem is the bound element, vmData is a dictionary containing all the data from the view model, and dataSourceInfomation contains information on the source of the data in the form:

{
    "type": 'field',
    "name": 'personal.otherArr.0.a',
    "fieldPath": "personal.otherArr.0.a",  // applicable if source is a field from database
    "match": "personal.otherArr.*.*",      // applicable if source is a field from database
    "params": ["0","a"]                    // applicable if source is a field from database
}

Example Use Case: Consider an input field with some validator. An invalid value might cause some validation error message to be set to be non-empty, and that change in view model data might trigger various forms of presentation. For example:

<div data-bind="html: tagsValidationErrorText; visible: tagsValidationErrorText|trueIfNonEmpty; style: {color: tagsValidationErrorText|trueIfNonEmpty|redIfTrue}"></div>

Pre-processors are called with this bound to template instance, and Template.instance() is also accessible. (Note: Be careful of lexically scoped arrow functions that overrides call/apply/bind.)

Pre-processors may be inherited.

Pure Processing Bindings

One can data-bind to a sub-object/array/the entire document and pass it through a pre-processor. At times, only side-effects are sought. For this purpose, the process binding was created. For example:

<div data-bind="process: vertices|plotDiagram"></div>

This gives a pre-processor plotDiagram the value of vertices and the div element on which the binding is defined. What follows might be that plotDiagram empties out the div (via jQuery's .empty()) and fills it up with a plot of the vertices. Pure side-effects with no annoying hackery.

Furthermore, for the process pre-processor, it will be possible to bind the entire document. Wherein all transformations from MiniMongo will be available.

<div data-bind="process: *|visualizeDocument"></div>

In addition, one may also bind to the full content of the view-model, wherein data is presented in a "flat" form.

<div data-bind="process: @|describeViewModelContent"></div>

There are some fundamental differences between binding to the entire view model (@) and binding to the associated document (*).

Binding to the View Model (@) Binding to the Associated Document (*)
All view model data Just data in the associated document
Flat data representation An object with "depth" and transformations, if any
Updated on view model update Updated when MiniMongo is updated (from the server; hence latency)

Pre-processor Pipelines in Blaze

Pre-processors may also be used with the _3w_display blaze-helper. For example,

<!-- Maps an array of e-mail preference codes to human readable
names and wraps with STRONG tags if there are more than one-->
{{{_3w_display 'emailPrefs|mapToEmailPrefs|boldIfMoreThanOne'}}}

<!-- Spaces out a comma separated list of tags -->
{{_3w_display 'tags|tagsTextDisplay'}}

<!-- Maps (3) numerical intensity values to a RGB string -->
{{_3w_display 'colR#colG#colB|makeRGB'}}

This use of Handlebars expressions is an alternative to display bindings in tags. While the two methods are very similar and their use would largely be a matter of preference, it should be noted that:

  • Handlebars expressions cannot be styled as straightforwardly as adding style and class bindings
  • Ownership of Handlebars expressions to a template (or correspondence with a template instance) is always clear

Data Validation

Data validators are defined as follows:

// Validators under validatorsVM consider view-model data
// Useful for making sure that transformations to server values do not fail
// Validators under validatorsServer consider transformed data (for the server)
//
// validators have method signature:
//   function(value, matchInformation, vmData)
// success/failure call backs have signature:
//   function(value, matchInformation, vmData)
// all are called with the template instance as context
//
// matchInformation takes the form:
//   {
//      "fieldPath": "personal.otherArr.0.a",
//      "match": "personal.otherArr.*.*",
//      "params": ["0","a"]
//   }
validatorsVM: {
    // tags seems to be a decent candidate for one here
    // but see below
},
validatorsServer: {
    tags: {
        validator: function(value, matchInformation, vmData) {
            // tags must begin with "tag"
            return value.filter(x => x.substr(0, 3).toLowerCase() !== 'tag').length === 0;
        },
        success: function(value, matchInformation, vmData) {
            var instance = this;
            instance._3w_.set('tagsValidationErrorText', '');
        },
        failure: function(value, matchInformation, vmData) {
            var instance = this;
            instance._3w_.set('tagsValidationErrorText', 'Each tag should begin with \"tag\".');
        },
    },
},
validateRepeats: false,  // (default: false)

In options one can set the value of validateRepeats to determines whether successive identical values are validated. Deals with the issue of validation firing on change and then for the server updates.

Recall that in the previous section, the following example was described:

<div data-bind="html: tagsValidationErrorText; visible: tagsValidationErrorText|trueIfNonEmpty; style: {color: tagsValidationErrorText|trueIfNonEmpty|redIfTrue}"></div>

The validation flow is as follows:

1. a change is made in the view which propagates to the view model
2. validation starts
3. view-model level validation using data in the view model and success/failure call-backs fire
4. if the view-model level check does not fail, server validation is run and success/failure call-backs fire
5. the overall result is returned

If the change is a candidate for a database update (e.g.: the value is not the same as the previous known value in the database), then validity is used as a requirement for an update. (As is common sense.)

The reasons for having two separate checks is to deal with include the possibility that a user may want to guard against a transformation being done on invalid data, and that checks may be more convenient in one form or another. (Not to mention silly stuff relating to wild card matching shenanigans.)

"Family Access": Ancestor and Descendant Data

ThreeWay-linked template instances can be connected in parent-child relationships. Data can be accessed across template boundaries in the following ways (and more):

  • ancestor (any number of levels up)
  • descendant (any number of levels down; requires knowledge of the relevant template instance identifiers of successive descendants passed into each template as _3w_name in the data context)
  • sibling (requires knowledge of the relevant template instance identifier passed into template as _3w_name in data context)

In principle, as long at the template instance of the "highest-level" ancestor can be acquired, then any connected instance may be straight-forwardly accessed. "Sibling" data-access is syntactic sugar for this.

See Instance Methods for more information.

Data Migration on Hot Code Push

ThreeWay migrates data between reloads triggered by hot code push (or the reload package). To do this properly, instances should not have instance ids that collide.

There are a few sufficient conditions for non-collision such as the following all being true, all ThreeWay-enabled template instances are in a common template tree (there is a ThreeWay-enabled such that all others are descendants. This may be achieved trivially by calling:

ThreeWay.prepare(Template.BigPapaRootTemplateLayout, {});

... on the root template, which may be a layout that swaps components in and out with Template.dynamic. (Do look at Using Dynamic Data Binding with Multiple ThreeWay instances to ensure that this is done properly.)

Recall that one may customize ids manually by passing _3w_name into the data context of each template instance. For instances with the same name (instance id), that get created and destroyed dynamically, only the first instance will get the data from the previous migration.

One may pass _3w_ignoreReloadData (boolean) into the data context of each template instance to indicate whether to ignore migrated data (true to ignore).

Debug

ThreeWay.DEBUG_MODE.setOn() - Turns debug mode on

ThreeWay.DEBUG_MODE.setOff() - Turns debug mode off

ThreeWay.DEBUG_MODE.isOn - Returns whether debug mode is on

ThreeWay.DEBUG_MODE.selectAll() - Show all debug messages (initially none)

ThreeWay.DEBUG_MODE.selectNone() - Reset selection of debug message classes to none

ThreeWay.DEBUG_MODE.select(aspect) - More granular control of debug messages, debug messages fall into the following classes:

  • 'parse'
  • 'bind'
  • 'tracker'
  • 'new-id'
  • 'observer'
  • 'db'
  • 'default-values'
  • 'validation'
  • 'data-mirror'
  • 'vm-only'
  • 'reload'
  • 'bindings'
  • 'value'
  • 'checked'
  • 'focus'
  • 'html-text'
  • 'visible-and-disabled'
  • 'style'
  • 'attr'
  • 'class'
  • 'event'
  • 'process'

The above is obtainable from ThreeWay.DEBUG_MODE.MESSAGE_HEADINGS.

ThreeWay.utils.allInstances: a description of all instances an array of instances as follows

{
    instanceId: ...,
    dataId: ...,
    data: ...,
    document: ...,
    template: instance,
    templateType: instance.view.name,
}

ThreeWay.utils.allInstancesByTemplateType: ThreeWay.utils.allInstances grouped by template type (name)

Extras

Extra/Default Pre-processors

Extra processors may be accessed via the ThreeWay.preProcessors namespace (e.g.: ThreeWay.preProcessors.updateSemanticUIDropdown). All "extra" pre-processors will be included by default if no pre-processor with the same name is defined.

  • truthy: returns a boolean which reflects the "truthiness" of the value
  • not: returns a boolean which reflects the "falsiness" of the value
  • isNonEmptyString: returns the described true/false value
  • isNonEmptyArray: returns the described true/false value
  • toUpperCase: transforms the value to a string (undefined to '') and returns it in upper case
  • toLowerCase: transforms the value to a string (undefined to '') and returns it in lower case
  • updateSemanticUIDropdown: does the necessary DOM manipulation that enables the use of Semantic UI dropdowns; (Previously, this used .dropdown("set exactly", ...) that would trigger value changes and unnecessary updates.)
  • undefinedToEmptyStringFilter: maps undefined's to empty strings and passes other values

Extra Pre-Processor Generators

Similar to the above, but these are generators for pre-processors: they each accept one or more arguments and return a pre-processor. They may be accessed via the ThreeWay.preProcessorGenerators namespace (e.g.: ThreeWay.preProcessorGenerators.undefinedFilterGenerator).

  • undefinedFilterGenerator(defaultValue): a function that returns a function that maps undefined's to defaultValue and passes other values
  • makeMap(map, defaultValue): a function that maps k to map[k] (and returns defaultValue if map does not have property k)

Extra Transformations

Built-in transformations, for mapping from server to view model and back, may be accessed via the ThreeWay.transformations namespace. (e.g.: ThreeWay.transformations.dateToString or ThreeWay.transformations.dateFromString) Generally, the naming convention is understood to be "server-side value" on left and "view model value" on right.

  • arrayFromCommaDelimitedString: maps a comma delimited string to an array of a separated values (empty string maps to empty array)
  • arrayToCommaDelimitedString: maps an array to a comma delimited string

The following are available but unnecessary because one can use input elements of type=date, type=month, type=datetime-local and ThreeWay will make things work with Date-typed view-model values.

  • dateFromString: maps a string of the form "YYYY-MM-DD" to a Date
  • dateToString: maps a Date to a string of the form "YYYY-MM-DD"
  • datetimeFromString: maps a string of the form "YYYY-MM-DDThh:mm" to a Date
  • datetimeToString: maps a Date to a string of the form "YYYY-MM-DDThh:mm"
  • monthFromString: maps a string of the form "YYYY-MM" to a Date
  • monthToString: maps a Date to a string of the form "YYYY-MM"

Extra Transformation Generators

Similar to the above, but these are generators for transformations that take one or more parameters and return a transformation. They may be accessed via the ThreeWay.transformationGenerators namespace (e.g.: ThreeWay.transformationGenerators.booleanFromArray).

  • arrayFromDelimitedString(delimiter): generates transformations like arrayFromCommaDelimitedString above
  • arrayToDelimitedString(delimiter): generates transformations like arrayToCommaDelimitedString above
  • arrayFromIdKeyDictionary(idField): generates a transform function that maps dictionaries (objects) like:
[
   {_id: 'abc', f1: 'a', f2: 1}
   {_id: 'def', f1: 'b', f2: 2}
   {_id: 'ghi', f1: 'c', f2: 3}
]

... to ... { 'abc': {_id: 'abc', f1: 'a', f2: 1} 'def': {_id: 'def', f1: 'b', f2: 2} 'ghi': {_id: 'ghi', f1: 'c', f2: 3} } ... (here idField is _id).

  • arrayToIdKeyDictionary(idField): generates the reverse transformation of arrayFromIdKeyDictionary(idField).
  • booleanFromArray(trueIndicator): generates a transform function that returns true if the input is an array with a single element taking value trueIndicator) and false otherwise.
  • booleanToArray(trueIndicator): generates a transform function that evaluates the "truthiness" of the input and returns [trueIndicator] if true and [] otherwise.
  • numberFromString(defaultValue): generates a function that maps a string to a number with a default value in the event of a casting error.

Extra Event Generators

These are generators for event handlers via specialization of existing DOM events. They take an event handler and wrap a filter around it, returning a new event handler that is called on the parent event, but checks to see whether the user specified handler should be called. These generators may be accessed via the ThreeWay.eventGenerators namespace (e.g.: ThreeWay.eventGenerators.returnKeyHandlerGenerator).

Generally, these generators take, as first argument, an event handler with signature function(event, template, vmData) (see Event Bindings).

  • keypressHandlerGenerator(handler, keyCodes, specialKeys): calls event handler if the pressed key is in the array keyCodes and the special keys (SHIFT, CTRL, ALT) pressed match those in specialKeys (bind the result to keydown, keyup and similar handlers)

  • keypressHandlerGeneratorFromChars(handler, chars, specialKeys): calls event handler if the pressed key is in the string char and the special keys (SHIFT, CTRL, ALT) pressed match those in specialKeys (bind the result to keydown, keyup and similar handlers)

  • returnKeyHandlerGenerator(handler, specialKeys): calls event handler if the pressed key was RETURN and the special keys (SHIFT, CTRL, ALT) pressed match those in specialKeys (bind the result to keydown, keyup and similar handlers)

For example:

var ctrlReturnKey = ThreeWay.eventGenerators.returnKeyHandlerGenerator(() => console.info('[CTRL-ENTER handler]'), {
    ctrlKey: true,
    altKey: false,
    shiftKey: false,
});

Extra Events

The following trigger when the relevant key is pressed in a keyup:

  • backspaceKey
  • tabKey
  • returnKey
  • escapeKey
  • pageUpKey
  • pageDownKey
  • endKey
  • homeKey
  • leftArrowKey
  • upArrowKey
  • rightArrowKey
  • downArrowKey
  • insertKey
  • deleteKey
  • f1Key
  • f2Key
  • f3Key
  • f4Key
  • f5Key
  • f6Key
  • f7Key
  • f8Key
  • f9Key
  • f10Key
  • f11Key
  • f12Key

For finer grained control, the above may be prefixed with keydown_ or keyup_ (e.g.: keydown_backspaceKey and keyup_tabKey).

Notes

View Model to Database Binding

Currently, binding to database fields only occurs if the required field is already in the database. So fields bound on in the DOM do not drive the binding. However, sometimes records in the database have missing values that should be filled in.

As of v0.1.17, a compromise solution was included in the form of the injectDefaultValues option, where missing fields are filled in with default values.

Database Updates and Observer Callbacks

Pre-v0.1.2, there was the issue of a race condition when multiple fields with the same top level field (e.g.: particulars.name and particulars.hobbies.4.hobbyId) would be updated tens of milliseconds apart. The observer callbacks would send entire top level sub-documents even if a single primitive value deep within was updated.

For a time, an attempt was made to address the problem by (i) queueing via promise chains of Meteor methods grouped by top-level fields plus a delay before next Meteor method being triggered, and (ii) field specific updaters (with individual throttling/debouncing) to avoid inadvertent skipping of updates from sub-fields (due to debounce/throttle effects on a method being used to update multiple sub-fields).

Pre-v0.1.14, the above race condition was still not fully solved. The "comprehensive solution" was to store snapshots of entire sub-documents with the expectation that stuff would get sent back and data sent back from the server matching existing values (that were not too old) could be "ignored".

As of v0.1.20, promise chains/bins were no longer used.

Dynamic Data Binding

Pre-v0.1.9, dynamic rebinding was incomplete and carried out by polling the DOM. As of v0.1.9, Mutation Observers have been used to deal with things in an event-driven manner.

The mixing of dynamic data-binding and the possibility of multiple ThreeWay instances poses some challenges with regards to the question of which ThreeWay instance a new DOM element should be data bound with. See the discussion in Using Dynamic Data Binding with Multiple ThreeWay instances for more information.

Pre-v0.1.20, late creation of child templates posed a problem. They were outside of the normal order of the template life cycle:

  • Parent created
  • Child created
  • Grandchild created
  • Grandchild rendered
  • Child rendered
  • Parent rendered ... which enabled appropriate creation of MutationObserver's in onRendered hooks, so the most junior nodes (in order of creation or "age") would get first bite at new nodes, which makes sense by default. (See the discussion in Using Dynamic Data Binding with Multiple ThreeWay instances for more information on how to create nodes in a child template but have them bound to a parent.)

The late creation problem was solved by introducing something of a "bind auction" for added and modified nodes. The bid value for each template instance involved being its level of depth in its ThreeWay family tree. Ties are broken arbitrarily (actually, on a first created first served basis).

Why Not Group Debounced Updates?

(Debouncing)[http://underscorejs.org/#debounce] Meteor methods for updates ensures that updates are sent after a "pause in editing", such as with a text field. Due to the fact that cursors send entire sub-documents when changes are made, and to reduce the number of Meteor calls made, There is a sense in which one might combine updates into single debounced calls (e.g.: {$set: {field1: value1, field2: value2}} instead of {$set: {field1: value1}} and {$set: {field2: value2}}).

However, this is when check and authentication causes a bit of a problem. The user should not be expected to write a general method that does schema and authentication checks. In principle, given a schema, appropriate methods can be generated, but ThreeWay is not the place for automatic method generation (based on schemas).

To Do

  • Consider how to perform binding to web components (possibly via a call-back interface)
  • Consider enabling "hard-links" between pieces of data (possibly via a SSOT)
  • Consider events executed at most n times
  • Consider events "limited"/"filtered" by a truthy view model field
  • Reconsider group debounced updates (given that auto-generation of a general updater in convexset:collection-tools is done)

About

Flexible three-way data-binding for Meteor (db-VM-V; also, Blaze-friendly)

Resources

Stars

Watchers

Forks

Packages

No packages published