From: Tim Otten Date: Fri, 23 Jan 2015 11:30:02 +0000 (-0800) Subject: CRM-15855 - Add crmAutosave module X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=2386ec88490e9e5e72490179b9c5644940b45261;p=civicrm-core.git CRM-15855 - Add crmAutosave module --- diff --git a/Civi/Angular/Manager.php b/Civi/Angular/Manager.php index 8b151802cd..bb3043ff8d 100644 --- a/Civi/Angular/Manager.php +++ b/Civi/Angular/Manager.php @@ -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 index 0000000000..9466627f2b --- /dev/null +++ b/js/angular-crmAutosave.js @@ -0,0 +1,103 @@ +/// crmAutosave +(function(angular, $, _) { + + angular.module('crmAutosave', ['crmUtil']); + + // usage:
...
+ // + // 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 index 0000000000..7633e84ef7 --- /dev/null +++ b/tests/karma/unit/crmAutosaveSpec.js @@ -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('
')($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('
')($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('
')($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); + }); + }); +});