First commit

This commit is contained in:
Theodotos Andreou 2018-01-14 13:10:16 +00:00
commit c6e2478c40
13918 changed files with 2303184 additions and 0 deletions

View file

@ -0,0 +1,49 @@
{
"name": "angular-unsavedChanges",
"version": "0.1.1",
"homepage": "https://github.com/facultymatt/angular-unsavedChanges",
"authors": [
"Matt Miller <matt@facultycreative.com>"
],
"description": "AngularJS directive to warn user of unsaved changes when navigating away from a form.",
"main": "unsavedChanges.js",
"keywords": [
"form",
"angularjs",
"unsaved",
"changes",
"warning",
"dirty",
"reload"
],
"license": "MIT",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"angular": "~1.2.5"
},
"devDependencies": {
"angular-route": "~1.2.2",
"angular-mocks": "~1.2.2",
"angular-scenario": "~1.2.2",
"jquery": "~2.0.3",
"angular-translate": "latest"
},
"resolutions": {
"angular": "~1.2.5"
},
"_release": "0.1.1",
"_resolution": {
"type": "version",
"tag": "v0.1.1",
"commit": "9bba1eba672e0e6169ff41ba02db1ce2c1c6acc7"
},
"_source": "https://github.com/facultymatt/angular-unsavedChanges.git",
"_target": "~0.1.1",
"_originalSource": "angular-unsavedChanges"
}

View file

@ -0,0 +1,139 @@
// @todo configure grunt default stuff to run on every save so we know that
// dist is always up to date and jsLinted
module.exports = function(grunt) {
require('load-grunt-tasks')(grunt, {
scope: ['dependencies', 'devDependencies']
});
grunt.initConfig({
// end 2 end testing with protractor
protractor: {
options: {
keepAlive: false,
configFile: './protractor.conf.js'
},
singlerun: {},
travis: {
configFile: './protractor_travis.conf.js'
},
auto: {
keepAlive: true,
options: {
args: {
seleniumPort: 4444
}
}
}
},
connect: {
server: {
options: {
port: 9001,
open: 'http://localhost:9001/demo',
keepalive: true
}
},
// our protractor server
testserver: {
options: {
port: 9999
}
},
travisServer: {
options: {
port: 9999
}
},
},
// watch tasks
// Watch specified files for changes and execute tasks on change
watch: {
livereload: {
options: {
livereload: true
},
files: [
'src/*.js',
'demo/*.js'
],
tasks: ['jshint']
},
},
karma: {
plugins: [
'karma-osx-reporter'
],
unit: {
configFile: 'karma-unit.conf.js',
autoWatch: false,
singleRun: true
},
unitAuto: {
configFile: 'karma-unit.conf.js',
autoWatch: true,
singleRun: false
}
},
'min': {
'dist': {
'src': ['dist/unsavedChanges.js'],
'dest': 'dist/unsavedChanges.min.js'
}
},
jshint: {
all: ['src/*.js']
},
strip: {
main: {
src: 'src/unsavedChanges.js',
dest: 'dist/unsavedChanges.js'
}
}
});
grunt.registerTask('test', [
'test:unit'
]);
grunt.registerTask('server', [
'connect:server'
]);
grunt.registerTask('test:unit', [
'karma:unit'
]);
grunt.registerTask('autotest', [
'autotest:unit'
]);
grunt.registerTask('autotest:unit', [
'karma:unitAuto'
]);
grunt.registerTask('default', [
'jshint',
'strip:main',
'min'
]);
grunt.registerTask('autotest:e2e', [
'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', [
'connect:testserver', // - run concurrent tests
'protractor:singlerun' // - single run protractor
]);
grunt.registerTask('test:travis', [
'connect:travisServer', // - run concurrent tests
'karma:unit' // - single run karma unit
]);
};

View file

@ -0,0 +1,40 @@
{
"name": "angular-unsavedChanges",
"version": "0.1.1",
"homepage": "https://github.com/facultymatt/angular-unsavedChanges",
"authors": [
"Matt Miller <matt@facultycreative.com>"
],
"description": "AngularJS directive to warn user of unsaved changes when navigating away from a form.",
"main": "unsavedChanges.js",
"keywords": [
"form",
"angularjs",
"unsaved",
"changes",
"warning",
"dirty",
"reload"
],
"license": "MIT",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"angular": "~1.2.5"
},
"devDependencies": {
"angular-route": "~1.2.2",
"angular-mocks": "~1.2.2",
"angular-scenario": "~1.2.2",
"jquery": "~2.0.3",
"angular-translate": "latest"
},
"resolutions": {
"angular": "~1.2.5"
}
}

View file

