CRM-15855 - Add crmAutosave module
authorTim Otten <totten@civicrm.org>
Fri, 23 Jan 2015 11:30:02 +0000 (03:30 -0800)
committerTim Otten <totten@civicrm.org>
Sat, 24 Jan 2015 08:02:05 +0000 (00:02 -0800)
Civi/Angular/Manager.php
js/angular-crmAutosave.js [new file with mode: 0644]
tests/karma/unit/crmAutosaveSpec.js [new file with mode: 0644]

index 8b151802cd78ad1b1cd67b5a5589b248f262f5aa..bb3043ff8decf9f55c4983959ad4a209f4cfb749 100644 (file)
@@ -69,6 +69,10 @@ class Manager {
         'css' => array('css/angular-crmAttachment.css'),
         'partials' => array('partials/crmAttachment'),
       );
+      $angularModules['crmAutosave'] = array(
+        'ext' => 'civicrm',
+        'js' => array('js/angular-crmAutosave.js'),
+      );
       $angularModules['crmResource'] = array(
         'ext' => 'civicrm',
         // 'js' => array('js/angular-crmResource/byModule.js'), // One HTTP request per module.
diff --git a/js/angular-crmAutosave.js b/js/angular-crmAutosave.js
new file mode 100644 (file)
index 0000000..9466627
--- /dev/null
@@ -0,0 +1,103 @@
+/// crmAutosave
+(function(angular, $, _) {
+
+  angular.module('crmAutosave', ['crmUtil']);
+
+  // usage: <form crm-autosave="myCtrl.save()" crm-autosave-model="myModel">...</form>
+  //
+  // Automatically save changes. Don't save while the user is actively updating the model -- save
+  // after a pause in user activity (e.g. after 2sec).
+  //
+  //  - crm-autosave="callback" -- A function to handle saving. Should return a promise.
+  //                               If it's not a promise, then we'll assume that it completes successfully.
+  //  - crm-autosave-interval="object" -- Interval spec. Default: {poll: 250, save: 3000}
+  //  - crm-autosave-if="conditional" -- Only allow autosave when conditional returns true. Default: !form.$invalid
+  //  - crm-autosave-model="object" -- (Re)schedule saves based on observed changes to object.
+  //    We perform deep ispection on the model object. This could be a performance issue you had many concurrent
+  //    autosave forms, but it should be fine with one.
+  //
+  // The call to the autosave function will cause the form to be marked as pristine (unless there's an error).
+  angular.module('crmAutosave').directive('crmAutosave', function($interval, $timeout) {
+    return {
+      restrict: 'AE',
+      require: '^form',
+      link: function(scope, element, attrs, form) {
+        var intervals = angular.extend({poll: 250, save: 3000}, scope.$eval(attrs.crmAutosaveInterval));
+        var jobs = {poll: null, save: null}; // job handles used ot cancel/reschedule timeouts/intervals
+        var lastSeenModel = null;
+        var saving = false;
+
+        // Determine if model has changed; (re)schedule the save.
+        // This is a bit expensive and doesn't need to be continuous, so we use polling instead of watches.
+        function checkChanges() {
+          if (saving) {
+            return;
+          }
+          var currentModel = scope.$eval(attrs.crmAutosaveModel);
+          if (lastSeenModel === null) {
+            lastSeenModel = angular.copy(currentModel);
+          }
+          else if (!angular.equals(currentModel, lastSeenModel)) {
+            lastSeenModel = angular.copy(currentModel);
+            if (jobs.save) {
+              $timeout.cancel(jobs.save);
+            }
+            jobs.save = $timeout(doAutosave, intervals.save);
+          }
+        }
+
+        function doAutosave() {
+          jobs.save = null;
+          if (saving) {
+            return;
+          }
+
+          if (attrs.crmAutosaveIf) {
+            if (!scope.$eval(attrs.crmAutosaveIf)) {
+              return;
+            }
+          }
+          else if (form.$invalid) {
+            return;
+          }
+
+          saving = true;
+
+          // Set to pristine before saving -- not after saving.
+          // If an eager user continues editing concurrent with the
+          // save process, then the form should become dirty again.
+          form.$setPristine();
+          var res = scope.$eval(attrs.crmAutosave);
+          if (res && res.then) {
+            res.then(
+              function() {
+                saving = false;
+              },
+              function() {
+                saving = false;
+                form.$setDirty();
+              }
+            );
+          }
+          else {
+            saving = false;
+          }
+        }
+
+        jobs.poll = $interval(checkChanges, intervals.poll);
+        element.on('$destroy', function() {
+          if (jobs.poll) {
+            $interval.cancel(jobs.poll);
+            jobs.poll = null;
+          }
+          if (jobs.save) {
+            $timeout.cancel(jobs.save);
+            jobs.save = null;
+          }
+        });
+
+      }
+    };
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/tests/karma/unit/crmAutosaveSpec.js b/tests/karma/unit/crmAutosaveSpec.js
new file mode 100644 (file)
index 0000000..7633e84
--- /dev/null
@@ -0,0 +1,213 @@
+describe('crmAutosave', function() {
+
+  function using(name, values, func) {
+    for (var i = 0, count = values.length; i < count; i++) {
+      if (Object.prototype.toString.call(values[i]) !== '[object Array]') {
+        values[i] = [values[i]];
+      }
+      func.apply(this, values[i]);
+      jasmine.currentEnv_.currentSpec.description += ' (with "' + name + '" using ' + values[i].join(', ') + ')';
+    }
+  }
+
+  beforeEach(function() {
+    module('crmUtil');
+    module('crmAutosave');
+  });
+
+  describe('Autosave directive', function() {
+    var $compile,
+      $rootScope,
+      $interval,
+      $timeout,
+      fakeCtrl,
+      model,
+      element;
+
+    beforeEach(inject(function(_$compile_, _$rootScope_, _$interval_, _$timeout_, _$q_) {
+      // The injector unwraps the underscores (_) from around the parameter names when matching
+      $compile = _$compile_;
+      $rootScope = _$rootScope_;
+      $interval = _$interval_;
+      $timeout = _$timeout_;
+
+      $rootScope.fakeCtrl = fakeCtrl = {
+        doSave: function() {
+        },
+        doSaveWithPromise: function() {
+          var dfr = _$q_.defer();
+          $timeout(function() {
+            dfr.resolve();
+          }, 25);
+          return dfr.promise;
+        },
+        doSaveSlowly: function() {
+          var dfr = _$q_.defer();
+          fakeCtrl.savingSlowly = true;
+          $timeout(function() {
+            fakeCtrl.savingSlowly = false;
+            dfr.resolve();
+          }, 1000);
+          return dfr.promise;
+        }
+      };
+      spyOn(fakeCtrl, 'doSave').and.callThrough();
+      spyOn(fakeCtrl, 'doSaveWithPromise').and.callThrough();
+      spyOn(fakeCtrl, 'doSaveSlowly').and.callThrough();
+
+      $rootScope.model = model = {
+        fieldA: 'alpha',
+        fieldB: 'beta'
+      };
+    }));
+
+    // Fake wait - advance the interval & timeout
+    // @param int msec Total time to advance the clock
+    // @param int steps Number of times to issue flush()
+    //    Higher values provide a more realistic simulation
+    //    but can be a bit slower.
+    function wait(msec, steps) {
+      if (!steps) steps = 4;
+      for (var i = 0; i < steps; i++) {
+        $interval.flush(msec/steps);
+        $timeout.flush(msec/steps);
+      }
+    }
+
+    // TODO: Test: If the save function throws an error, or if its promise returns an error, reset form as dirty.
+
+    var fakeSaves = {
+      "fakeCtrl.doSave()": 'doSave',
+      "fakeCtrl.doSaveWithPromise()": 'doSaveWithPromise'
+    };
+    angular.forEach(fakeSaves, function(saveFunc, saveFuncExpr) {
+      it('calls ' + saveFuncExpr + ' twice over the course of three changes', function() {
+        element = $compile('<form name="myForm" crm-autosave="' + saveFuncExpr + '" crm-autosave-model="model" crm-autosave-interval="{poll: 25, save: 50}"><input class="fieldA" ng-model="model.fieldA"><input class="fieldB" ng-model="model.fieldB"></form>')($rootScope);
+        $rootScope.$digest();
+
+        // check that we load pristine data and don't do any saving
+        wait(100);
+        expect(element.find('.fieldA').val()).toBe('alpha');
+        expect(element.find('.fieldB').val()).toBe('beta');
+        expect($rootScope.myForm.$pristine).toBe(true);
+        expect(fakeCtrl[saveFunc].calls.count()).toBe(0);
+
+        // first round of changes
+        element.find('.fieldA').val('eh?').trigger('change');
+        $rootScope.$digest();
+        element.find('.fieldB').val('bee').trigger('change');
+        $rootScope.$digest();
+        expect(model.fieldA).toBe('eh?');
+        expect(model.fieldB).toBe('bee');
+        expect($rootScope.myForm.$pristine).toBe(false);
+        expect(fakeCtrl[saveFunc].calls.count()).toBe(0);
+
+        // first autosave
+        wait(100);
+        expect($rootScope.myForm.$pristine).toBe(true);
+        expect(fakeCtrl[saveFunc].calls.count()).toBe(1);
+
+        // a little stretch of time with nothing happening
+        wait(100);
+        expect(fakeCtrl[saveFunc].calls.count()).toBe(1);
+
+        // second round of changes
+        element.find('.fieldA').val('ah').trigger('change');
+        $rootScope.$digest();
+        expect(model.fieldA).toBe('ah');
+
+        // second autosave
+        expect($rootScope.myForm.$pristine).toBe(false);
+        expect(fakeCtrl[saveFunc].calls.count()).toBe(1);
+        wait(100);
+        expect(fakeCtrl[saveFunc].calls.count()).toBe(2);
+        expect($rootScope.myForm.$pristine).toBe(true);
+
+        // a little stretch of time with nothing happening
+        wait(100);
+        expect(fakeCtrl[saveFunc].calls.count()).toBe(2);
+      });
+    });
+
+    it('does not save an invalid form', function() {
+      element = $compile('<form name="myForm" crm-autosave="fakeCtrl.doSave()" crm-autosave-model="model" crm-autosave-interval="{poll: 25, save: 50}"><input class="fieldA" ng-model="model.fieldA"><input class="fieldB" required ng-model="model.fieldB"></form>')($rootScope);
+      $rootScope.$digest();
+
+      // check that we load pristine data and don't do any saving
+      wait(100);
+      expect(element.find('.fieldA').val()).toBe('alpha');
+      expect(element.find('.fieldB').val()).toBe('beta');
+      expect($rootScope.myForm.$pristine).toBe(true);
+      expect(fakeCtrl.doSave.calls.count()).toBe(0);
+
+      // first round of changes - fieldB is invalid
+      element.find('.fieldA').val('eh?').trigger('change');
+      $rootScope.$digest();
+      element.find('.fieldB').val('').trigger('change');
+      $rootScope.$digest();
+      expect(model.fieldA).toBe('eh?');
+      expect(model.fieldB).toBeFalsy();
+      expect($rootScope.myForm.$pristine).toBe(false);
+      expect(fakeCtrl.doSave.calls.count()).toBe(0);
+
+      // first autosave declines to run
+      wait(100);
+      expect($rootScope.myForm.$pristine).toBe(false);
+      expect(fakeCtrl.doSave.calls.count()).toBe(0);
+
+      // second round of changes
+      element.find('.fieldB').val('bee').trigger('change');
+      $rootScope.$digest();
+      expect(model.fieldB).toBe('bee');
+
+      // second autosave
+      expect($rootScope.myForm.$pristine).toBe(false);
+      expect(fakeCtrl.doSave.calls.count()).toBe(0);
+      wait(100);
+      expect(fakeCtrl.doSave.calls.count()).toBe(1);
+      expect($rootScope.myForm.$pristine).toBe(true);
+
+      // a little stretch of time with nothing happening
+      wait(100);
+      expect(fakeCtrl.doSave.calls.count()).toBe(1);
+    });
+
+    it('defers saving new changes when a save is already pending', function() {
+      element = $compile('<form name="myForm" crm-autosave="fakeCtrl.doSaveSlowly()" crm-autosave-model="model" crm-autosave-interval="{poll: 25, save: 50}"><input class="fieldA" ng-model="model.fieldA"><input class="fieldB" ng-model="model.fieldB"></form>')($rootScope);
+      $rootScope.$digest();
+
+      // check that we load pristine data and don't do any saving
+      wait(100);
+      expect(element.find('.fieldA').val()).toBe('alpha');
+      expect(element.find('.fieldB').val()).toBe('beta');
+      expect($rootScope.myForm.$pristine).toBe(true);
+      expect(fakeCtrl.doSaveSlowly.calls.count()).toBe(0);
+
+      // first round of changes
+      element.find('.fieldA').val('eh?').trigger('change');
+      $rootScope.$digest();
+      expect(model.fieldA).toBe('eh?');
+      expect($rootScope.myForm.$pristine).toBe(false);
+      expect(fakeCtrl.doSaveSlowly.calls.count()).toBe(0);
+
+      // first autosave starts
+      wait(100);
+      expect(fakeCtrl.savingSlowly).toBe(true);
+      expect(fakeCtrl.doSaveSlowly.calls.count()).toBe(1);
+
+      // second round of changes; doesn't save yet
+      element.find('.fieldA').val('aleph').trigger('change');
+      $rootScope.$digest();
+      expect(model.fieldA).toBe('aleph');
+      expect(fakeCtrl.savingSlowly).toBe(true);
+      expect(fakeCtrl.doSaveSlowly.calls.count()).toBe(1);
+      wait(100);
+      expect(fakeCtrl.doSaveSlowly.calls.count()).toBe(1);
+
+      // second autosave starts and finishes
+      wait(2500, 5);
+      expect(fakeCtrl.savingSlowly).toBe(false);
+      expect(fakeCtrl.doSaveSlowly.calls.count()).toBe(2);
+    });
+  });
+});