Binding Behaviours: Can they be applied from the ViewModel rather than the View

I have a custom BindingBehaviour we’ll call MySpecialBindingBehaviour.

I know that I can apply this from the view template as follows:
<input value.bind="myVal & mySpecial" />

However, I would like to apply the behaviour to a property from the ViewModel itself. The end goal is to make a property decorator that would set up the binding with my behaviour.

Something like:

class SomeClass {

    @bindable
    @applyMySpecialBindingBehaviour
    myVal = "Testing"

}

Or

class SomeClass {

    @bindable({
        bindingMode: bindingMode.twoWay, 
        bindingBehaviour: MySpecialBindingBehaviour,
    })
    myVal = "Testing"

}

I have looked through the source for @bindable to try and work out if I could pass in my behaviour some how, or implement my own decorator based on bindable, but no luck.

If anyone could help with any of the following, it would be greatly appreciated:

  • Is there an existing approach for setting behaviours from the ViewModel.
  • Could point me to the place in the source code where binding behaviours are applied during property binding, so I could do some further digging.

Regards,
Grant

1 Like

At the moment, applying a BB to a @bindable property isn’t supported. Though can you clarify how you want to apply the binding behavior?

For example, we have:

class SomeClass {

    @bindable({
        bindingMode: bindingMode.twoWay, 
        bindingBehaviour: MySpecialBindingBehaviour,
    })
    myVal = "Testing"

}

Usages:
And inside its own template:

<template>
  <input value.bind="myVal" />
</template>

And there are two places it’s used like this:

<some-class my-val.bind=""><some-class>

Where should, and shouldn’t it apply the binding behavior?

1 Like

Good question. I would say it should apply to both examples? As a bit more background to what my behaviour does:

It allows me to set to set up two different change handlers for a two-way binding depending on whether it is being set from the source or the target.

class SomeClass {

   myVal = "testing"

   myValChanged(newVal, oldVal) {
       // Regular handler, called in both directions
   }

   myValChangedFromSource(newVal, oldVal) {

   }

   myValChangedFromTarget(newVal, oldVal) {

   }

}

If this was set up for a form element in the view:

  • myValChangedFromSource would be triggered when we set the value from within the ViewModel (ie this.myVal = "something")
  • myValChangedFromTarget would get called when the form element is updated (ie, the user types something in the input).

I haven’t tested it yet in the latter situation you described, but I would assume if SomeClass was a child element with an external binding, then the source and target roles might be switched? Not sure though. Need to confirm.

I don’t know if this might be achievable in some other fashion (other than a binding behaviour), however, for various reasons, we are trying to avoid specifying any such logic in the View and keep it confined to the ViewModel. With that in mind:

  • I would like to define the behaviour using a decorator.
  • Using a one-way bind combined with a change delegate is not a desirable option.

After a bit of further reading I have worked out:

  • BindingBehaviours need to be registered as resources via the ViewFactory
  • The sourceExpression from the binding gets parsed and there is a specific part that detects BindingBehaviours.

What I’m unclear on is whether:
a) The sourceExpression gets evaluated every time a value is set on the binding.
b) The sourceExpression is evaluated once, when the binding is set up (and then when the value is set, binding behaviours that were instantiated are found and applied using a lookup function).

If it’s the latter, then it would seem there is a feasible path to manually registering the behaviour.

Just as a note, I’m willing to get down and dirty with Aurelia internals to find a solution. It doesn’t have to be via publicly documented APIs.

1 Like

After many hours of digging and reading, I have a proof of concept for a decorator that allows you to specify a BindingBehaviour from the ViewModel. Still quite a lot of testing to do before it’s useable. Posting here in case anyone has any comments / suggestions. I may tidy it up and publish it as a package if I can get it working well enough.

Usage
Take the following bindable property:

@bindable({ bindingMode: bindingMode.twoWay })
test = "";

For pre-registered behaviours (such as throttle and debounce) you can pass in the name as a string (followed by any arguments) like so:

@bindable({ bindingMode: bindingMode.twoWay })
@useBindingBehavior("throttle", 3000)
test = "";

Where you have your own custom behaviors, you can pass in a class (followed by any arguments) like so:

@bindable({ bindingMode: bindingMode.twoWay })
@useBindingBehavior(MyCustomBindingBehavior, "arg1", 2)
test = "";

(Note: you do not have to <require> the behavior in the view as it is automatically registered).

You can also stack the decorator to use multiple behaviors:

@bindable({ bindingMode: bindingMode.twoWay })
@useBindingBehavior("throttle", 3000)
@useBindingBehavior(MyCustomBindingBehavior, "arg1", 2)
test = "";

 
 

Code
Below is the POC code (needs a lot of cleaning up). If you are wondering about Some() and None(). they are type guards used for a custom option type Maybe<T>. Just read them as null / undefined checks.

import { Binding, BindingBehavior, camelCase, Container, Expression, LookupFunctions, Scope, ViewResources } from "aurelia-framework";
import { None, Some } from './lib/utils/Maybe';