@ -0,0 +1,41 @@
# Changelog
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.1.0
**Features**
- Add `lazy-model` directive, and change `clear` buttons to `type="reset"` which allows for resetting the model to original values. Furthermore values are only persisted to model if user submits valid form.
- Only set pristine when clearing changes if form is valid. (https://github.com/facultymatt/angular-unsavedChanges/commit/26cd981397f3e1e637280e3778aa80708821dab4). The lazy-model form reset hook handles resetting the value.
- Directive now removes onbeforeunload and route change listeners if no registered forms exist on the page. (https://github.com/facultymatt/angular-unsavedChanges/commit/58cad5401656bb806183d0a42c8b81bf1fbeeac6)
**Breaking Changes**
- Change getters and setters to user NJO (native javascript objects). This means that insated of setting `provider.setUseTranslateService(true)` you can natively set `provider.useTranslateService = true`. This may seem like semantics but if follows one of angulars core principals.
### 0.0.3
**Tests**
- Add full set of unit and e2e tests
**Features**
- Add config option for custom messages
- Add support for uiRouter state change event via. config
- Add support for Angular Translate
- Add custom logging method for development
**Chores**
- Add module to bower.
**Breaking Changes**
- Changed name from `mm.unsavedChanges` to `unsavedChanges`
### 0.0.2 and below
Offical changelog was not maintained for these versions.

View file

@ -0,0 +1,43 @@
angular
.module('app', ['unsavedChanges', 'ngRoute'])
.config(['$routeProvider', 'unsavedWarningsConfigProvider',
function($routeProvider, unsavedWarningsConfigProvider) {
$routeProvider
.when('/page1', {
templateUrl: 'page1.html'
})
.when('/page2', {
templateUrl: 'page2.html'
})
.otherwise({
redirectTo: '/page1'
});
// We can turn on logging through this provider method
// We uncomment out the below line in order to watch for angular-ui router events
// rather than standard Angular router events. The default event is $locationChangeStart
//unsavedWarningsConfigProvider.setRouteEventToWatchFor('$stateChangeStart');
// We uncomment out the below line in order to change the navigate message
//unsavedWarningsConfigProvider.setNavigateMessage('Leaving now will lose your unsaved work');
// We uncomment out the below line in order to change the refresh message
//unsavedWarningsConfigProvider.setReloadMessage('Refreshing now will lose your unsaved work');
// We use the below line in order to override the default and tell unsavedWarning to NOT
// use the awesome angular-translate library for some reason
unsavedWarningsConfigProvider.useTranslateService = false;
}
])
.controller('demoCtrl', function($scope) {
$scope.user = {};
$scope.demoFormSubmit = function() {
$scope.message = 'Form Saved';
//$scope.user = {};
}
$scope.clearChanges = function() {
$scope.user = {};
}
});

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html class="no-js" ng-app="app">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Angular Unsaved Changes</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/angular-route/angular-route.js"></script>
<script src="../src/unsavedChanges.js"></script>
<script src="app.js"></script>
</head>
<body>
<a id="page1" href="#/page1">Page 1</a>
<a id="page2" href="#/page2">Page 2</a>
<div ng-view></div>
</body>
</html>

View file

@ -0,0 +1,17 @@
<h1>Page 1</h1>
<div ng-controller="demoCtrl">
<form name="demoForm" ng-submit="demoFormSubmit()" novalidate unsaved-warning-form>
<input id="userName" type="text" name="name" lazy-model="user.name" required>
<input type="text" name="email" lazy-model="user.email">
<button id="submitForm" type="submit">Submit Form</button>
<button id="clear" type="reset" unsaved-warning-clear ng-disabled="demoForm.$pristine">Disregard Changes</button>
</form>
<pre>
{{user | json}}
</pre>
<p>Is Form Dirty?: {{demoForm.$dirty}}</p>
<p ng-show="message">Message: {{message}}</p>
</div>
<input ng-model="outsideForm">

View file

@ -0,0 +1,33 @@
<div>
<h1>Page 2</h1>
<div ng-controller="demoCtrl">
<form name="demoForm1" ng-submit="demoFormSubmit()" novalidate unsaved-warning-form>
<legend>Form 1</legend>
<input type="text" name="name" ng-model="user1.name">
<button type="submit">Submit Form</button>
<button id="clear" type="button" unsaved-warning-clear>Disregard Form 1 Changes</button>
</form>
<p>Is Form 1 Dirty?: {{demoForm1.$dirty}}</p>
<p ng-show="message">Message: {{message}}</p>
</div>
<div ng-controller="demoCtrl">
<form name="demoForm2" ng-submit="demoFormSubmit()" novalidate unsaved-warning-form>
<legend>Form 2</legend>
<input type="text" name="name" ng-model="user2.name">
<button type="submit">Submit Form</button>
</form>
<p>Is Form 2 Dirty?: {{demoForm2.$dirty}}</p>
<p ng-show="message">Message: {{message}}</p>
</div>
<div ng-controller="demoCtrl">
<form name="demoForm3" ng-submit="demoFormSubmit()" novalidate unsaved-warning-form>
<legend>Form 3</legend>
<input type="text" name="name" ng-model="user3.name">
<button type="submit">Submit Form</button>
<button type="button" unsaved-warning-clear>Disregard Form 3 Changes</button>
</form>
<p>Is Form 3 Dirty?: {{demoForm3.$dirty}}</p>
<p ng-show="message">Message: {{message}}</p>
</div>
</div>

View file

@ -0,0 +1,331 @@
'use strict';
/*jshint globalstrict: true*/
/*jshint undef:false */
// @todo NOTE We should investigate changing default to
// $routeChangeStart see https://github.com/angular-ui/ui-router/blob/3898270241d4e32c53e63554034d106363205e0e/src/compat.js#L126
angular.module('unsavedChanges', ['lazyModel'])
.provider('unsavedWarningsConfig', function() {
var _this = this;
// defaults
var logEnabled = false;
var useTranslateService = true;
var routeEvent = ['$locationChangeStart', '$stateChangeStart'];
var navigateMessage = 'You will lose unsaved changes if you leave this page';
var reloadMessage = 'You will lose unsaved changes if you reload this page';
Object.defineProperty(_this, 'navigateMessage', {
get: function() {
return navigateMessage;
},
set: function(value) {
navigateMessage = value;
}
});
Object.defineProperty(_this, 'reloadMessage', {
get: function() {
return reloadMessage;
},
set: function(value) {
reloadMessage = value;
}
});
Object.defineProperty(_this, 'useTranslateService', {
get: function() {
return useTranslateService;
},
set: function(value) {
useTranslateService = !! (value);
}
});
Object.defineProperty(_this, 'routeEvent', {
get: function() {
return routeEvent;
},
set: function(value) {
if (typeof value === 'string') value = [value];
routeEvent = value;
}
});
Object.defineProperty(_this, 'logEnabled', {
get: function() {
return logEnabled;
},
set: function(value) {
logEnabled = !! (value);
}
});
this.$get = ['$injector',
function($injector) {
function translateIfAble(message) {
if ($injector.has('$translate') && useTranslateService) {
return $injector.get('$translate')(message);
} else {
return false;
}
}
var publicInterface = {
// log function that accepts any number of arguments
// @see http://stackoverflow.com/a/7942355/1738217
log: function() {
if (console.log && logEnabled && arguments.length) {
var newarr = [].slice.call(arguments);
if (typeof console.log === 'object') {
log.apply.call(console.log, console, newarr);
} else {
console.log.apply(console, newarr);
}
}
}
};
Object.defineProperty(publicInterface, 'useTranslateService', {
get: function() {
return useTranslateService;
}
});
Object.defineProperty(publicInterface, 'reloadMessage', {
get: function() {
return translateIfAble(reloadMessage) || reloadMessage;
}
});
Object.defineProperty(publicInterface, 'navigateMessage', {
get: function() {
return translateIfAble(navigateMessage) || navigateMessage;
}
});
Object.defineProperty(publicInterface, 'routeEvent', {
get: function() {
return routeEvent;
}
});
Object.defineProperty(publicInterface, 'logEnabled', {
get: function() {
return logEnabled;
}
});
return publicInterface;
}
];
})
.service('unsavedWarningSharedService', ['$rootScope', 'unsavedWarningsConfig', '$injector',
function($rootScope, unsavedWarningsConfig, $injector) {
// Controller scopped variables
var _this = this;
var allForms = [];
var areAllFormsClean = true;
var removeFunctions = [angular.noop];
// @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
// 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 areAllFormsClean; // no dirty forms were found
}
// adds form controller to registered forms array
// this array will be checked when user navigates away from page
this.init = function(form) {
if (allForms.length === 0) setup();
unsavedWarningsConfig.log("Registering form", form);
allForms.push(form);
};
this.removeForm = function(form) {
var idx = allForms.indexOf(form);
// this form is not present array
// @todo needs test coverage
if (idx === -1) return;
allForms.splice(idx, 1);
unsavedWarningsConfig.log("Removing form from watch list", form);
if (allForms.length === 0) tearDown();
};
function tearDown() {
unsavedWarningsConfig.log('No more forms, tearing down');
angular.forEach(removeFunctions, function(fn) {
fn();
});
window.onbeforeunload = null;
}
// Function called when user tries to close the window
this.confirmExit = function() {
// @todo this could be written a lot cleaner!
if (!allFormsClean()) return messages.reload;
tearDown();
};
// bind to window close
// @todo investigate new method for listening as discovered in previous tests
function setup() {
unsavedWarningsConfig.log('Setting up');
window.onbeforeunload = _this.confirmExit;
var eventsToWatchFor = unsavedWarningsConfig.routeEvent;
angular.forEach(eventsToWatchFor, function(aEvent) {
// 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!
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");
}
} else {
unsavedWarningsConfig.log("all forms are clean");
}
});
removeFunctions.push(removeFn);
});
}
}
])
.directive('unsavedWarningClear', ['unsavedWarningSharedService',
function(unsavedWarningSharedService) {
return {
scope: true,
require: '^form',
priority: 3000,
link: function(scope, element, attrs, formCtrl) {
element.bind('click', function(event) {
formCtrl.$setPristine();
});
}
};
}
])
.directive('unsavedWarningForm', ['unsavedWarningSharedService',
function(unsavedWarningSharedService) {
return {
require: 'form',
link: function(scope, formElement, attrs, formCtrl) {
// register this form
unsavedWarningSharedService.init(formCtrl);
// bind to form submit, this makes the typical submit button work
// in addition to the ability to bind to a seperate button which clears warning
formElement.bind('submit', function(event) {
if (formCtrl.$valid) {
formCtrl.$setPristine();
}
});
// @todo check destroy on clear button too?
scope.$on('$destroy', function() {
unsavedWarningSharedService.removeForm(formCtrl);
});
}
};
}
]);
/**
* --------------------------------------------
* Lazy model adapted from vitalets
* @see https://github.com/vitalets/lazy-model/
* --------------------------------------------
*
*/
angular.module('lazyModel', [])
.directive('lazyModel', ['$parse', '$compile',
function($parse, $compile) {
return {
restrict: 'A',
priority: 500,
terminal: true,
require: '^form',
scope: true,
compile: function compile(elem, attr) {
// getter and setter for original model
var ngModelGet = $parse(attr.lazyModel);
var ngModelSet = ngModelGet.assign;
// set ng-model to buffer in isolate scope
elem.attr('ng-model', 'buffer');
// remove lazy-model attribute to exclude recursion
elem.removeAttr("lazy-model");
return {
pre: function(scope, elem) {
// initialize buffer value as copy of original model
scope.buffer = ngModelGet(scope.$parent);
// compile element with ng-model directive pointing to buffer value
$compile(elem)(scope);
},
post: function postLink(scope, elem, attr, formCtrl) {
// bind form submit to write back final value from buffer
var form = elem.parent();
while (form[0].tagName !== 'FORM') {
form = form.parent();
}
form.bind('submit', function() {
// form valid - save new value
if (formCtrl.$valid) {
scope.$apply(function() {
ngModelSet(scope.$parent, scope.buffer);
});
}
});
form.bind('reset', function(e) {
e.preventDefault();
scope.$apply(function() {
scope.buffer = ngModelGet(scope.$parent);
});
});
}
};
}
};
}
]);

