JcT
JcT

Reputation: 3569

Angular service watching for json property change, copying value to other property

Given json data provided by an angular service:

{
  "editableData": {
    "test": {
      "value": "Never gonna give you up"
    }
  },
  "hiddenData": {
    "test": {
      "value": "Never gonna let you down"
    }
  }
}

... I want to let the user modify editableData.test.value, but continuously synchronise that value across to private hiddenData.test.value. The part that has me stumped is the best way to ensure this update is always triggered when the first value is changed, and that it happens as soon as possible.

Real-world complications

  1. Must work in IE8+ (Angular 1.2.x; es5-shim allowed), and not break or overcomplicate two-way binding.

  2. The 'public' property is simultaneously used by multiple controllers, directives and intermediary services, so as per DRY it would be better to handle this in the core service than in every other place it's used.

  3. It's undesirable/unmaintainable to implement a solution that would involve declaring rules for developers like 'whenever this property gets changed, you must remember to call xyz() afterwards to refresh any mirroring'.

  4. The actual data structure is much larger, and may have multiple properties that must be mirrored. Any solution should be fairly easy to scale up.

Possible Solution 1: $rootScope.$watch()

Within the service, I could use $rootScope.$watch(funcThatRunsOnEveryDigest) to fire on every $digest and continually copy values across.

However, I'm uneasy because:

  1. I know it can lead to issues when you start giving $rootScope to services... I think this may be a situation that warrants it, but it feels like a sin.

  2. This would run on every single $digest, regardless of whether the properties have changed. But this would be the case with any watcher, right (that is, the watcher expression/function is always run)? And better than having dozens of watchers, one per mirrored property, each dirty checking?

  3. Am I going to run into issues if editableData.test.value isn't currently exposed on $scope somewhere, but it gets modified behind-the-scenes by code running in response to some other user action, or resolution of an async op?

Possible Solution 2: Link by reference

Simply linking the properties by reference:

//linked at the parent obj containing the .value property
_data.hiddenData.test = _data.editableData.test;

There are still some further implications to consider, including how easily this reference may be broken, and that it's kind of 'sneaky' and seems like it could surprise maintenance devs.

Better answers or insights into implications I haven't considered greatly appreciated!

http://plnkr.co/edit/mOhFBFfKfqDiHBFEgEpH?p=preview

