--- /dev/null
+/// 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._);
--- /dev/null
+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);
+ });
+ });
+});