View file

@ -0,0 +1 @@
"use strict";angular.module("unsavedChanges",["lazyModel"]).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}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")}}else{c.log("all forms are clean")}});d.push(n)})}}]).directive("unsavedWarningClear",["unsavedWarningSharedService",function(a){return{scope:true,require:"^form",priority:3000,link:function(d,c,b,e){c.bind("click",function(f){e.$setPristine()})}}}]).directive("unsavedWarningForm",["unsavedWarningSharedService",function(a){return{require:"form",link:function(d,c,b,e){a.init(e);c.bind("submit",function(f){if(e.$valid){e.$setPristine()}});d.$on("$destroy",function(){a.removeForm(e)})}}}]);angular.module("lazyModel",[]).directive("lazyModel",["$parse","$compile",function(b,a){return{restrict:"A",priority:500,terminal:true,require:"^form",scope:true,compile:function c(g,e){var f=b(e.lazyModel);var h=f.assign;g.attr("ng-model","buffer");g.removeAttr("lazy-model");return{pre:function(i,j){i.buffer=f(i.$parent);a(j)(i)},post:function d(j,l,i,m){var k=l.parent();while(k[0].tagName!=="FORM"){k=k.parent()}k.bind("submit",function(){if(m.$valid){j.$apply(function(){h(j.$parent,j.buffer)})}});k.bind("reset",function(n){n.preventDefault();j.$apply(function(){j.buffer=f(j.$parent)})})}}}}}]);