// Gets the camelCased name for the behavior
const resolveName = (nameOrClass: any) => {

    const name = (typeof nameOrClass === "string") ?
        nameOrClass : nameOrClass.name;

    if (name.endsWith('BindingBehavior'))
        return camelCase(name.substring(0, name.length - 15));

    return camelCase(name);
    
}

// Register the binding behavior resource
const registerBindingBehavior = (nameOrClass: any) => {

    const viewResources = Container.instance
        .get(ViewResources);

    const name = resolveName(nameOrClass);

    const behavior = viewResources
        .getBindingBehavior(name);

    if (Some(behavior))
        return;

    if (typeof nameOrClass === "string")
        throw `Could not find behavior named ${name}`;

    const instance = Container.instance
        .get(nameOrClass as any);

    if (None(instance))
        throw `Could not get instance of ${name}`;

    viewResources.registerBindingBehavior(
        name, instance);

}

// Create a wrapped binding function to replace
// the one on the behaviour class
const getPatchedBind = (...args: any[]) => {

    return function (
        this: any, binding: Binding, scope: Scope, lookupFunctions: LookupFunctions) {

        // Everything from here until the last line is 
        // copied from source.

        if (this.expression.expression && this.expression.bind) {
            this.expression.bind(binding, scope, lookupFunctions);
        }

        let behavior = lookupFunctions.bindingBehaviors(this.name);
        if (!behavior) {
            throw new Error(`No BindingBehavior named "${this.name}" was found!`);
        }
        let behaviorKey = `behavior-${this.name}`;
        if ((binding as any)[behaviorKey]) {
            throw new Error(`A binding behavior named "${this.name}" has already been applied to "${this.expression}"`);
        }

        (binding as any)[behaviorKey] = behavior;

        // This is the line we replace, so we can pass in
        // our resolved arguments. Normally, this line would
        // get the list of arguments as Expressions, then eval
        // them, then pass them in. We already have the resolved
        // args, so we just pass them in directly.
        behavior.bind.apply(behavior, [binding, scope, ...args]);
    }

}

// We need to traverse the list of bindings and
// find ones that match our property name. This
// is a basic (and probably not very robust) way
// to get it from the source expression. It assumes 
// no other binding behaviors or value converters.
const resolvePropertyName = (expression: any): any => {

    // Ensure no nested properties for now.
    if (expression.isAssignable === true && expression.ancestor === 0) {
        return expression.name;
    }

    return null;

}

// Our decorator creator
export const useBindingBehavior = (nameOrClass: any, ...args: any[]) => {

    if (None(nameOrClass))
        throw "No bindingbehaviour or name provided.";

    const name = resolveName(nameOrClass);

    // The decorator
    return (target: any, key: any) => {

        const oldActivate = target["activate"];

        // Wrap the old activate function with ours.
        target["activate"] = (...activateArgs: any[]) => {

            const viewResources = Container.instance.get(ViewResources);

            // Register view engine hooks.
            viewResources.registerViewEngineHooks({
                beforeCreate: (viewFactory: any) => {

                    // Register our binding behavior in the view
                    registerBindingBehavior(nameOrClass);

                },
                beforeBind: (context: any) => {

                    // Todo, pick the correct binding(s)
                    context.bindings.forEach((binding: any) => {

                        const resolvedPropertyName =
                            resolvePropertyName(
                                binding.sourceExpression)

                        // Not a match
                        if (resolvedPropertyName !== key)
                            return;

                        const newExpression = new BindingBehavior(
                            binding.sourceExpression as Expression,
                            name,
                            [],
                        ) as any;

                        // Swap the original bind funtion for our
                        // patched one.
                        newExpression.bind = getPatchedBind(...args);

                        // Replace the original source expression
                        binding.sourceExpression = newExpression;

                    })

                },
            });

            // Call the original activate function as well.
            if (oldActivate) {
                oldActivate(...activateArgs);
            }

        }

    }

}

Edit: Fixed mistake with args being passed to oldActivate.

2 Likes

Just noticed a mistake. The old activate function is being called with the wrong args array:

target["activate"] = () => {
oldActivate(...args);

Should be

target["activate"] = (...activateArgs: any[]) => {
oldActivate(...activateArgs);

@bigopon Since you are very active here, I shall ask you: is it preferable to post corrections by editing the original reply or posting a new one?

2 Likes

People are likely going to copy the first sample they see and it won’t work so perhaps not even bothering looking further. I’d recommend fixing the post and adding a edit note

Edit:
Good job with your POC

3 Likes

I have a pending post for your Q as well, but not sure when im able to finish it. Was planning to continue this weekend. Glad that you got something working.

For where to post, I’d say we post a new one, and then copy paste minimal stuff to the old one, with a link. Best of both world, maybe?

3 Likes

@granth is the decorator working well?

@MaximBalaganskiy Hi. Sorry for the slow reply. Since making the post, I have not done any additional work or testing on this. I’ll be revisiting it this week and I’ll post any updates / notes here.