vecnatechnologies / backbone-torso

A holistic approach to Backbone applications
http://vecnatechnologies.github.io/backbone-torso
Apache License 2.0
17 stars 20 forks source link

Shared code / behaviors enhancement #240

Closed kentmw closed 7 years ago

kentmw commented 8 years ago

How do you share behaviors between two Torso views? For example:

var EditInsurancePerspective = TorsoView.extend({
  template: '<div inject="modal" />',

  events: {
    'click .back-link': '_openConfirmationModal'
  },

  initialize: function() {
    this._confirmationModal = new ConfirmationModal({
      text: 'Confirm exit Insurance edit'
    });
    this.set('confirmModalIsOpen', false);
    this.listenTo(this.viewState, 'change:confirmModalIsOpen', this.render);
  },

  attachTrackedViews: function() {
    if (this.get('confirmModalIsOpen')) {
      this.attachView('modal', this._confirmationModal);
    }
  },

  _openConfirmationModal: function() {
    this.set('confirmModalIsOpen', true);
  }
});

var EditDemographicsPerspective = TorsoView.extend({
  template: '<div inject="modal" />',

  events: {
    'click .back-link': '_openConfirmationModal'
  },

  initialize: function() {
    this._confirmationModal = new ConfirmationModal({
      text: 'Confirm exit Demographics edit'
    });
    this.set('confirmModalIsOpen', false);
    this.listenTo(this.viewState, 'change:confirmModalIsOpen', this.render);
  },

  attachTrackedViews: function() {
    if (this.get('confirmModalIsOpen')) {
      this.attachView('modal', this._confirmationModal);
    }
  },

  _openConfirmationModal: function() {
    this.set('confirmModalIsOpen', true);
  }
});

We want to share multiple behaviors across many views that aren't part of the same inheritance hierarchy.

One implementation:

var Behaviors = {};

var eventMap = {
  attached: '_attach',
  detached:  '_detach',
  activated: '_activate',
  deactivated: '_deactivate',
  disposed: '_dispose',
  attachedTrackedViews: '_attachTrackedViews',
  'render:begin': '_prerender', 
  'render:complete': '_postrender',
  updatedDOM: '_updateDOM',
  initialize:begin:  '_preinitialize',
  'initialize:complete': '_postinitialize'
};

var Torso.Behavior = Torso.Cell.extend({
  constructor: function(args) {
    this.view = args.view;
    this.config =  _.defaults(args.configOverrides, this.defaults);
    this.setupCallbacks();
    this.bindViewEvents();
  },

  action: _.noop,

  setupCallbacks: function() {
    var actionCallback = _.bind(this.action, this);
    if (config.events) {
      // wrap view.events with config.events that bind to actionCallback.
    }
    if (config.listeners) {
      // handle arrays/strings, etc.
      _.each(config.listeners, function(listener) {
        this.view.on(listener, actionCallback);
      }, this);
    }
  },

  bindViewEvents: function() {
    _.each(eventMap, function(callback, event) {
     this.listenTo(this.view, event, this[callback]);
    }, this);
  }
});

_.each(eventMap, function(callback) {
  Torso.Behavior.prototype[callback] = _.noop;
});

var Torso.View = Backbone.View.extend({
  ...
  initialize: function() {
    ...
    this.behaviors = {};
    _.each(this.behaviors, function(behaviorConfig, behaviorName) {
      var Behavior = Behaviors[behaviorName];
      behaviors[behaviorName] = new Behavior({
        view: this,
        configOverrides: behaviorConfig
      });
    });
  },

  perform: function(behaviorName) {
    this.behaviors[behaviorName].action();
  },
  ...
});

var AlertBehavior = Torso.Behavior.extend({

  defaults: {
    text: 'hey!',
    listeners: 'alert',
    events: 'click .alert'
  },

  action: function() {
    var text = this.config.text;
    this.doTheAlert(text);
  },

  doTheAlert: function(text) {
    alert(text);
  },

  _dispose: function() {
    this.view.alertBehaviorSawViewWasDisposed = true;
  }
}))();

Behaviors.alert = AlertBehavior;

/////////////////////////////////

var myView = new (Torso.View.extend({
   behaviors: {
     // overrides for behaviors config
     'alert': { 
       text: 'blah!', 
       listeners: 'alert otherEvent'
     }
   }
}))();

// manually trigger behavior, trigger off of event, or dom event.
myView.perform('alert');
myView.trigger('alert');
myView.$el('.alert').click();

myView.doTheAlert // undefined.
mandragorn commented 8 years ago

please add summary of the what and why of this. Thanks :)

kentmw commented 8 years ago