View file

@ -0,0 +1,75 @@
// Karma configuration
// http://karma-runner.github.io/0.10/config/configuration-file.html
module.exports = function(config) {
config.set({
// base path, that will be used to resolve files and exclude
basePath: '',
// testing framework to use (jasmine/mocha/qunit/...)
frameworks: ['jasmine'],
files: [
// ------------------------------
// DEPENDENCIES
// ------------------------------
'demo/bower_components/jquery/jquery.js',
'demo/bower_components/angular/angular.js',
'demo/bower_components/angular-mocks/angular-mocks.js',
'demo/bower_components/angular-route/angular-route.js',
'demo/bower_components/angular-translate/angular-translate.js',
//'demo/bower_components/angular-scenario/angular-scenario.js',
//'node_modules/karma-ng-scenario/lib/adapter.js',
'src/unsavedChanges.js',
'test/unit/**/*.spec.js'
],
// preprocessors: {
// 'app/scripts/components/*/views/*.html': ['ng-html2js']
// },
ngHtml2JsPreprocessor: {
// strip this from the file path
stripPrefix: 'app/',
// make templates accessible in tests
moduleName: 'templates'
},
// list of files / patterns to exclude
exclude: [],
// web server port
port: 8080,
// level of logging
// possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
// @note our grunt tasks can over ride these settings. They are just default here.
// we do this with out auto_test grunt task.
autoWatch: false,
// Start these browsers, currently available:
// @note you must have the browser on the computer doing the testing
// - Chrome
// - ChromeCanary
// - Firefox
// - Opera
// - Safari (only Mac)
// - PhantomJS
// - IE (only Windows)
browsers: ['PhantomJS'],
// Continuous Integration mode
// if true, it capture browsers, run tests and exit
singleRun: true,
// reporters?
reporters: ['progress', 'osx'],
// provide green / red for apss / fail.
colors: true
});
};

