Search code examples
javascriptangularjsreactive-programmingrxjsfrp

Can I use RxJS to do inter-panel communication?


Note: I mentioned RxJS but any reactive library can do (Bacon, Kefir, Most, etc.). My context is AngularJS but the solution is probably independent (more or less).

My problem / task: we have an AngularJS application where we want to have side panels (and a central one), each side panel might have sub-panels that can be added, removed, etc.

These panels must communicate between them: not only parent-child exchanges, but also any panel to any panel (side / sub / central...).

I feel that the classical Angular way (event bus: $emit / $broadcast / $on) is rather inadequate here. Even for simple parent / child communication, I had issues, when the parent fires an event on startup, but the child isn't listening yet. Solved that with a $timeout, but that's brittle. Beside, to make two children to communicate, they send to the parent which transmits, which is clumsy.

I see this problem as an opportunity to introduce reactive programming in the project (in very early stage, won't be disruptive here), but if I have read a lot on the topic, I have little experience so far.

Hence my question: is there a clean way to manage this with FRP?

I am thinking of setting up a service (thus a singleton) which would listen to new panels, broadcast observables, accept observers, etc. But I am not too sure how to do this.

Instead of reinventing the wheel, I prefer to ask if this problem has been already resolved, without too much coupling, without being inflexible, etc.

Note: if a nice solution doesn't use FRP, that's fine too! :-)

Thanks.


Solution

  • Thanks to @xgrommx and @user3743222's comments, and to the good RxJS book, I was able to reach my goal.

    My experiment playground is at http://plnkr.co/edit/sGx4HH?p=preview

    The communication center service's body is (stripped down):

    var service = {};
    
    service.channels = {};
    
    /**
     * Creates a new channel with given behavior (options) and returns it.
     * If the channel already exists, just returns it.
     */
    service.createChannel = function(name, behavior)
    {
      checkName(name);
      if (_.isObject(service.channels[name]))
        return service.channels[name];
    
      behavior = behavior || {};
      _.defaults(behavior, { persistent: false });
    
      if (behavior.persistent)
      {
        _.defaults(behavior, { bufferSize: null /* unlimited */, windowSize: 5000 /* 5 s */ });
        service.channels[name] = new Rx.ReplaySubject(behavior.bufferSize, behavior.windowSize);
      }
      else
      {
        service.channels[name] = new Rx.Subject();
      }
      return service.channels[name];
    };
    
    /**
     * Returns the channel at given name, undefined if not existing.
     */
    service.getChannel = function(name)
    {
      checkName(name);
      return service.channels[name];
    };
    
    /**
     * Destroys an existing channel.
     */
    service.destroyChannel = function(name)
    {
      checkName(name);
      if (!_.isObject(service.channels[name]))
        return;
    
      service.channels[name].dispose();
      service.channels[name] = undefined;
    };
    
    /**
     * Emits an event with a value.
     */
    service.emit = function(name, value)
    {
      checkName(name);
      if (!_.isObject(service.channels[name]))
        return;
    
      service.channels[name].onNext(value);
    };
    
    
    function checkName(name)
    {
      if (!_.isString(name))
        throw Error('Name of channel must be a string.');
    }
    
    return service;
    

    I use it as follows:

    angular.module('proofOfConceptApp', [ 'rx' ])
    .run(function (CommunicationCenterService)
    {
      CommunicationCenterService.createChannel('Center', { persistent: true });
      CommunicationCenterService.createChannel('Left');
      CommunicationCenterService.createChannel('Right');
    })
    .controller('CentralController', function ($scope, $http, rx, observeOnScope, CommunicationCenterService) 
    {
      var vm = this;
    
      CommunicationCenterService.getChannel('Right')
        .safeApply($scope, function (color) 
        {
          vm.userInput = color;
        })
        .subscribe();
    
      observeOnScope($scope, function () { return vm.userInput; })
        .debounce(1000)
        .map(function(change)
        {
          return change.newValue || "";
        })
        .distinctUntilChanged() // Only if the value has changed
        .flatMapLatest(searchWikipedia)
        .safeApply($scope, function (result) 
        {
          // result: [0] = search term, [1] = found names, [2] = descriptions, [3] = links
          var grouped = _.zip(result.data[1], result.data[3]);
          vm.results = _.map(grouped, function(r)
          {
            return { title: r[0], url: r[1] };
          });
          CommunicationCenterService.emit('Center', vm.results.length);
        })
        .subscribe();
    
      function searchWikipedia(term) 
      {
        console.log('search ' + term);
        return rx.Observable.fromPromise($http(
          {
            url: "http://en.wikipedia.org/w/api.php?callback=JSON_CALLBACK",
            method: "jsonp",
            params: 
            {
              action: "opensearch",
              search: encodeURI(term),
              format: "json"
            }
          }));             
      }
    
      CommunicationCenterService.emit('Center', 42); // Emits immediately
    })
    .controller('SubController', function($scope, $http, rx, observeOnScope, CommunicationCenterService) 
    {
      var vm = this;
    
      vm.itemNb = $scope.$parent.results;//.length;
    
      CommunicationCenterService.getChannel('Left')
        .safeApply($scope, function (toggle) 
        {
          vm.messageFromLeft = toggle ? 'Left is OK' : 'Left is KO';
        })
        .subscribe();
    
      CommunicationCenterService.getChannel('Center')
        .safeApply($scope, function (length) 
        {
          vm.itemNb = length;
        })
        .subscribe();
    })
    .controller('LeftController', function($scope, $http, rx, observeOnScope, CommunicationCenterService) 
    {
      var vm = this;
    
      vm.toggle = true;
    
      vm.toggleValue = function ()
      {
        CommunicationCenterService.emit('Left', vm.toggle);
      };
    
      observeOnScope($scope, function () { return vm.toggle; })
        .safeApply($scope, function (toggleChange) 
        {
          vm.valueToDisplay = toggleChange.newValue ? 'On' : 'Off';
        })
        .subscribe();
    
      CommunicationCenterService.getChannel('Center')
        .safeApply($scope, function (length) 
        {
          vm.messageFromCenter = 'Search gave ' + length + ' results';
        })
        .subscribe();
    })
    .controller('RightController', function($scope, $http, rx, observeOnScope, CommunicationCenterService) 
    {
      var vm = this;
    
      var display = { red: 'Pink', green: 'Aquamarine', blue: 'Sky' };
    
      vm.color = { value: "blue" }; // Initial value
    
      observeOnScope($scope, function () { return vm.color.value; })
        .tap(function(x) 
        { 
          CommunicationCenterService.emit('Right', vm.color.value);
        })
        .safeApply($scope, function (colorChange) 
        {
          vm.valueToDisplay = display[colorChange.newValue];
        })
        .subscribe();
    
      CommunicationCenterService.getChannel('Left')
        .safeApply($scope, function (toggle) 
        {
          vm.messageFromLeft = toggle ? 'Left is on' : 'Left is off';
        })
        .subscribe();
    })
    ;
    

    I have to create the channels upfront (in .run) otherwise listening to an uncreated channel crashes. Not sure if I will lift the restriction...

    It is only a rough draft, probably brittle, but it reaches my expectations so far.

    I hope it can be useful to somebody.

    [EDIT] I updated my plunk.

    In Take 2: http://plnkr.co/edit/0yZ86a I cleaned up the API and made an intermediary service hiding the channel service. Channels are created upfront. I made clean up of subscriptions a bit easier.

    In Take 3: http://plnkr.co/edit/UqdyB2 I adds topics to channels (inspired by Postal.js) allowing finer communication and less Subjects.