diff --git a/Gulpfile.js b/Gulpfile.js new file mode 100644 index 0000000..a9fe738 --- /dev/null +++ b/Gulpfile.js @@ -0,0 +1,24 @@ +/* jshint strict: false */ +var gulp = require('gulp'); +var uglify = require('gulp-uglify'); +var concat = require('gulp-concat'); + +var PATH = { + src: [ + 'src/autodisable.module.js', + 'src/autodisable.directive.js', + 'src/autodisable.factory.js' + ] +}; + +gulp.task('build', function() { + return gulp.src(PATH.src) + .pipe(concat('autodisable.js')) + .pipe(gulp.dest('.')) + .pipe(uglify()) + .pipe(gulp.dest('dist')); +}); + +gulp.task('watch', function () { + gulp.watch(PATH.src, ['build']); +}); \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..157fe56 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +install: + npm install + +build: + ./node_modules/gulp/bin/gulp.js build + +test: + ./node_modules/karma/bin/karma start karma.conf.js --single-run + +tdd: + ./node_modules/karma/bin/karma start karma.conf.js \ No newline at end of file diff --git a/autodisable.js b/autodisable.js new file mode 100644 index 0000000..9fd3b21 --- /dev/null +++ b/autodisable.js @@ -0,0 +1,298 @@ +angular.module('angular-autodisable', []); +/* global angular */ +(function(module) { + 'use strict'; + + /** + * @directive [autodisable] + * @example + *
+ * + * + *
+ */ + function autodisableDirective(AutoDisable) { + return { + restrict: 'A', + compile: compiler, + require: ['?form', '?^form'] + }; + + function compiler(element, attrs) { + var autoDisable = new AutoDisable(element, attrs, attrs.autodisable); + return autoDisable.link.bind(autoDisable); + } + } + + module.directive('autodisable', ['AutoDisable', autodisableDirective]); + +})(angular.module('angular-autodisable')); + +/* global angular */ +(function(module) { + 'use strict'; + + var TAG_INPUT = /^(input|textarea)$/i, + TAG_BUTTON = /^button$/i, + TAG_FORM = /^form$/i, + + CLS_AUTODISABLE = 'autodisable', + CLS_LOCKED = 'autodisable-locked', + CLS_BUSY = 'autodisable-busy', + + baseConfig = { + lockOnComplete: true + }; + + /** + * @factory + */ + function AutoDisableFactory($parse, $q) { + /* jshint validthis: true */ + function AutoDisable(element, attrs, options) { + var tagName = String(element && element[0] && element[0].tagName || ''), + type = attrs.type || '', + isSubmit = type === 'submit', + isInput = TAG_INPUT.test(tagName), + isButton = TAG_BUTTON.test(tagName), + isForm = TAG_FORM.test(tagName); + + this.options = options; + this.type = type; + + this.isSubmit = isSubmit && (isButton || isInput); + this.isForm = isForm; + this.isInput = !isForm; + + this.onClick = !!attrs.ngClick; + this.onSubmit = !!attrs.ngSubmit; + } + + AutoDisable.prototype = { + constructor: AutoDisable, + link: link, + lock: lock, + unlock: unlock, + busyLock: busyLock, + busyUnlock: busyUnlock, + initialize: initialize + }; + + function initialize() { + // listen to ng-submit + if (this.isForm && this.onSubmit) { + bindEvent(this, 'submit'); + } + + // listen to ng-click on submit button + if (this.isSubmit && this.onClick) { + bindEvent(this, 'click'); + } + + if (this.isForm) { + bindFormState(this); + } else { + bindChildState(this); + } + } + + function link($scope, $element, $attrs, controllers) { + var form = this.isForm ? controllers[0] : controllers[1], + options = this.options; + + form.$busy = form.$disabled = false; + + angular.extend(this, { + scope: $scope, + element: $element, + attrs: $attrs, + form: form, + options: angular.isString(options) && $scope.$eval(options) || {} + }); + + $element.addClass(CLS_AUTODISABLE); + + this.initialize(); + } + + function busyLock(promise) { + var self = this; + + if (self.promise) return; + + self.element.addClass(CLS_BUSY); + + // at form or submit button, bind to promise + // otherwise, just lock the field + if (promise) { + self.promise = promise.then(function(response) { + self.busyUnlock(true); + return response; + }, function(error) { + self.busyUnlock(false); + return $q.reject(error); + }); + } + + if (this.isForm) { + setFormBusy(self, true); + } else { + setInputDisable(self, true); + } + } + + function busyUnlock(success) { + this.element.removeClass(CLS_BUSY); + this.promise = null; + + if (this.isForm) { + setFormBusy(this, false); + + if (success && baseConfig.lockOnComplete) { + this.form.$setPristine(); + } + } else { + setInputDisable(this, false); + } + } + + function setFormBusy(self, value) { + self.form.$busy = value; + setFormDisable(self, value); + } + + function lock() { + if (this.locked) return; + + this.locked = true; + this.element.addClass(CLS_LOCKED); + + if (this.isForm) { + setFormDisable(this, true); + } else if (this.isSubmit) { + setInputDisable(this, true); + } else { + setInputDisable(this, false); + } + } + + function unlock() { + if (!this.locked) return; + + this.locked = false; + this.element.removeClass(CLS_LOCKED); + + if (this.isForm) { + setFormDisable(this, false); + } else { + setInputDisable(this, false); + } + } + + function setFormDisable(self, value) { + self.form.$disabled = value; + } + + function setInputDisable(self, value) { + self.attrs.$set('disabled', value); + } + + // helper functions + function bindEvent(self, eventName) { + var attributeName = 'ng' + eventName.charAt(0).toUpperCase() + eventName.slice(1), + fn = $parse(self.attrs[attributeName], /* interceptorFn */ null, /* expensiveChecks */ true); + + self.element.unbind(eventName).bind(eventName, handler); + + function handler($event) { + if (self.locked || self.promise) return; + + var result = fn(self.scope, { + $event: $event + }); + + if (isPromise(result)) { + self.busyLock(result); + } + + self.scope.$apply(); + } + } + + function bindStateTrigger(self, watcher, trigger) { + var form = self.form || false; + if (!form) return; + self.scope.$watch(watcher, trigger); + } + + /** + * Lock/unlock the form + */ + function bindFormState(self) { + var form = self.form; + bindStateTrigger(self, isInvalid, updateLockOnForm); + + function isInvalid() { + return form.$pristine + '' + form.$invalid; + } + + function updateLockOnForm() { + if ( + (form.$pristine && self.options.pristine !== false) || + (form.$invalid && self.options.invalid !== false) + ) { + self.lock(); + return; + } + + self.unlock(); + } + } + + function bindChildState(self) { + var form = self.form; + + bindStateTrigger(self, isFormDisabled, updateChildDisabled); + bindStateTrigger(self, isFormBusy, updateChildBusy); + + function isFormDisabled() { + return form.$disabled; + } + + function isFormBusy() { + return form.$busy; + } + + function updateChildDisabled() { + if (form.$disabled && !form.$busy) { + self.lock(); + } else { + self.unlock(); + } + } + + function updateChildBusy() { + if (form.$busy) { + self.busyLock(); + } else { + self.busyUnlock(); + } + } + } + + function isPromise(value) { + return Boolean(value && typeof value.then === 'function' && + typeof value.finally === 'function'); + } + + return AutoDisable; + } + + module.provider('AutoDisable', function() { + this.$get = ['$parse', '$q', AutoDisableFactory]; + this.config = function(config) { + angular.extend(baseConfig, config); + }; + }); + +})(angular.module('angular-autodisable')); diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..49dfd86 --- /dev/null +++ b/bower.json @@ -0,0 +1,25 @@ +{ + "name": "ng-autodisable", + "main": "autodisable.js", + "version": "0.2.0", + "homepage": "https://github.com/darlanalves/angular-autodisable", + "authors": [ + "Darlan Alves " + ], + "description": "Autodisable form fields and buttons while a promise is in progress", + "keywords": [ + "angularjs" + ], + "license": "ISC", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests", + "Gruntfile.js", + "karma.conf.js", + "Makefile", + "src/**.*" + ] +} diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..a8b1f7a --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,76 @@ +// Karma configuration +// Generated on Sun Jul 05 2015 01:20:52 GMT-0300 (BRT) +var path = require('path'); + +module.exports = function(config) { + 'use strict'; + + var angular = path.join(path.dirname(require.resolve('angular')), 'angular.js'); + var angularMocks = path.join(path.dirname(require.resolve('angular-mocks/ngMock')), 'angular-mocks.js'); + var es5Shim = path.join(path.dirname(require.resolve('es5-shim')), 'es5-shim.js'); + + config.set({ + + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: '', + + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + + // list of files / patterns to load in the browser + files: [ + angular, + angularMocks, + es5Shim, + 'src/autodisable.module.js', + 'src/autodisable.directive.js', + 'src/autodisable.factory.js', + 'src/*.spec.js' + ], + + + // list of files to exclude + exclude: [], + + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: {}, + + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress'], + + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ['PhantomJS'], + + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: false + }) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c7c13a --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "_args": [ + [ + "git://github.com/contentools/angular-autodisable.git#v0.3.1", + "/Users/davi/Projetos/contentools/frontend" + ] + ], + "_from": "git://github.com/contentools/angular-autodisable.git#v0.3.1", + "_id": "autodisable@git://github.com/contentools/angular-autodisable.git#cccaf2fa1f2409a320e932d1e996de206ff271b1", + "_inBundle": false, + "_integrity": "", + "_location": "/autodisable", + "_phantomChildren": {}, + "_requested": { + "type": "git", + "raw": "git://github.com/contentools/angular-autodisable.git#v0.3.1", + "rawSpec": "git://github.com/contentools/angular-autodisable.git#v0.3.1", + "saveSpec": "git://github.com/contentools/angular-autodisable.git#v0.3.1", + "fetchSpec": "git://github.com/contentools/angular-autodisable.git", + "gitCommittish": "v0.3.1" + }, + "_requiredBy": [ + "/" + ], + "_resolved": "git://github.com/contentools/angular-autodisable.git#cccaf2fa1f2409a320e932d1e996de206ff271b1", + "_spec": "git://github.com/contentools/angular-autodisable.git#v0.3.1", + "_where": "/Users/davi/Projetos/contentools/frontend", + "author": "", + "description": "Autodisable buttons and form fields on submit", + "devDependencies": { + "angular": "^1.3.16", + "angular-mocks": "^1.3.16", + "es5-shim": "^4.1.7", + "gulp": "^3.9.0", + "gulp-concat": "^2.6.0", + "gulp-uglify": "^1.2.0", + "jasmine-core": "^2.3.4", + "karma": "^0.12.37", + "karma-jasmine": "^0.3.6", + "karma-phantomjs-launcher": "^0.2.0", + "phantomjs": "^1.9.17" + }, + "license": "ISC", + "main": "autodisable.js", + "name": "autodisable", + "scripts": { + "test": "make test" + }, + "version": "0.3.1" +} diff --git a/src/autodisable.directive.js b/src/autodisable.directive.js new file mode 100644 index 0000000..73eb259 --- /dev/null +++ b/src/autodisable.directive.js @@ -0,0 +1,28 @@ +/* global angular */ +(function(module) { + 'use strict'; + + /** + * @directive [autodisable] + * @example + *
+ * + * + *
+ */ + function autodisableDirective(AutoDisable) { + return { + restrict: 'A', + compile: compiler, + require: ['?form', '?^form'] + }; + + function compiler(element, attrs) { + var autoDisable = new AutoDisable(element, attrs, attrs.autodisable); + return autoDisable.link.bind(autoDisable); + } + } + + module.directive('autodisable', ['AutoDisable', autodisableDirective]); + +})(angular.module('angular-autodisable')); diff --git a/src/autodisable.directive.spec.js b/src/autodisable.directive.spec.js new file mode 100644 index 0000000..65afedf --- /dev/null +++ b/src/autodisable.directive.spec.js @@ -0,0 +1,163 @@ +'use strict'; + +describe('autodisable directive', function() { + + beforeEach(module('angular-autodisable')); + + var compile, flush; + + beforeEach(inject(function($rootScope, $compile) { + compile = function(template) { + var el = $compile(template)($rootScope); + $rootScope.$digest(); + + return el; + }; + + flush = function() { + $rootScope.$digest(); + }; + })); + + describe('disable form submission if there is a promise waiting in the submit event handler', function() { + it('should not fire the handler twice while the promise is waiting', inject(function($rootScope, $q) { + var template = '
' + + '' + + '' + + '
', + + count = 0, + element = compile(template), + deferred = $q.defer(), + form = element.data('$formController'), + button = element.find('button'), + input = element.find('input'); + + expect(count).toBe(0); + + // autolock + expect(element.hasClass('autodisable')).toBe(true); + expect(element.hasClass('autodisable-locked')).toBe(true); + expect(element.hasClass('autodisable-busy')).toBe(false); + + // auto lock children on startup + expect(button.attr('disabled')).toBe('disabled'); + expect(button.hasClass('autodisable-locked')).toBe(true); + expect(button.hasClass('autodisable-busy')).toBe(false); + + expect(input.attr('disabled')).not.toBe('disabled'); + expect(input.hasClass('autodisable-locked')).toBe(true); + expect(input.hasClass('autodisable-busy')).toBe(false); + + + $rootScope.onsubmit = function() { + count++; + return deferred.promise; + }; + + form.$setDirty(); + flush(); + + /** + * Submit the form + */ + element.triggerHandler('submit'); + expect(count).toBe(1); + + expect(element.hasClass('autodisable')).toBe(true); + expect(element.hasClass('autodisable-locked')).toBe(false); + expect(element.hasClass('autodisable-busy')).toBe(true); + + // auto locked on submit + expect(button.attr('disabled')).toBe('disabled'); + expect(button.hasClass('autodisable-locked')).toBe(false); + expect(button.hasClass('autodisable-busy')).toBe(true); + + // inputs are locked as well + expect(input.attr('disabled')).toBe('disabled'); + expect(input.hasClass('autodisable-locked')).toBe(false); + expect(input.hasClass('autodisable-busy')).toBe(true); + + /** + * Another submission must be ignored + */ + element.triggerHandler('submit'); + expect(count).toBe(1); + + deferred.resolve(); + flush(); + + /** + * The promise is now resolved. Unlock the form + */ + expect(element.hasClass('autodisable')).toBe(true); + expect(element.hasClass('autodisable-locked')).toBe(false); + expect(element.hasClass('autodisable-busy')).toBe(false); + + // unlocked when the submission ends + expect(button.attr('disabled')).not.toBe('disabled'); + expect(button.hasClass('autodisable-locked')).toBe(false); + expect(button.hasClass('autodisable-busy')).toBe(false); + + // inputs are unlocked as well + expect(input.attr('disabled')).not.toBe('disabled'); + expect(input.hasClass('autodisable-locked')).toBe(false); + expect(input.hasClass('autodisable-busy')).toBe(false); + + element.triggerHandler('submit'); + expect(count).toBe(2); + })); + + it('should autolock if the form is invalid or pristine', inject(function($rootScope) { + var template = '
', + element = compile(template), + count = 0; + + $rootScope.onsubmit = function() { + count++; + }; + + element.triggerHandler('submit'); + expect(count).toBe(0); + })); + + it('should lock the child nodes when the parent is locked', inject(function() { + var template = '
' + + '' + + '' + + '
', + + element = compile(template); + + var button = element.find('button'), + input = element.find('input'); + + // auto locked due to form state being pristine + expect(button.attr('disabled')).toBe('disabled'); + expect(button.hasClass('autodisable-locked')).toBe(true); + expect(button.hasClass('autodisable-busy')).toBe(false); + + // has the class but won't be disabled, otherwise the form + // would be unusable + expect(input.attr('disabled')).not.toBe('disabled'); + expect(input.hasClass('autodisable-locked')).toBe(true); + expect(input.hasClass('autodisable-busy')).toBe(false); + })); + + it('should unlock the child nodes when the parent is unlocked', inject(function() { + var template = '
' + + '' + + '' + + '
', + + element = compile(template), + form = element.data('$formController'); + + form.$setDirty(); + flush(); + + expect(element.find('button').attr('disabled')).not.toBe('disabled'); + expect(element.find('input').attr('disabled')).not.toBe('disabled'); + })); + }); +}); diff --git a/src/autodisable.factory.js b/src/autodisable.factory.js new file mode 100644 index 0000000..4290196 --- /dev/null +++ b/src/autodisable.factory.js @@ -0,0 +1,268 @@ +/* global angular */ +(function(module) { + 'use strict'; + + var TAG_INPUT = /^(input|textarea)$/i, + TAG_BUTTON = /^button$/i, + TAG_FORM = /^form$/i, + + CLS_AUTODISABLE = 'autodisable', + CLS_LOCKED = 'autodisable-locked', + CLS_BUSY = 'autodisable-busy', + + baseConfig = { + lockOnComplete: true + }; + + /** + * @factory + */ + function AutoDisableFactory($parse, $q) { + /* jshint validthis: true */ + function AutoDisable(element, attrs, options) { + var tagName = String(element && element[0] && element[0].tagName || ''), + type = attrs.type || '', + isSubmit = type === 'submit', + isInput = TAG_INPUT.test(tagName), + isButton = TAG_BUTTON.test(tagName), + isForm = TAG_FORM.test(tagName); + + this.options = options; + this.type = type; + + this.isSubmit = isSubmit && (isButton || isInput); + this.isForm = isForm; + this.isInput = !isForm; + + this.onClick = !!attrs.ngClick; + this.onSubmit = !!attrs.ngSubmit; + } + + AutoDisable.prototype = { + constructor: AutoDisable, + link: link, + lock: lock, + unlock: unlock, + busyLock: busyLock, + busyUnlock: busyUnlock, + initialize: initialize + }; + + function initialize() { + // listen to ng-submit + if (this.isForm && this.onSubmit) { + bindEvent(this, 'submit'); + } + + // listen to ng-click on submit button + if (this.isSubmit && this.onClick) { + bindEvent(this, 'click'); + } + + if (this.isForm) { + bindFormState(this); + } else { + bindChildState(this); + } + } + + function link($scope, $element, $attrs, controllers) { + var form = this.isForm ? controllers[0] : controllers[1], + options = this.options; + + form.$busy = form.$disabled = false; + + angular.extend(this, { + scope: $scope, + element: $element, + attrs: $attrs, + form: form, + options: angular.isString(options) && $scope.$eval(options) || {} + }); + + $element.addClass(CLS_AUTODISABLE); + + this.initialize(); + } + + function busyLock(promise) { + var self = this; + + if (self.promise) return; + + self.element.addClass(CLS_BUSY); + + // at form or submit button, bind to promise + // otherwise, just lock the field + if (promise) { + self.promise = promise.then(function(response) { + self.busyUnlock(true); + return response; + }, function(error) { + self.busyUnlock(false); + return $q.reject(error); + }); + } + + if (this.isForm) { + setFormBusy(self, true); + } else { + setInputDisable(self, true); + } + } + + function busyUnlock(success) { + this.element.removeClass(CLS_BUSY); + this.promise = null; + + if (this.isForm) { + setFormBusy(this, false); + + if (success && baseConfig.lockOnComplete) { + this.form.$setPristine(); + } + } else { + setInputDisable(this, false); + } + } + + function setFormBusy(self, value) { + self.form.$busy = value; + setFormDisable(self, value); + } + + function lock() { + if (this.locked) return; + + this.locked = true; + this.element.addClass(CLS_LOCKED); + + if (this.isForm) { + setFormDisable(this, true); + } else if (this.isSubmit) { + setInputDisable(this, true); + } else { + setInputDisable(this, false); + } + } + + function unlock() { + if (!this.locked) return; + + this.locked = false; + this.element.removeClass(CLS_LOCKED); + + if (this.isForm) { + setFormDisable(this, false); + } else { + setInputDisable(this, false); + } + } + + function setFormDisable(self, value) { + self.form.$disabled = value; + } + + function setInputDisable(self, value) { + self.attrs.$set('disabled', value); + } + + // helper functions + function bindEvent(self, eventName) { + var attributeName = 'ng' + eventName.charAt(0).toUpperCase() + eventName.slice(1), + fn = $parse(self.attrs[attributeName], /* interceptorFn */ null, /* expensiveChecks */ true); + + self.element.unbind(eventName).bind(eventName, handler); + + function handler($event) { + if (self.locked || self.promise) return; + + var result = fn(self.scope, { + $event: $event + }); + + if (isPromise(result)) { + self.busyLock(result); + } + + self.scope.$apply(); + } + } + + function bindStateTrigger(self, watcher, trigger) { + var form = self.form || false; + if (!form) return; + self.scope.$watch(watcher, trigger); + } + + /** + * Lock/unlock the form + */ + function bindFormState(self) { + var form = self.form; + bindStateTrigger(self, isInvalid, updateLockOnForm); + + function isInvalid() { + return form.$pristine + '' + form.$invalid; + } + + function updateLockOnForm() { + if ( + (form.$pristine && self.options.pristine !== false) || + (form.$invalid && self.options.invalid !== false) + ) { + self.lock(); + return; + } + + self.unlock(); + } + } + + function bindChildState(self) { + var form = self.form; + + bindStateTrigger(self, isFormDisabled, updateChildDisabled); + bindStateTrigger(self, isFormBusy, updateChildBusy); + + function isFormDisabled() { + return form.$disabled; + } + + function isFormBusy() { + return form.$busy; + } + + function updateChildDisabled() { + if (form.$disabled && !form.$busy) { + self.lock(); + } else { + self.unlock(); + } + } + + function updateChildBusy() { + if (form.$busy) { + self.busyLock(); + } else { + self.busyUnlock(); + } + } + } + + function isPromise(value) { + return Boolean(value && typeof value.then === 'function' && + typeof value.finally === 'function'); + } + + return AutoDisable; + } + + module.provider('AutoDisable', function() { + this.$get = ['$parse', '$q', AutoDisableFactory]; + this.config = function(config) { + angular.extend(baseConfig, config); + }; + }); + +})(angular.module('angular-autodisable')); diff --git a/src/autodisable.module.js b/src/autodisable.module.js new file mode 100644 index 0000000..cceab42 --- /dev/null +++ b/src/autodisable.module.js @@ -0,0 +1 @@ +angular.module('angular-autodisable', []); \ No newline at end of file