View file

@ -0,0 +1,63 @@
{
"name": "angular-unsavedChanges",
"version": "0.1.1",
"description": "AngularJS directive to warn user of unsaved changes when navigating away from a form.",
"main": "Gruntfile.js",
"devDependencies": {
"grunt-strip": "~0.2.1",
"express": "latest",
"bower": "~1.2.6",
"grunt": "~0.4.1",
"grunt-shell": "~0.4.0",
"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-rev": "~0.1.0",
"grunt-concurrent": "~0.3.0",
"load-grunt-tasks": "~0.2.0",
"time-grunt": "~0.1.0",
"grunt-karma": "~0.6.2",
"karma-script-launcher": "~0.1.0",
"karma-chrome-launcher": "~0.1.0",
"karma-firefox-launcher": "~0.1.0",
"karma-html2js-preprocessor": "~0.1.0",
"karma-jasmine": "~0.1.3",
"karma-requirejs": "~0.1.0",
"karma-coverage": "0.1.0",
"karma-osx-reporter": "*",
"karma-phantomjs-launcher": "~0.1.0",
"karma": "~0.10.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"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://github.com/facultymatt/angular-unsavedChanges.git"
},
"keywords": [
"form",
"angularjs"
],
"authors": [
"Matt Miller <matt@facultycreative.com>"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/facultymatt/angular-unsavedChanges/issues"
},
"homepage": "https://github.com/facultymatt/angular-unsavedChanges"
}

View file

@ -0,0 +1,70 @@
// A reference configuration file.
exports.config = {
// ----- How to setup Selenium -----
//
// There are three ways to specify how to use Selenium. Specify one of the
// following:
//
// 1. seleniumServerJar - to start Selenium Standalone locally.
// 2. seleniumAddress - to connect to a Selenium server which is already
// running.
// 3. sauceUser/sauceKey - to use remote Selenium servers via SauceLabs.
// The location of the selenium standalone server .jar file.
//seleniumServerJar: './selenium/selenium-server-standalone-2.35.0.jar',
seleniumAddress: 'http://127.0.0.1:4444/wd/hub',
// The port to start the selenium server on, or null if the server should
// find its own unused port.
seleniumPort: null,
// Chromedriver location is used to help the selenium standalone server
// find chromedriver. This will be passed to the selenium jar as
// the system property webdriver.chrome.driver. If null, selenium will
// attempt to find chromedriver using PATH.
//chromeDriver: './selenium/chromedriver',
// Additional command line options to pass to selenium. For example,
// if you need to change the browser timeout, use
// seleniumArgs: ['-browserTimeout=60'],
seleniumArgs: [],
// ----- What tests to run -----
//
// Spec patterns are relative to the location of this config.
specs: [
'test/e2e/*.js'
],
// ----- Capabilities to be passed to the webdriver instance ----
//
// For a full list of available capabilities, see
// https://code.google.com/p/selenium/wiki/DesiredCapabilities
// and
// https://code.google.com/p/selenium/source/browse/javascript/webdriver/capabilities.js
capabilities: {
'browserName': 'chrome',
//'version': '7',
//'platform': 'XP'
},
// A base URL for your application under test. Calls to protractor.get()
// with relative paths will be prepended with this.
baseUrl: 'http://localhost:9999',
// Selector for the element housing the angular app - this defaults to
// body, but is necessary if ng-app is on a descendant of <body>
rootElement: 'body',
// ----- Options to be passed to minijasminenode -----
jasmineNodeOpts: {
// onComplete will be called just before the driver quits.
onComplete: null,
// If true, display spec names.
isVerbose: false,
// If true, print colors to the terminal.
showColors: true,
// If true, include stack traces in failures.
includeStackTrace: true,
// Default time to wait in ms before a test fails.
defaultTimeoutInterval: 1000000
}
};