(function() {
  "use strict";
  
  angular.module("myApp", ["myServices"])
  .controller("Controller1", ["$scope", "dataServiceFacade1", Controller1])
  .controller("Controller2", ["$scope", "dataServiceFacade2", Controller2])
  .controller("Controller3", ["$scope", "dataServiceCore", Controller3]);
  
  angular.module("myServices", [])
  .service("dataServiceCore", ["$rootScope", DataServiceCore])
  .service("dataServiceFacade1", ["dataServiceCore", DataServiceFacade1])
  .service("dataServiceFacade2", ["dataServiceCore", DataServiceFacade2]);
  
  /* myApp controllers */
  function Controller1($scope, dataServiceFacade1) {
    $scope.data = dataServiceFacade1.data; //using facade1 which returns editableData.test.value as test1.value
  }
  
  function Controller2($scope, dataServiceFacade2) {
    $scope.data = dataServiceFacade2.data; //using facade2 which returns editableData.test.value as test2.value
  }
  
  function Controller3($scope, dataServiceCore) {
    $scope.data = dataServiceCore.data; //no facade, raw data straight from the core
    $scope.isWatching = dataServiceCore.mirrorByRootScopeWatch; // for toggling the $rootScope.$watch on and off
    $scope.isReferencing = dataServiceCore.mirrorByRef; // for toggling ref on and off
    $scope.reset = dataServiceCore.reset;
  }
  
  /* myServices services */
  function DataServiceCore($rootScope) {
    
    var _data,
    _isWatching,
    _watcherDereg,
    _isReferencing;
    
    _init();
    
    //##################################################
    //# Mirroring by updating from within the service, #
    //# listening to every digest...                   #
    //##################################################
    function _watcherFireOnEveryDigest() {
      _data.hiddenData.test.value = _data.editableData.test.value; //mirroring the value
    }
    
    //_isWatching flag getter/setter
    function _mirrorByRootScopeWatch(value) {
      if(typeof value !== "undefined") {
        _isWatching = value;
        
        if(_isWatching) {
          _mirrorByRef(false);
          _watcherDereg = $rootScope.$watch(_watcherFireOnEveryDigest); //no listener function
        } else if(typeof _watcherDereg === "function") {
          _watcherDereg();
          _watcherDereg = null;
        }
      }
      
      return _isWatching;
    }
    
    function _mirrorByRef(value) {
      if(typeof value !== "undefined") {
        _isReferencing = value;
        
        if(_isReferencing) {
          _mirrorByRootScopeWatch(false);
          //##################################################
          //# Mirroring by creating reference from one prop  #
          //# to the other...                                #
          //##################################################
          _data.hiddenData.test = _data.editableData.test; //linking by ref
        } else {
          _data.hiddenData.test = JSON.parse(JSON.stringify(_data.hiddenData.test)); //set to a de-ref'd copy of itself
        }
      }
      
      return _isReferencing;
    }
    
    function _init() {
      if(_data) {
        //if _data already exists, merge (deep copy / recursive extend) so we update without breaking existing ref's
        merge(_data, _getData());
      } else {
        _data =_getData();
      }
      _mirrorByRootScopeWatch(false);
      _mirrorByRef(false);
    }
    
    //return a clone of the original data
    function _getData() {
      return JSON.parse(JSON.stringify({
        "editableData": {
          "test": {
            "value": "Never gonna give you up"
          }
        },
        "hiddenData": {
          "test": {
            "value": "Never gonna let you down"
          }
        }
      }));
    }
    
    //merge function adapted from angular.merge (angular 1.4+) as per http://stackoverflow.com/a/29003438/446030
    function merge(dst){
      var slice = [].slice;
      var isArray = Array.isArray;
      function baseExtend(dst, objs, deep) {
        for (var i = 0, ii = objs.length; i < ii; ++i) {
          var obj = objs[i];
          if (!angular.isObject(obj) && !angular.isFunction(obj)) continue;
          var keys = Object.keys(obj);
          for (var j = 0, jj = keys.length; j < jj; j++) {
            var key = keys[j];
            var src = obj[key];
            if (deep && angular.isObject(src)) {
              if (!angular.isObject(dst[key])) dst[key] = isArray(src) ? [] : {};
              baseExtend(dst[key], [src], true);
            } else {
              dst[key] = src;
            }
          }
        }
    
        return dst;
      }
      return baseExtend(dst, slice.call(arguments, 1), true);
    }
    
    return {
      data: _data,
      mirrorByRootScopeWatch: _mirrorByRootScopeWatch,
      mirrorByRef: _mirrorByRef,
      reset: _init
    };
  }
  
  function DataServiceFacade1(dataServiceCore) {
    var _data = {
      "test1": dataServiceCore.data.editableData.test
    };
    
    return {
      data: _data
    };
  }
  
  function DataServiceFacade2(dataServiceCore) {
    var _data = {
      "test2": dataServiceCore.data.editableData.test
    };
    
    return {
      data: _data
    };
  }
  
})();
<!DOCTYPE html>
<html>

  <head>
    <script data-require="angular.js@*" data-semver="1.2.28" src="https://code.angularjs.org/1.2.28/angular.js"></script>
    <style type="text/css">
      body {font: 0.9em Arial, Verdana, sans-serif;}
      div {margin-bottom: 4px;}
      label {margin-right: 8px;}
      p {font-size: 0.9em; color: #999;}
      code {color:#000; background-color: #eee}
      pre code {display:block;}
    </style>
    <script src="script.js"></script>
  </head>

  <body>
    <div ng-app="myApp">
      
      <div ng-controller="Controller1">
        <h4>Controller1</h4>
        <p>This value is linked to <code>editableData.test.value</code> via
        reference in its facade service.</p>
        <label for="test1">test1.value</label>
        <input ng-model="data.test1.value" id="test1" />
        <pre><code>{{data|json}}</code></pre>
      </div>
      
      <div ng-controller="Controller2">
        <h4>Controller2</h4>
        <p>This value is <em>also</em> linked to
        <code>editableData.test.value</code> via reference in its facade
        service.</p>
        <label for="test2">test2.value</label>
        <input ng-model="data.test2.value" id="test2" />
        <pre><code>{{data|json}}</code></pre>
      </div>
      
      <div ng-controller="Controller3">
        <h4>Core Data</h4>
        <p>'Mirroring' the value of <code>editableData.test.value</code> to
        <code>hiddenData.test.value</code> by listening for every
        <code>$rootScope.$digest</code> from within the service, and copying
        between them.</p>
        <p>Enable/Disable mirroring with the button below, and type in the
        input fields above.</p>
        <button ng-click="isWatching(!isWatching());"><strong>
          {{isWatching() ? "Disable" : "Enable"}}</strong> mirroring with
          <code>$rootScope.$watch</code></button>
        <button ng-click="isReferencing(!isReferencing());"><strong>
          {{isReferencing() ? "Disable" : "Enable"}}</strong> mirroring by ref
          </button>
        <button ng-click="reset()">Reset</button>
        <pre><code>{{data|json}}</code></pre>
      </div>
      
    </div>
  </body>

</html>

Update: Based on accepted answer, have made some further modifications to a fork of the Plnkr to play with encapsulating to a separate service. More complicated than necessary, really, so that I can still test out By Ref vs By $rootScope.$watch():

http://plnkr.co/edit/agdBWg?p=preview

Upvotes: 2

Views: 1616

Answers (1)

Jan
Jan

Reputation: 5827

EDIT: It seems I misunderstood the question, since it was about how to watch for changes in the data object, you could add a separate service/controller whose ONLY job is to watch for the change. For me that sounds enough of a separation of concerns to make it quite ok. Here is an example using a DataWatcher controller.

function DataWatcher($scope, dataServiceCore) {
  function _watcherHasChanged() {
    return dataServiceCore.data.editableData;
  }
  function _watcherFireOnEveryChange() {
    dataServiceCore.data.hiddenData.test.value = dataServiceCore.data.editableData.test.value; //mirroring the value
  }
  $scope.$watch(_watcherHasChanged, _watcherFireOnEveryChange, true);
}

http://plnkr.co/edit/PWPUPyEXGN5hcc9JANBo?p=preview

OLD ANSWER:

According to What is the most efficient way to deep clone an object in JavaScript? JSON.parse(JSON.stringify(obj)) is the most efficient way to clone an object. Which is essentially what you're trying to do here.

So something like

myObject.hiddenData = JSON.parse(JSON.stringify(myObject.editableData);

executed whenever editableData is changed in myObject might be what you're looking for.

Upvotes: 1

Related Questions