diff --git a/Gruntfile.js b/Gruntfile.js index 8a7f3d1..31f7436 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -7,6 +7,9 @@ module.exports = function(grunt) { scope: ['dependencies', 'devDependencies'] }); + grunt.loadNpmTasks('grunt-protractor-runner'); + grunt.loadNpmTasks('grunt-protractor-webdriver'); + grunt.initConfig({ // end 2 end testing with protractor protractor: { @@ -27,6 +30,17 @@ module.exports = function(grunt) { } } }, + protractor_webdriver: { + options: { + keepAlive : true + }, + e2eStart: { + options: { + path: './node_modules/.bin/', + command: 'webdriver-manager start --standalone' + }, + }, + }, connect: { server: { options: { @@ -38,7 +52,7 @@ module.exports = function(grunt) { // our protractor server testserver: { options: { - port: 9999 + port: 9998 } }, travisServer: { @@ -121,12 +135,14 @@ module.exports = function(grunt) { ]); grunt.registerTask('autotest:e2e', [ + 'protractor_webdriver:e2eStart', 'connect:testserver', // - starts the app so the test runner can visit the app 'shell:selenium', // - starts selenium server in watch mode 'watch:protractor' // - watches scripts and e2e specs, and starts tests on file change ]); grunt.registerTask('test:e2e', [ + 'protractor_webdriver:e2eStart', 'connect:testserver', // - run concurrent tests 'protractor:singlerun' // - single run protractor ]); diff --git a/bower.json b/bower.json index b256877..9b4d4e2 100644 --- a/bower.json +++ b/bower.json @@ -1,12 +1,12 @@ { "name": "angular-unsavedChanges", - "version": "0.2.3-alpha.1", + "version": "0.2.5", "homepage": "https://github.com/facultymatt/angular-unsavedChanges", "authors": [ - "Matt Miller " + "Matt Miller " ], "description": "AngularJS directive to warn user of unsaved changes when navigating away from a form.", - "main": "unsavedChanges.js", + "main": "dist/unsavedChanges.js", "keywords": [ "form", "angularjs", @@ -24,17 +24,12 @@ "test", "tests" ], - "dependencies": { - "angular": "~1.2.5" - }, "devDependencies": { - "angular-route": "~1.2.2", - "angular-mocks": "~1.2.2", - "angular-scenario": "~1.2.2", + "angular": "~1.5.x", + "angular-route": "~1.5.x", + "angular-mocks": "~1.5.x", + "angular-scenario": "~1.5.x", "jquery": "~2.0.3", - "angular-translate": "latest" - }, - "resolutions": { - "angular": "~1.2.5" + "angular-translate": "^2.0.1" } } diff --git a/changelog.md b/changelog.md index 604e651..05470d2 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,21 @@ Versioning follows [http://semver.org/](http://semver.org/), ie: MAJOR.MINOR.PATCH. Major version 0 is initial development. Minor versions may be backwards incompatible. +### 0.2.3-alpha.2 + +__angular-unsavedChanges will remain in alpha until the e2e tests pass, as per https://github.com/facultymatt/angular-unsavedChanges/issues/25__ + + +- Fixed support for angular translate > 2.0.0 [#14](https://github.com/facultymatt/angular-unsavedChanges/pull/14), thanks to @dmytroyarmak +- Fixed issue where removeFunctions were not being cleared properly [#21](https://github.com/facultymatt/angular-unsavedChanges/pull/21), thanks to @dmytroyarmak +- fix unit tests for jasmine 2.0 +- add test for isolate scope on form element +- require form on element or parent, fixes #22 +- use $window instead of window +- update to angular 1.3, closes #23 + + + ### 0.2.3-alpha.1 - Removed form and model dependencies and code from `resettable` directive. We weren't using the get form functionality anyhow. diff --git a/dist/unsavedChanges.js b/dist/unsavedChanges.js index 41882bd..f2b1262 100644 --- a/dist/unsavedChanges.js +++ b/dist/unsavedChanges.js @@ -65,7 +65,7 @@ angular.module('unsavedChanges', ['resettable']) function translateIfAble(message) { if ($injector.has('$translate') && useTranslateService) { - return $injector.get('$translate')(message); + return $injector.get('$translate').instant(message); } else { return false; } @@ -121,27 +121,21 @@ angular.module('unsavedChanges', ['resettable']) ]; }) -.service('unsavedWarningSharedService', ['$rootScope', 'unsavedWarningsConfig', '$injector', - function($rootScope, unsavedWarningsConfig, $injector) { +.service('unsavedWarningSharedService', ['$rootScope', 'unsavedWarningsConfig', '$injector', '$window', + function($rootScope, unsavedWarningsConfig, $injector, $window) { // Controller scopped variables var _this = this; var allForms = []; var areAllFormsClean = true; - var removeFunctions = [angular.noop]; + var removeFunctions = []; // @note only exposed for testing purposes. this.allForms = function() { return allForms; }; - // save shorthand reference to messages - var messages = { - navigate: unsavedWarningsConfig.navigateMessage, - reload: unsavedWarningsConfig.reloadMessage - }; - - // Check all registered forms + // Check all registered forms // if any one is dirty function will return true function allFormsClean() { @@ -167,7 +161,7 @@ angular.module('unsavedChanges', ['resettable']) var idx = allForms.indexOf(form); // this form is not present array - // @todo needs test coverage + // @todo needs test coverage if (idx === -1) return; allForms.splice(idx, 1); @@ -181,12 +175,13 @@ angular.module('unsavedChanges', ['resettable']) angular.forEach(removeFunctions, function(fn) { fn(); }); - window.onbeforeunload = null; + removeFunctions = []; + $window.onbeforeunload = null; } // Function called when user tries to close the window this.confirmExit = function() { - if (!allFormsClean()) return messages.reload; + if (!allFormsClean()) return unsavedWarningsConfig.reloadMessage; $rootScope.$broadcast('resetResettables'); tearDown(); }; @@ -197,7 +192,7 @@ angular.module('unsavedChanges', ['resettable']) function setup() { unsavedWarningsConfig.log('Setting up'); - window.onbeforeunload = _this.confirmExit; + $window.onbeforeunload = _this.confirmExit; var eventsToWatchFor = unsavedWarningsConfig.routeEvent; @@ -205,16 +200,19 @@ angular.module('unsavedChanges', ['resettable']) //calling this function later will unbind this, acting as $off() var removeFn = $rootScope.$on(aEvent, function(event, next, current) { unsavedWarningsConfig.log("user is moving with " + aEvent); - // @todo this could be written a lot cleaner! + // @todo this could be written a lot cleaner! if (!allFormsClean()) { unsavedWarningsConfig.log("a form is dirty"); - if (!confirm(messages.navigate)) { - unsavedWarningsConfig.log("user wants to cancel leaving"); - event.preventDefault(); // user clicks cancel, wants to stay on page - } else { - unsavedWarningsConfig.log("user doesn't care about loosing stuff"); - $rootScope.$broadcast('resetResettables'); - } + // allow any existing scope digest to complete + setTimeout(function () { + if (!confirm(unsavedWarningsConfig.navigateMessage)) { + unsavedWarningsConfig.log("user wants to cancel leaving"); + event.preventDefault(); // user clicks cancel, wants to stay on page + } else { + unsavedWarningsConfig.log("user doesn't care about loosing stuff"); + $rootScope.$broadcast('resetResettables'); + } + }); } else { unsavedWarningsConfig.log("all forms are clean"); } @@ -242,13 +240,27 @@ angular.module('unsavedChanges', ['resettable']) } ]) -.directive('unsavedWarningForm', ['unsavedWarningSharedService', '$rootScope', - function(unsavedWarningSharedService, $rootScope) { +.directive('unsavedWarningForm', ['unsavedWarningSharedService', '$rootScope', '$timeout', + function(unsavedWarningSharedService, $rootScope, $timeout) { return { scope: {}, - require: 'form', + require: '^form', link: function(scope, formElement, attrs, formCtrl) { + // @todo refactor, temp fix for issue #22 + // where user might use form on element inside a form + // we shouldnt need isolate scope on this, but it causes the tests to fail + // traverse up parent elements to find the form. + // we need a form element since we bind to form events: submit, reset + var count = 0; + while(formElement[0].tagName !== 'FORM' && count < 3) { + count++; + formElement = formElement.parent(); + } + if(count >= 3) { + throw('unsavedWarningForm must be inside a form element'); + } + // register this form unsavedWarningSharedService.init(formCtrl); @@ -265,20 +277,21 @@ angular.module('unsavedChanges', ['resettable']) // do things like reset validation, present messages, etc. formElement.bind('reset', function(event) { event.preventDefault(); - // because we bind to `resetResettables` also when - // dismissing alerts, we need to apply() in this - // instance to ensure the model view updates. - // @note for ngActiveResoruce, where the models - // themselves do validation, we can't rely on just - // setting the form to valid - we need to set each - // model value back to valid. - scope.$apply($rootScope.$broadcast('resetResettables')); + + // trigger resettables within this form or element + var resettables = angular.element(formElement[0].querySelector('[resettable]')); + if(resettables.length) { + // use safer method than $apply + $timeout(function () { + resettables.triggerHandler('resetResettables'); + }); + } // sets for back to valid and pristine states formCtrl.$setPristine(); }); - // @todo check destroy on clear button too? + // @todo check destroy on clear button too? scope.$on('$destroy', function() { unsavedWarningSharedService.removeForm(formCtrl); }); @@ -300,6 +313,10 @@ angular.module('unsavedChanges', ['resettable']) * to original value. * -------------------------------------------- * + * @note we don't create a seperate scope so the model value + * is still available onChange within the controller scope. + * This fixes https://github.com/facultymatt/angular-unsavedChanges/issues/19 + * */ angular.module('resettable', []) @@ -307,13 +324,12 @@ angular.module('resettable', []) function($parse, $compile, $rootScope) { return { - scope: true, restrict: 'A', link: function postLink(scope, elem, attr, ngModelCtrl) { var setter, getter, originalValue; - // save getters and setters and store the original value. + // save getters and setters and store the original value. attr.$observe('ngModel', function(newValue) { getter = $parse(attr.ngModel); setter = getter.assign; @@ -325,6 +341,8 @@ angular.module('resettable', []) setter(scope, originalValue); }; + elem.on('resetResettables', resetFn); + // @note this doesn't work if called using // $rootScope.on() and $rootScope.$emit() pattern var removeListenerFn = scope.$on('resetResettables', resetFn); diff --git a/dist/unsavedChanges.min.js b/dist/unsavedChanges.min.js index 29bc868..d2d1053 100644 --- a/dist/unsavedChanges.min.js +++ b/dist/unsavedChanges.min.js @@ -1 +1 @@ -"use strict";angular.module("unsavedChanges",["resettable"]).provider("unsavedWarningsConfig",function(){var f=this;var e=false;var b=true;var d=["$locationChangeStart","$stateChangeStart"];var c="You will lose unsaved changes if you leave this page";var a="You will lose unsaved changes if you reload this page";Object.defineProperty(f,"navigateMessage",{get:function(){return c},set:function(g){c=g}});Object.defineProperty(f,"reloadMessage",{get:function(){return a},set:function(g){a=g}});Object.defineProperty(f,"useTranslateService",{get:function(){return b},set:function(g){b=!!(g)}});Object.defineProperty(f,"routeEvent",{get:function(){return d},set:function(g){if(typeof g==="string"){g=[g]}d=g}});Object.defineProperty(f,"logEnabled",{get:function(){return e},set:function(g){e=!!(g)}});this.$get=["$injector",function(h){function i(j){if(h.has("$translate")&&b){return h.get("$translate")(j)}else{return false}}var g={log:function(){if(console.log&&e&&arguments.length){var j=[].slice.call(arguments);if(typeof console.log==="object"){log.apply.call(console.log,console,j)}else{console.log.apply(console,j)}}}};Object.defineProperty(g,"useTranslateService",{get:function(){return b}});Object.defineProperty(g,"reloadMessage",{get:function(){return i(a)||a}});Object.defineProperty(g,"navigateMessage",{get:function(){return i(c)||c}});Object.defineProperty(g,"routeEvent",{get:function(){return d}});Object.defineProperty(g,"logEnabled",{get:function(){return e}});return g}]}).service("unsavedWarningSharedService",["$rootScope","unsavedWarningsConfig","$injector",function(j,c,k){var i=this;var a=[];var g=true;var d=[angular.noop];this.allForms=function(){return a};var f={navigate:c.navigateMessage,reload:c.reloadMessage};function h(){g=true;angular.forEach(a,function(m,l){c.log("Form : "+m.$name+" dirty : "+m.$dirty);if(m.$dirty){g=false}});return g}this.init=function(l){if(a.length===0){e()}c.log("Registering form",l);a.push(l)};this.removeForm=function(m){var l=a.indexOf(m);if(l===-1){return}a.splice(l,1);c.log("Removing form from watch list",m);if(a.length===0){b()}};function b(){c.log("No more forms, tearing down");angular.forEach(d,function(l){l()});window.onbeforeunload=null}this.confirmExit=function(){if(!h()){return f.reload}j.$broadcast("resetResettables");b()};function e(){c.log("Setting up");window.onbeforeunload=i.confirmExit;var l=c.routeEvent;angular.forEach(l,function(m){var n=j.$on(m,function(p,o,q){c.log("user is moving with "+m);if(!h()){c.log("a form is dirty");if(!confirm(f.navigate)){c.log("user wants to cancel leaving");p.preventDefault()}else{c.log("user doesn't care about loosing stuff");j.$broadcast("resetResettables")}}else{c.log("all forms are clean")}});d.push(n)})}}]).directive("unsavedWarningClear",["unsavedWarningSharedService",function(a){return{scope:{},require:"^form",priority:10,link:function(d,c,b,e){c.bind("click",function(f){e.$setPristine()})}}}]).directive("unsavedWarningForm",["unsavedWarningSharedService","$rootScope",function(b,a){return{scope:{},require:"form",link:function(e,d,c,f){b.init(f);d.bind("submit",function(g){if(f.$valid){f.$setPristine()}});d.bind("reset",function(g){g.preventDefault();e.$apply(a.$broadcast("resetResettables"));f.$setPristine()});e.$on("$destroy",function(){b.removeForm(f)})}}}]);angular.module("resettable",[]).directive("resettable",["$parse","$compile","$rootScope",function(d,c,b){return{scope:true,restrict:"A",link:function a(m,g,j,e){var h,k,l;j.$observe("ngModel",function(n){k=d(j.ngModel);h=k.assign;l=k(m)});var i=function(){h(m,l)};var f=m.$on("resetResettables",i);m.$on("$destroy",f)}}}]); \ No newline at end of file +"use strict";angular.module("unsavedChanges",["resettable"]).provider("unsavedWarningsConfig",function(){var f=this;var e=false;var b=true;var d=["$locationChangeStart","$stateChangeStart"];var c="You will lose unsaved changes if you leave this page";var a="You will lose unsaved changes if you reload this page";Object.defineProperty(f,"navigateMessage",{get:function(){return c},set:function(g){c=g}});Object.defineProperty(f,"reloadMessage",{get:function(){return a},set:function(g){a=g}});Object.defineProperty(f,"useTranslateService",{get:function(){return b},set:function(g){b=!!(g)}});Object.defineProperty(f,"routeEvent",{get:function(){return d},set:function(g){if(typeof g==="string"){g=[g]}d=g}});Object.defineProperty(f,"logEnabled",{get:function(){return e},set:function(g){e=!!(g)}});this.$get=["$injector",function(h){function i(j){if(h.has("$translate")&&b){return h.get("$translate").instant(j)}else{return false}}var g={log:function(){if(console.log&&e&&arguments.length){var j=[].slice.call(arguments);if(typeof console.log==="object"){log.apply.call(console.log,console,j)}else{console.log.apply(console,j)}}}};Object.defineProperty(g,"useTranslateService",{get:function(){return b}});Object.defineProperty(g,"reloadMessage",{get:function(){return i(a)||a}});Object.defineProperty(g,"navigateMessage",{get:function(){return i(c)||c}});Object.defineProperty(g,"routeEvent",{get:function(){return d}});Object.defineProperty(g,"logEnabled",{get:function(){return e}});return g}]}).service("unsavedWarningSharedService",["$rootScope","unsavedWarningsConfig","$injector","$window",function(j,c,k,d){var i=this;var a=[];var g=true;var e=[];this.allForms=function(){return a};function h(){g=true;angular.forEach(a,function(m,l){c.log("Form : "+m.$name+" dirty : "+m.$dirty);if(m.$dirty){g=false}});return g}this.init=function(l){if(a.length===0){f()}c.log("Registering form",l);a.push(l)};this.removeForm=function(m){var l=a.indexOf(m);if(l===-1){return}a.splice(l,1);c.log("Removing form from watch list",m);if(a.length===0){b()}};function b(){c.log("No more forms, tearing down");angular.forEach(e,function(l){l()});e=[];d.onbeforeunload=null}this.confirmExit=function(){if(!h()){return c.reloadMessage}j.$broadcast("resetResettables");b()};function f(){c.log("Setting up");d.onbeforeunload=i.confirmExit;var l=c.routeEvent;angular.forEach(l,function(m){var n=j.$on(m,function(p,o,q){c.log("user is moving with "+m);if(!h()){c.log("a form is dirty");setTimeout(function(){if(!confirm(c.navigateMessage)){c.log("user wants to cancel leaving");p.preventDefault()}else{c.log("user doesn't care about loosing stuff");j.$broadcast("resetResettables")}})}else{c.log("all forms are clean")}});e.push(n)})}}]).directive("unsavedWarningClear",["unsavedWarningSharedService",function(a){return{scope:{},require:"^form",priority:10,link:function(d,c,b,e){c.bind("click",function(f){e.$setPristine()})}}}]).directive("unsavedWarningForm",["unsavedWarningSharedService","$rootScope","$timeout",function(c,a,b){return{scope:{},require:"^form",link:function(f,e,d,h){var g=0;while(e[0].tagName!=="FORM"&&g<3){g++;e=e.parent()}if(g>=3){throw ("unsavedWarningForm must be inside a form element")}c.init(h);e.bind("submit",function(i){if(h.$valid){h.$setPristine()}});e.bind("reset",function(i){i.preventDefault();var j=angular.element(e[0].querySelector("[resettable]"));if(j.length){b(function(){j.triggerHandler("resetResettables")})}h.$setPristine()});f.$on("$destroy",function(){c.removeForm(h)})}}}]);angular.module("resettable",[]).directive("resettable",["$parse","$compile","$rootScope",function(d,c,b){return{restrict:"A",link:function a(m,g,j,e){var h,k,l;j.$observe("ngModel",function(n){k=d(j.ngModel);h=k.assign;l=k(m)});var i=function(){h(m,l)};g.on("resetResettables",i);var f=m.$on("resetResettables",i);m.$on("$destroy",f)}}}]); \ No newline at end of file diff --git a/package.json b/package.json index e98ed42..a726195 100644 --- a/package.json +++ b/package.json @@ -1,52 +1,54 @@ { "name": "angular-unsavedChanges", - "version": "0.2.3-alpha.1", + "version": "0.2.5", "description": "AngularJS directive to warn user of unsaved changes when navigating away from a form.", "main": "Gruntfile.js", "devDependencies": { - "grunt-strip": "~0.2.1", + "bower": "^1.7.9", "express": "latest", - "bower": "~1.2.6", - "grunt": "~0.4.1", - "grunt-shell": "~0.4.0", + "grunt": "^1.0.1", + "grunt-concurrent": "^2.3.1", + "grunt-contrib-clean": "^1.0.0", + "grunt-contrib-concat": "^1.0.1", + "grunt-contrib-connect": "^1.0.2", + "grunt-contrib-copy": "^1.0.0", + "grunt-contrib-cssmin": "^1.0.1", + "grunt-contrib-jshint": "^1.0.0", + "grunt-contrib-uglify": "^2.0.0", + "grunt-contrib-watch": "^1.0.0", + "grunt-jsbeautifier": "~0.2.3", + "grunt-karma": "latest", "grunt-open": "~0.2.2", - "grunt-contrib-copy": "~0.4.1", - "grunt-contrib-concat": "~0.3.0", - "grunt-contrib-uglify": "~0.2.0", - "grunt-contrib-jshint": "~0.6.0", - "grunt-contrib-cssmin": "~0.6.0", - "grunt-contrib-connect": "~0.5.0", - "grunt-contrib-clean": "~0.5.0", - "grunt-contrib-watch": "~0.5.3", - "grunt-usemin": "~0.1.11", + "grunt-protractor-runner": "^3.2.0", + "grunt-replace": "^1.0.1", "grunt-rev": "~0.1.0", - "grunt-concurrent": "~0.3.0", - "load-grunt-tasks": "~0.2.0", - "time-grunt": "~0.1.0", - "grunt-karma": "latest", - "karma-script-launcher": "latest", + "grunt-shell": "^1.3.1", + "grunt-strip": "~0.2.1", + "grunt-usemin": "^3.1.1", + "grunt-yui-compressor": "^0.4.0", + "karma": "latest", "karma-chrome-launcher": "latest", + "karma-coverage": "latest", "karma-firefox-launcher": "latest", "karma-html2js-preprocessor": "latest", "karma-jasmine": "latest", - "karma-requirejs": "latest", - "karma-coverage": "latest", "karma-osx-reporter": "latest", "karma-phantomjs-launcher": "latest", - "karma": "latest", + "karma-requirejs": "latest", + "karma-script-launcher": "latest", + "load-grunt-tasks": "^3.5.2", "protractor": "latest", - "grunt-protractor-runner": "latest", - "grunt-jsbeautifier": "~0.2.3", - "grunt-replace": "~0.5.1", - "grunt-contrib-jshint": "~0.6.3", - "grunt-yui-compressor": "~0.3.0" + "time-grunt": "^1.4.0" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "grunt", + "dev": "grunt server", + "test": "grunt test", + "test:e2e": "grunt test:e2e" }, "repository": { "type": "git", - "url": "https://github.com/facultymatt/angular-unsavedChanges.git" + "url": "https://github.com/naviplus-asp/angular-unsavedChanges.git" }, "keywords": [ "form", diff --git a/protractor.conf.js b/protractor.conf.js index 99997b8..d9e0b2d 100644 --- a/protractor.conf.js +++ b/protractor.conf.js @@ -48,7 +48,7 @@ exports.config = { // A base URL for your application under test. Calls to protractor.get() // with relative paths will be prepended with this. - baseUrl: 'http://localhost:9999', + baseUrl: 'http://localhost:9998', // Selector for the element housing the angular app - this defaults to // body, but is necessary if ng-app is on a descendant of diff --git a/readme.md b/readme.md index ae35068..dbbe2eb 100644 --- a/readme.md +++ b/readme.md @@ -44,8 +44,20 @@ Add to forms you want to register with directive. The module will only listen wh ``` +Optionally, you can add to an element within a form: + +``` +
+
+
+
+``` + +When used in this way, it must be no more then 3 levels nested within parent form. + + #### unsaved-warning-clear -Add to button or link that will disregard changes, preventing the messaging when user tries to navigate. Note that button type should be `reset` to work with `lazy-model` directive (outlined below). +Add to button or link that will disregard changes, preventing the messaging when user tries to navigate. Note that button type should be `reset`. ```
@@ -62,6 +74,11 @@ Add to inputs that use `ng-model` to reset model values when user dismisses chan ``` +Note that if you have multiple forms on the page, only the model values inside the form which was reset will be effected. + +On page change or reload, all model values will be effected. + + ### Provider Configuration A number of options can be configured. The module uses the `Object.defineProperty` pattern. This avoids the need for custom getters and setters and allows us to treat configuration as pure JS objects. diff --git a/src/unsavedChanges.js b/src/unsavedChanges.js index 41882bd..7e9915c 100644 --- a/src/unsavedChanges.js +++ b/src/unsavedChanges.js @@ -65,7 +65,7 @@ angular.module('unsavedChanges', ['resettable']) function translateIfAble(message) { if ($injector.has('$translate') && useTranslateService) { - return $injector.get('$translate')(message); + return $injector.get('$translate').instant(message); } else { return false; } @@ -121,38 +121,28 @@ angular.module('unsavedChanges', ['resettable']) ]; }) -.service('unsavedWarningSharedService', ['$rootScope', 'unsavedWarningsConfig', '$injector', - function($rootScope, unsavedWarningsConfig, $injector) { +.service('unsavedWarningSharedService', ['$rootScope', 'unsavedWarningsConfig', '$injector', '$window', + function($rootScope, unsavedWarningsConfig, $injector, $window) { // Controller scopped variables var _this = this; var allForms = []; var areAllFormsClean = true; - var removeFunctions = [angular.noop]; + var removeFunctions = []; // @note only exposed for testing purposes. this.allForms = function() { return allForms; }; - // save shorthand reference to messages - var messages = { - navigate: unsavedWarningsConfig.navigateMessage, - reload: unsavedWarningsConfig.reloadMessage - }; - - // Check all registered forms + // Check all registered forms // if any one is dirty function will return true function allFormsClean() { - areAllFormsClean = true; - angular.forEach(allForms, function(item, idx) { - unsavedWarningsConfig.log('Form : ' + item.$name + ' dirty : ' + item.$dirty); - if (item.$dirty) { - areAllFormsClean = false; - } + return allForms.every(function(form, idx) { + unsavedWarningsConfig.log('Form : ' + form.$name + ' dirty : ' + form.$dirty); + return form.$pristine; }); - return areAllFormsClean; // no dirty forms were found } // adds form controller to registered forms array @@ -167,7 +157,7 @@ angular.module('unsavedChanges', ['resettable']) var idx = allForms.indexOf(form); // this form is not present array - // @todo needs test coverage + // @todo needs test coverage if (idx === -1) return; allForms.splice(idx, 1); @@ -181,12 +171,13 @@ angular.module('unsavedChanges', ['resettable']) angular.forEach(removeFunctions, function(fn) { fn(); }); - window.onbeforeunload = null; + removeFunctions = []; + $window.onbeforeunload = null; } // Function called when user tries to close the window this.confirmExit = function() { - if (!allFormsClean()) return messages.reload; + if (!allFormsClean()) return unsavedWarningsConfig.reloadMessage; $rootScope.$broadcast('resetResettables'); tearDown(); }; @@ -197,7 +188,8 @@ angular.module('unsavedChanges', ['resettable']) function setup() { unsavedWarningsConfig.log('Setting up'); - window.onbeforeunload = _this.confirmExit; + $window.onbeforeunload = _this.confirmExit; + var eventsToWatchFor = unsavedWarningsConfig.routeEvent; @@ -205,16 +197,19 @@ angular.module('unsavedChanges', ['resettable']) //calling this function later will unbind this, acting as $off() var removeFn = $rootScope.$on(aEvent, function(event, next, current) { unsavedWarningsConfig.log("user is moving with " + aEvent); - // @todo this could be written a lot cleaner! + // @todo this could be written a lot cleaner! if (!allFormsClean()) { unsavedWarningsConfig.log("a form is dirty"); - if (!confirm(messages.navigate)) { - unsavedWarningsConfig.log("user wants to cancel leaving"); - event.preventDefault(); // user clicks cancel, wants to stay on page - } else { - unsavedWarningsConfig.log("user doesn't care about loosing stuff"); - $rootScope.$broadcast('resetResettables'); - } + // allow any existing scope digest to complete + setTimeout(function () { + if (!confirm(unsavedWarningsConfig.navigateMessage)) { + unsavedWarningsConfig.log("user wants to cancel leaving"); + event.preventDefault(); // user clicks cancel, wants to stay on page + } else { + unsavedWarningsConfig.log("user doesn't care about loosing stuff"); + $rootScope.$broadcast('resetResettables'); + } + }); } else { unsavedWarningsConfig.log("all forms are clean"); } @@ -242,13 +237,27 @@ angular.module('unsavedChanges', ['resettable']) } ]) -.directive('unsavedWarningForm', ['unsavedWarningSharedService', '$rootScope', - function(unsavedWarningSharedService, $rootScope) { +.directive('unsavedWarningForm', ['unsavedWarningSharedService', '$rootScope', '$timeout', + function(unsavedWarningSharedService, $rootScope, $timeout) { return { scope: {}, - require: 'form', + require: '^form', link: function(scope, formElement, attrs, formCtrl) { + // @todo refactor, temp fix for issue #22 + // where user might use form on element inside a form + // we shouldnt need isolate scope on this, but it causes the tests to fail + // traverse up parent elements to find the form. + // we need a form element since we bind to form events: submit, reset + var count = 0; + while(formElement[0].tagName !== 'FORM' && count < 3) { + count++; + formElement = formElement.parent(); + } + if(count >= 3) { + throw('unsavedWarningForm must be inside a form element'); + } + // register this form unsavedWarningSharedService.init(formCtrl); @@ -265,20 +274,21 @@ angular.module('unsavedChanges', ['resettable']) // do things like reset validation, present messages, etc. formElement.bind('reset', function(event) { event.preventDefault(); - // because we bind to `resetResettables` also when - // dismissing alerts, we need to apply() in this - // instance to ensure the model view updates. - // @note for ngActiveResoruce, where the models - // themselves do validation, we can't rely on just - // setting the form to valid - we need to set each - // model value back to valid. - scope.$apply($rootScope.$broadcast('resetResettables')); + + // trigger resettables within this form or element + var resettables = angular.element(formElement[0].querySelector('[resettable]')); + if(resettables.length) { + // use safer method than $apply + $timeout(function () { + resettables.triggerHandler('resetResettables'); + }); + } // sets for back to valid and pristine states formCtrl.$setPristine(); }); - // @todo check destroy on clear button too? + // @todo check destroy on clear button too? scope.$on('$destroy', function() { unsavedWarningSharedService.removeForm(formCtrl); }); @@ -300,6 +310,10 @@ angular.module('unsavedChanges', ['resettable']) * to original value. * -------------------------------------------- * + * @note we don't create a seperate scope so the model value + * is still available onChange within the controller scope. + * This fixes https://github.com/facultymatt/angular-unsavedChanges/issues/19 + * */ angular.module('resettable', []) @@ -307,13 +321,12 @@ angular.module('resettable', []) function($parse, $compile, $rootScope) { return { - scope: true, restrict: 'A', link: function postLink(scope, elem, attr, ngModelCtrl) { var setter, getter, originalValue; - // save getters and setters and store the original value. + // save getters and setters and store the original value. attr.$observe('ngModel', function(newValue) { getter = $parse(attr.ngModel); setter = getter.assign; @@ -325,6 +338,8 @@ angular.module('resettable', []) setter(scope, originalValue); }; + elem.on('resetResettables', resetFn); + // @note this doesn't work if called using // $rootScope.on() and $rootScope.$emit() pattern var removeListenerFn = scope.$on('resetResettables', resetFn); diff --git a/test/e2e/unsavedChanges.e2e.js b/test/e2e/unsavedChanges.e2e.js index 127e7da..d34bbfb 100644 --- a/test/e2e/unsavedChanges.e2e.js +++ b/test/e2e/unsavedChanges.e2e.js @@ -7,6 +7,7 @@ var alertDialog; var cleanUp = function() { browser.navigate().refresh(); + browser.ignoreSynchronization = true; alertDialog = browser.switchTo().alert(); alertDialog.accept(); }; @@ -23,9 +24,11 @@ describe('When single form is dirty', function() { describe('when user clicks a link', function() { - beforeEach(function() { + beforeEach(function(done) { + browser.ignoreSynchronization = true; element(by.id('page2')).click(); - alertDialog = browser.switchTo().alert(); + alertDialog = browser.switchTo().alert() + alertDialog.then(done); }); it('should alert user', function() { @@ -79,9 +82,11 @@ describe('When single form is dirty', function() { describe('when user refreshes page', function() { - beforeEach(function() { + beforeEach(function(done) { + browser.ignoreSynchronization = true; browser.navigate().refresh(); - alertDialog = browser.switchTo().alert(); + alertDialog = browser.switchTo().alert() + alertDialog.then(done); }); it('should alert user', function() { @@ -131,9 +136,13 @@ describe('When single form is dirty', function() { describe('when user clicks back button', function() { - beforeEach(function() { - browser.navigate().back(); - alertDialog = browser.switchTo().alert(); + beforeEach(function (done) { + browser.ignoreSynchronization = true; + browser.navigate().back().catch(function () { + browser.switchTo().alert().then(function (dialog) { + alertDialog = dialog; + }) + }).then(done) }); it('should alert user', function() { @@ -153,7 +162,7 @@ describe('When single form is dirty', function() { describe('when user rejects alert', function() { beforeEach(function() { - alertDialog.dismiss(); + alertDialog.dismiss() }); it('should stay on page', function() { @@ -187,8 +196,9 @@ describe('When single form is dirty', function() { afterEach(function() { browser.navigate().refresh(); + browser.ignoreSynchronization = true; alertDialog = browser.switchTo().alert(); - alertDialog.accept(); + alertDialog.then(alertDialog.accept) }); describe('user clicks link, dismisses, then clicks link again', function() { @@ -215,6 +225,7 @@ describe('When single form is dirty', function() { describe('user clicks link, dismisses, then refreshes page', function() { beforeEach(function() { + browser.ignoreSynchronization = true; element(by.id('page2')).click(); alertDialog = browser.switchTo().alert(); alertDialog.dismiss(); @@ -236,6 +247,7 @@ describe('When single form is dirty', function() { describe('user refreshes, dismisses, then clicks link', function() { beforeEach(function() { + browser.ignoreSynchronization = true; browser.navigate().refresh(); alertDialog = browser.switchTo().alert(); alertDialog.dismiss(); @@ -257,6 +269,7 @@ describe('When single form is dirty', function() { describe('user refreshes, dismisses, then refreshes', function() { beforeEach(function() { + browser.ignoreSynchronization = true; browser.navigate().refresh(); alertDialog = browser.switchTo().alert(); alertDialog.dismiss(); @@ -343,7 +356,7 @@ describe('When single form is dirty', function() { expect(browser.getCurrentUrl()).toContain('/page2'); }); }); - + }); }); @@ -361,6 +374,7 @@ describe('When multiple forms are dirty', function() { }); it('should only show one message (versus three)', function() { + browser.ignoreSynchronization = true; element(by.id('page1')).click(); alertDialog = browser.switchTo().alert(); alertDialog.accept(); diff --git a/test/unit/unsavedChanges.spec.js b/test/unit/unsavedChanges.spec.js index 265acaa..164ca44 100644 --- a/test/unit/unsavedChanges.spec.js +++ b/test/unit/unsavedChanges.spec.js @@ -103,9 +103,9 @@ describe('UnsavedChanges', function() { controllerScope.test = 'default value'; // @note logs will not occur if we are not calling through - spyOn(console, 'log').andCallThrough(); - spyOn(controllerScope.testForm, '$setPristine').andCallThrough(); - spyOn($rootScope, '$broadcast').andCallThrough(); + spyOn(console, 'log').and.callThrough(); + spyOn(controllerScope.testForm, '$setPristine').and.callThrough(); + spyOn($rootScope, '$broadcast').and.callThrough(); })); @@ -127,7 +127,7 @@ describe('UnsavedChanges', function() { expect(controllerScope.testForm.$setPristine).toHaveBeenCalled(); }); - it('calls $broadcast message when clicked', function() { + xit('calls $broadcast message when clicked', function() { expect($rootScope.$broadcast).toHaveBeenCalledWith('resetResettables'); }); @@ -135,7 +135,9 @@ describe('UnsavedChanges', function() { describe('Form', function() { - it('creates isolate scope', function() {}); + it('creates isolate scope', function() { + expect(controllerScope.$parent).toEqual($rootScope); + }); it('adds listener to onbeforeunload to detect page reload', function() { expect($window.onbeforeunload.toString()).toContain('allFormsClean()'); @@ -300,6 +302,113 @@ describe('UnsavedChanges', function() { expect(controllerScope.testForm.test.$modelValue).toEqual(undefined); }); + it('only resets values within this form', function() { + + var newScope = $rootScope.$new(); + newScope.test1 = 'test1 default'; + newScope.test2 = 'test2 default'; + newScope.test3 = 'test3 default'; + + formTemplate = angular.element('
' + + '' + + '' + + '' + + '' + + + '
' + + '' + + '' + + '
' + + + '
' + + '' + + '' + + '
' + + + '
'); + + $compile(formTemplate)(newScope); + newScope.$digest(); + + // change values for all inputs + newScope.test1 = 'test1 changed'; + newScope.test2 = 'test2 changed'; + newScope.test3 = 'test3 changed'; + + // clear form one changes only + formTemplate.find('#clear1').click(); + newScope.$digest(); + + expect(newScope.test1).toEqual('test1 default'); + expect(newScope.test2).toEqual('test2 changed'); + expect(newScope.test3).toEqual('test3 changed'); + + // debugging scope + // expect(newScope.$parent).toEqual($rootScope); + // expect(formTemplate.find('#form1').scope()).toEqual(newScope); + // expect(formTemplate.find('#form2').scope()).toEqual(newScope); + // expect(formTemplate.find('#form3').scope()).toEqual(newScope); + + }); + + it('can be used as child element of form', function() { + + var newScope = $rootScope.$new(); + newScope.test1 = 'test1 default'; + + formTemplate = angular.element('
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + + '
'); + + $compile(formTemplate)(newScope); + newScope.$digest(); + + // change values for all inputs + newScope.test1 = 'test1 changed'; + + // clear form one changes only + formTemplate.find('#clear1').click(); + newScope.$digest(); + + expect(newScope.test1).toEqual('test1 default'); + + }); + + it('exposes new value on ng-change', function() { + var didFire = false; + + var newScope = $rootScope.$new(); + newScope.test = 'a new default value'; + + newScope.didChange = function(newValue) { + didFire = newScope.test; + } + formTemplate = angular.element('
' + + '
' + + '' + + '' + + '
' + + '
'); + + $compile(formTemplate)(newScope); + + inputCtrl = formTemplate.find('#test').controller('ngModel'); + inputCtrl.$setViewValue('things are changing'); + + controllerScope.$digest(); + + expect(didFire).toEqual('things are changing'); + expect(newScope.newForm.test.$modelValue).toEqual('things are changing'); + }); + it('resets to original model value on form navigate', function() {}); it('observes ngModel and only sets original value when value is resolved', function() {});