View file

@ -0,0 +1,75 @@
// A reference configuration file.
exports.config = {
// ----- How to setup Selenium -----
//
// There are three ways to specify how to use Selenium. Specify one of the
// following:
//
// 1. seleniumServerJar - to start Selenium Standalone locally.
// 2. seleniumAddress - to connect to a Selenium server which is already
// running.
// 3. sauceUser/sauceKey - to use remote Selenium servers via SauceLabs.
seleniumAddress: 'http://facultymatt:b280b942-1965-446d-90bf-e069b5cd2cf9@localhost:4445/wd/hub',
// The port to start the selenium server on, or null if the server should
// find its own unused port.
seleniumPort: null,
// Chromedriver location is used to help the selenium standalone server
// find chromedriver. This will be passed to the selenium jar as
// the system property webdriver.chrome.driver. If null, selenium will
// attempt to find chromedriver using PATH.
//chromeDriver: './selenium/chromedriver',
// Additional command line options to pass to selenium. For example,
// if you need to change the browser timeout, use
// seleniumArgs: ['-browserTimeout=60'],
seleniumArgs: [],
// If sauceUser and sauceKey are specified, seleniumServerJar will be ignored.
// The tests will be run remotely using SauceLabs.
sauceUser: 'facultymatt',
sauceKey: 'b280b942-1965-446d-90bf-e069b5cd2cf9',
// ----- What tests to run -----
//
// Spec patterns are relative to the location of this config.
specs: [
'e2e/*.js'
],
// ----- Capabilities to be passed to the webdriver instance ----
//
// For a full list of available capabilities, see
// https://code.google.com/p/selenium/wiki/DesiredCapabilities
// and
// https://code.google.com/p/selenium/source/browse/javascript/webdriver/capabilities.js
capabilities: {
'username': 'facultymatt',
'accessKey': 'b280b942-1965-446d-90bf-e069b5cd2cf9',
'browserName': 'chrome',
'tunnelIdentifier': process.env.TRAVIS_JOB_NUMBER
//'version': '7',
//'platform': 'XP'
},
// A base URL for your application under test. Calls to protractor.get()
// with relative paths will be prepended with this.
baseUrl: 'http://localhost:9999',
// Selector for the element housing the angular app - this defaults to
// body, but is necessary if ng-app is on a descendant of <body>
rootElement: 'body',
// ----- Options to be passed to minijasminenode -----
jasmineNodeOpts: {
// onComplete will be called just before the driver quits.
onComplete: null,
// If true, display spec names.
isVerbose: false,
// If true, print colors to the terminal.
showColors: true,
// If true, include stack traces in failures.
includeStackTrace: true,
// Default time to wait in ms before a test fails.
defaultTimeoutInterval: 1000000
}
};

View file