Okay, so we've been using Torso for a while now and we've been building even larger codebases. Shared behaviors (as in View A needs to trigger an event "foo" if a button is pushed. So does View B, and View C, etc.) have had us rewriting a lot of code. Can we make these shared behaviors also shared code without running into issues of namespace collisions, state collisions, multi-inheritance limitations, etc.

Deena, @Earthstar, wrote up a good requirement set:

Basically, I want an independent entity representing a "behavior" to be added plug-n-play to any view without worry. No dependencies should be required of the view's API (except maybe injection sites DOM), no complicated wrapping of methods, and no additions to the view's API. I just want it to do what the behavior is offering, with configurations of course.

My idea right now is to create a Behavior Cell that defines it's own state and logic. It can hook into view events and view DOM events. It also can fire callbacks when the view triggers lifecycle events.

At the end of the day, I want to add a behaviors field to a view and have it add a behavior without doing anything else.

I've also considered have behaviors depend on other behaviors. Example: Tour Behavior depends on Tooltip Behavior where a tour opens and closes tooltips in an order.

Earthstar commented 8 years ago

Thinking about how we could implement the Behavior api such that you don't have to register behaviors globally. Since you want Behaviors to be instantiated per-instance, you can't instantiate the behaviors as class-level properties.

// Can't do this
var EditInsurancePerspectiveWithBehavior = TorsoView.extend({
  behaviors: [new ConfirmModalBehavior({message: 'Confirm exit Insurance edit'})]
});

Since Torso seems to like configuring options using fields, how about:

var ConfirmModalBehavior = require('./ConfirmModalBehavior');

var EditInsurancePerspectiveWithBehavior = TorsoView.extend({
  behaviors: [
    {
      class: ConfirmModalBehavior,
      options: {
        message: 'Confirm exit Insurance edit'
      }
    }
  ]
});

You could also make behaviors a function that returns a list of behaviors, which offers more flexibility, but I don't think we need flexibility in construction.

var EditInsurancePerspectiveWithBehavior = TorsoView.extend({
  behaviors: function() {
    return [new ConfirmModalBehavior({message: 'Confirm exit Insurance edit'})];
  }
});
Earthstar commented 8 years ago

Regarding Behavior -> View communication, it seems reasonable that a Behavior would have a reference to its containing view, which means you can trigger events, etc.

// Behavior communicating with view
var SomeBehavior = Torso.Behavior.extend({
  initialize: function(options) {
    this.view.trigger('event');
    // possible, but discouraged
    this.view.someMethod();
  }
});

Views can communicate with its behaviors by firing events (on itself?)

// View communicating with Behavior
var SomeView = Torso.View.extend({
  // has SomeBehavior

  _onClickLink: function() {
    this.trigger('event');
  }
});

var SomeBehavior = Torso.Behavior.extend({
  initialize: function(options) {
    this.listenTo(this.view, 'event', this.someMethod);
  }
});
kentmw commented 8 years ago

I think the behavior should have a reference to the view and you're example is spot on.

Events can be a way that views communicate with behaviors, but I also think there should be a way to grab that behavior if need be. That's why I liked the aliases of the behaviors. One method: view.getBehavior('alias') to get any behavior.

I like very much requiring the behaviors you want to stick into a view, but I feel the way you wrote it out is clunky. I like the alias way much better... not sure how to resolve that.

Earthstar commented 8 years ago

How about this as the definition of a behavior in a view?

var ConfirmModalBehavior = require('./ConfirmModalBehavior');

// in a view
var EditInsurancePerspectiveWithBehavior = TorsoView.extend({
  behaviors: {
    aliasOfConfirmModalBehavior: {
      class: ConfirmModalBehavior,
      options: {
        message: 'Confirm exit Insurance edit'
      }
    }
  }
});
kentmw commented 8 years ago

How about one step further to make it easier to write?

var EditInsurancePerspectiveWithBehavior = TorsoView.extend({
  behaviors: {
    confirmModal: {
      behavior: ConfirmModalBehavior,
      message: 'Confirm exit Insurance edit'
    }
  }
});
Earthstar commented 8 years ago

So anything that isn't the field "behavior" is passed in as an option?

kentmw commented 8 years ago

Do you think there will be a need for instance-level properties passed in? If so, do you think there's a need to add a this.configureBehavior() that can be called from the initialize method? Or should some of these options just be a function that can be invoked instead?

Earthstar commented 8 years ago

Well, if you leave "options" in the object, you can make "options" either an object or a function that returns an object. I'm not sure I want to implement instance-level properties unless we're sure we need the functionality.

Earthstar commented 8 years ago

First pass of Behavior implementation:

var EditInsurancePerspectiveWithBehavior = TorsoView.extend({
  behaviors: {
    confirmModal: {
      behavior: ConfirmModalBehavior,
      message: 'Confirm exit Insurance edit'
    }
  }
});