@ -0,0 +1,136 @@
# An AngularJS directive for forms that alerts user of unsaved changes.
_Dev Note: This module is still in development. However it's used in many of my production projects so it can be considered stable and battle tested._
This directive will alert users when they navigate away from a page where a form has unsaved changes. It will be triggered in all situations where form data would be lost:
- when user clicks a link
- when user navigates with forward / back button
- when user swipes (iOS)
- when user refreshes the page
In addition this module:
- Works with multiple forms on the same page
- Provides a button to disregard unsaved changes
- Works with Angular Translate module
- Has configurable reload and navigate messages
- Works with uiRouter by default by listeneing for `$locationChangeStart` and `$stateChangeStart`
- Can be configured to listen for any event
## How it Works
The directive binds to `locationChangeStart` and `window.onbeforeunload`. When these events happen all registered froms are checked if they are dirty. The module defers to the forms `$dirty` property as a single source of truth. If dirty, the user is alerted. Disregarding changes resets the form and sets pristine.
## Basic Usage
- Install from bower using `$ bower install angular-unsavedChanges --save`.
- Include the JS, for example `<script src="bower_components/angular-unsavedChanges/dist/unsavedChanges.js"></script>`.
- Include in your app, for example: `angular.module('app', ['unsavedChanges', 'anotherDirective'])`
- Add attribute to your form, `unsaved-changes-warning`
- That's it!
## API
### Directives
The module provides two directives for use.
#### unsaved-warning-form
Add to forms you want to register with directive. The module will only listen when forms are registered.
```
<form name="testForm" unsaved-warning-form>
</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).
```
<form name="testForm" unsaved-warning-form>
<input name="test" type="text" ng-model="test"/>
<button type="submit"></button>
<button type="reset" unsaved-warning-clear></button>
</form>
```
### 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.
#### useTranslateService
Defaults to `true`. Will use translate service if available. It's safe to leave this set to `true`, even when not using the translate service, because the module still checks that the service exists.
```
unsavedWarningsConfigProvider.useTranslateService = true;
```
#### logEnabled
Defaults to `false`. Uses the services internal logging method for debugging.
```
unsavedWarningsConfigProvider.logEnabled = true;
```
#### routeEvent
Defaults to `['$locationChangeStart' ,'$stateChangeStart']` which supports ui router by default.
```
unsavedWarningsConfigProvider.routeEvent = '$stateChangeStart';
```
#### navigateMessage
Set custom message displayed when user navigates. If using translate this will be the key to translate.
```
unsavedWarningsConfigProvider.navigateMessage = "Custom Navigate Message";
```
#### reloadMessage
Set custom message displayed when user refreshes the page. If using translate this will be the key to translate.
```
unsavedWarningsConfigProvider.reloadMessage = "Custom Reload Message";
```
## Integration with Lazy Model Directive
This module includes a customized version of [Lazy Model](https://github.com/vitalets/lazy-model). Lazy model ensures that model changes are only persisted when user submits valid form. It also resets model values to their original value when form is reset.
To use this simply add `lazy-model` to your inputs instead of `ng-model`. Submitting the form will update your model, while clicking "clear changes" will reset the model values to their original state.
```
<input name="test" type="text" lazy-model="test"/>
```
## Gotchas / Known Bugs
*** Known issue: sometimes the form is removed from expected scope. Ie: in your controller `$scope.formName` no longer works. You might need to access `$scope.$$childTail.formName`. This will be fixed in furture versions.
## Demo / Dev
To try the demo run `npm install` && `bower install` && `grunt connect`. The browser should open [http://127.0.0.1:9001/demo](http://127.0.0.1:9001/demo).
## Test
Note you need to manually change the paths in `index.html` and `karam-unit.conf` to point to the `dist` version for final testing. Make sure to run `$ grunt` first.
__End 2 End Testing__
Because of the alert / event driven nature of this module it made the most sense to rely on e2e tests. (also its hard to interact with alerts via unit tests).
To run the e2e tests do the following:
- Install Protractor as per directions here: [https://github.com/angular/protractor](https://github.com/angular/protractor)
- Start selenium server: `webdriver-manager start` (or use other selenium methods as per Protractor documentation.)
- Run `$ grunt test:e2e`
__Unit Tests__
- Run `$ grunt test:unit` OR `$ grunt test`
## Build
Run `$ grunt` to lint and minify the code. Also strips console logs.

View file

@ -0,0 +1,331 @@
'use strict';
/*jshint globalstrict: true*/
/*jshint undef:false */
// @todo NOTE We should investigate changing default to
// $routeChangeStart see https://github.com/angular-ui/ui-router/blob/3898270241d4e32c53e63554034d106363205e0e/src/compat.js#L126
angular.module('unsavedChanges', ['lazyModel'])
.provider('unsavedWarningsConfig', function() {
var _this = this;
// defaults
var logEnabled = false;
var useTranslateService = true;
var routeEvent = ['$locationChangeStart', '$stateChangeStart'];
var navigateMessage = 'You will lose unsaved changes if you leave this page';
var reloadMessage = 'You will lose unsaved changes if you reload this page';
Object.defineProperty(_this, 'navigateMessage', {
get: function() {
return navigateMessage;
},
set: function(value) {
navigateMessage = value;
}
});
Object.defineProperty(_this, 'reloadMessage', {
get: function() {
return reloadMessage;
},
set: function(value) {
reloadMessage = value;
}
});
Object.defineProperty(_this, 'useTranslateService', {
get: function() {
return useTranslateService;
},
set: function(value) {
useTranslateService = !! (value);
}
});
Object.defineProperty(_this, 'routeEvent', {
get: function() {
return routeEvent;
},
set: function(value) {
if (typeof value === 'string') value = [value];
routeEvent = value;
}
});
Object.defineProperty(_this, 'logEnabled', {
get: function() {
return logEnabled;
},
set: function(value) {
logEnabled = !! (value);
}
});
this.$get = ['$injector',
function($injector) {
function translateIfAble(message) {
if ($injector.has('$translate') && useTranslateService) {
return $injector.get('$translate')(message);
} else {
return false;
}
}
var publicInterface = {
// log function that accepts any number of arguments
// @see http://stackoverflow.com/a/7942355/1738217
log: function() {
if (console.log && logEnabled && arguments.length) {
var newarr = [].slice.call(arguments);
if (typeof console.log === 'object') {
log.apply.call(console.log, console, newarr);
} else {
console.log.apply(console, newarr);
}
}
}
};
Object.defineProperty(publicInterface, 'useTranslateService', {
get: function() {
return useTranslateService;
}
});
Object.defineProperty(publicInterface, 'reloadMessage', {
get: function() {
return translateIfAble(reloadMessage) || reloadMessage;
}
});
Object.defineProperty(publicInterface, 'navigateMessage', {
get: function() {
return translateIfAble(navigateMessage) || navigateMessage;
}
});
Object.defineProperty(publicInterface, 'routeEvent', {
get: function() {
return routeEvent;
}
});
Object.defineProperty(publicInterface, 'logEnabled', {
get: function() {
return logEnabled;
}
});
return publicInterface;
}
];
})
.service('unsavedWarningSharedService', ['$rootScope', 'unsavedWarningsConfig', '$injector',
function($rootScope, unsavedWarningsConfig, $injector) {
// Controller scopped variables
var _this = this;
var allForms = [];
var areAllFormsClean = true;
var removeFunctions = [angular.noop];
// @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
// 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 areAllFormsClean; // no dirty forms were found
}
// adds form controller to registered forms array
// this array will be checked when user navigates away from page
this.init = function(form) {
if (allForms.length === 0) setup();
unsavedWarningsConfig.log("Registering form", form);
allForms.push(form);
};
this.removeForm = function(form) {
var idx = allForms.indexOf(form);
// this form is not present array
// @todo needs test coverage
if (idx === -1) return;
allForms.splice(idx, 1);
unsavedWarningsConfig.log("Removing form from watch list", form);
if (allForms.length === 0) tearDown();
};
function tearDown() {
unsavedWarningsConfig.log('No more forms, tearing down');
angular.forEach(removeFunctions, function(fn) {
fn();
});
window.onbeforeunload = null;
}
// Function called when user tries to close the window
this.confirmExit = function() {
// @todo this could be written a lot cleaner!
if (!allFormsClean()) return messages.reload;
tearDown();
};
// bind to window close
// @todo investigate new method for listening as discovered in previous tests
function setup() {
unsavedWarningsConfig.log('Setting up');
window.onbeforeunload = _this.confirmExit;
var eventsToWatchFor = unsavedWarningsConfig.routeEvent;
angular.forEach(eventsToWatchFor, function(aEvent) {
// 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!
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");
}
} else {
unsavedWarningsConfig.log("all forms are clean");
}
});
removeFunctions.push(removeFn);
});
}
}
])
.directive('unsavedWarningClear', ['unsavedWarningSharedService',
function(unsavedWarningSharedService) {
return {
scope: true,
require: '^form',
priority: 3000,
link: function(scope, element, attrs, formCtrl) {
element.bind('click', function(event) {
formCtrl.$setPristine();
});
}
};
}
])
.directive('unsavedWarningForm', ['unsavedWarningSharedService',
function(unsavedWarningSharedService) {
return {
require: 'form',
link: function(scope, formElement, attrs, formCtrl) {
// register this form
unsavedWarningSharedService.init(formCtrl);
// bind to form submit, this makes the typical submit button work
// in addition to the ability to bind to a seperate button which clears warning
formElement.bind('submit', function(event) {
if (formCtrl.$valid) {
formCtrl.$setPristine();
}
});
// @todo check destroy on clear button too?
scope.$on('$destroy', function() {
unsavedWarningSharedService.removeForm(formCtrl);
});
}
};
}
]);
/**
* --------------------------------------------
* Lazy model adapted from vitalets
* @see https://github.com/vitalets/lazy-model/
* --------------------------------------------
*
*/
angular.module('lazyModel', [])
.directive('lazyModel', ['$parse', '$compile',
function($parse, $compile) {
return {
restrict: 'A',
priority: 500,
terminal: true,
require: '^form',
scope: true,
compile: function compile(elem, attr) {
// getter and setter for original model
var ngModelGet = $parse(attr.lazyModel);
var ngModelSet = ngModelGet.assign;
// set ng-model to buffer in isolate scope
elem.attr('ng-model', 'buffer');
// remove lazy-model attribute to exclude recursion
elem.removeAttr("lazy-model");
return {
pre: function(scope, elem) {
// initialize buffer value as copy of original model
scope.buffer = ngModelGet(scope.$parent);
// compile element with ng-model directive pointing to buffer value
$compile(elem)(scope);
},
post: function postLink(scope, elem, attr, formCtrl) {
// bind form submit to write back final value from buffer
var form = elem.parent();
while (form[0].tagName !== 'FORM') {
form = form.parent();
}
form.bind('submit', function() {
// form valid - save new value
if (formCtrl.$valid) {
scope.$apply(function() {
ngModelSet(scope.$parent, scope.buffer);
});
}
});
form.bind('reset', function(e) {
e.preventDefault();
scope.$apply(function() {
scope.buffer = ngModelGet(scope.$parent);
});
});
}
};
}
};
}
]);

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -e
grunt test:travis