diff --git a/platform/commonUI/edit/README.md b/platform/commonUI/edit/README.md index 7ddf90b699..a1e88d330c 100644 --- a/platform/commonUI/edit/README.md +++ b/platform/commonUI/edit/README.md @@ -1,5 +1,26 @@ Contains sources and resources associated with Edit mode. +# Extensions + +## Directives + +This bundle introduces the `mct-before-unload` directive, primarily for +internal use (to prompt the user to confirm navigation away from unsaved +changes in Edit mode.) + +The `mct-before-unload` directive is used as an attribute whose value is +an Angular expression that is evaluated when navigation changes (either +via browser-level changes, such as the refresh button, or changes to +the Angular route, which happens when hitting the back button in Edit +mode.) The result of this evaluation, when truthy, is shown in a browser +dialog to allow the user to confirm navigation. When falsy, no prompt is +shown, allowing these dialogs to be shown conditionally. (For instance, in +Edit mode, prompts are only shown if user-initiated changes have +occurred.) + +This directive may be attached to any element; its behavior will be enforced +so long as that element remains within the DOM. + # Toolbars Views may specify the contents of a toolbar through a `toolbar` diff --git a/platform/commonUI/edit/bundle.json b/platform/commonUI/edit/bundle.json index aebca5ff92..b2e7878beb 100644 --- a/platform/commonUI/edit/bundle.json +++ b/platform/commonUI/edit/bundle.json @@ -23,6 +23,13 @@ "depends": [ "$scope" ] } ], + "directives": [ + { + "key": "mctBeforeUnload", + "implementation": "directives/MCTBeforeUnload.js", + "depends": [ "$window" ] + } + ], "actions": [ { "key": "edit", diff --git a/platform/commonUI/edit/res/templates/edit.html b/platform/commonUI/edit/res/templates/edit.html index ed42b2dcf2..56f8a0b30e 100644 --- a/platform/commonUI/edit/res/templates/edit.html +++ b/platform/commonUI/edit/res/templates/edit.html @@ -1,8 +1,9 @@
+ ng-controller="EditController as editMode" + mct-before-unload="editMode.getUnloadWarning()"> - + diff --git a/platform/commonUI/edit/src/capabilities/EditorCapability.js b/platform/commonUI/edit/src/capabilities/EditorCapability.js index 666790601b..e7ae35ec01 100644 --- a/platform/commonUI/edit/src/capabilities/EditorCapability.js +++ b/platform/commonUI/edit/src/capabilities/EditorCapability.js @@ -77,6 +77,13 @@ define( */ cancel: function () { return resolvePromise(undefined); + }, + /** + * Check if there are any unsaved changes. + * @returns {boolean} true if there are unsaved changes + */ + dirty: function () { + return cache.dirty(); } }; }; diff --git a/platform/commonUI/edit/src/controllers/EditController.js b/platform/commonUI/edit/src/controllers/EditController.js index 2654e17f47..c91bd2cb3e 100644 --- a/platform/commonUI/edit/src/controllers/EditController.js +++ b/platform/commonUI/edit/src/controllers/EditController.js @@ -15,10 +15,12 @@ define( * @constructor */ function EditController($scope, $q, navigationService) { + var navigatedObject; + function setNavigation(domainObject) { // Wrap the domain object such that all mutation is // confined to edit mode (until Save) - $scope.navigatedObject = + navigatedObject = domainObject && new EditableDomainObject(domainObject, $q); } @@ -27,6 +29,31 @@ define( $scope.$on("$destroy", function () { navigationService.removeListener(setNavigation); }); + + return { + /** + * Get the domain object which is navigated-to. + * @returns {DomainObject} the domain object that is navigated-to + */ + navigatedObject: function () { + return navigatedObject; + }, + /** + * Get the warning to show if the user attempts to navigate + * away from Edit mode while unsaved changes are present. + * @returns {string} the warning to show, or undefined if + * there are no unsaved changes + */ + getUnloadWarning: function () { + var editorCapability = navigatedObject && + navigatedObject.getCapability("editor"), + hasChanges = editorCapability && editorCapability.dirty(); + + return hasChanges ? + "Unsaved changes will be lost if you leave this page." : + undefined; + } + }; } return EditController; diff --git a/platform/commonUI/edit/src/directives/MCTBeforeUnload.js b/platform/commonUI/edit/src/directives/MCTBeforeUnload.js new file mode 100644 index 0000000000..fad91bcdc6 --- /dev/null +++ b/platform/commonUI/edit/src/directives/MCTBeforeUnload.js @@ -0,0 +1,84 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Defines the `mct-before-unload` directive. The expression bound + * to this attribute will be evaluated during page navigation events + * and, if it returns a truthy value, will be used to populate a + * prompt to the user to confirm this navigation. + * @constructor + * @param $window the window + */ + function MCTBeforeUnload($window) { + var unloads = [], + oldBeforeUnload = $window.onbeforeunload; + + // Run all unload functions, returning the first returns truthily. + function checkUnloads() { + var result; + unloads.forEach(function (unload) { + result = result || unload(); + }); + return result; + } + + // Link function for an mct-before-unload directive usage + function link(scope, element, attrs) { + // Invoke the + function unload() { + return scope.$eval(attrs.mctBeforeUnload); + } + + // Stop using this unload expression + function removeUnload() { + unloads = unloads.filter(function (callback) { + return callback !== unload; + }); + if (unloads.length === 0) { + $window.onbeforeunload = oldBeforeUnload; + } + } + + // Show a dialog before allowing a location change + function checkLocationChange(event) { + // Get an unload message (if any) + var warning = unload(); + // Prompt the user if there's an unload message + if (warning && !$window.confirm(warning)) { + // ...and prevent the route change if it was confirmed + event.preventDefault(); + } + } + + // If this is the first active instance of this directive, + // register as the window's beforeunload handler + if (unloads.length === 0) { + $window.onbeforeunload = checkUnloads; + } + + // Include this instance of the directive's unload function + unloads.push(unload); + + // Remove it when the scope is destroyed + scope.$on("$destroy", removeUnload); + + // Also handle route changes + scope.$on("$locationChangeStart", checkLocationChange); + } + + return { + // Applicable as an attribute + restrict: "A", + // Link with the provided function + link: link + }; + } + + return MCTBeforeUnload; + + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js index a342fcdad8..062d9fa15e 100644 --- a/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js +++ b/platform/commonUI/edit/src/objects/EditableDomainObjectCache.js @@ -103,6 +103,13 @@ define( // Save; pass a nonrecursive flag to avoid looping return object.getCapability('editor').save(true); })); + }, + /** + * Check if any objects have been marked dirty in this cache. + * @returns {boolean} true if objects are dirty + */ + dirty: function () { + return Object.keys(dirty).length > 0; } }; } diff --git a/platform/commonUI/edit/test/controllers/EditControllerSpec.js b/platform/commonUI/edit/test/controllers/EditControllerSpec.js index 0d37bd3820..6384859de2 100644 --- a/platform/commonUI/edit/test/controllers/EditControllerSpec.js +++ b/platform/commonUI/edit/test/controllers/EditControllerSpec.js @@ -44,17 +44,17 @@ define( ); }); - it("places the currently-navigated object in scope", function () { - expect(mockScope.navigatedObject).toBeDefined(); - expect(mockScope.navigatedObject.getId()).toEqual("test"); + it("exposes the currently-navigated object", function () { + expect(controller.navigatedObject()).toBeDefined(); + expect(controller.navigatedObject().getId()).toEqual("test"); }); it("adds an editor capability to the navigated object", function () { // Should provide an editor capability... - expect(mockScope.navigatedObject.getCapability("editor")) + expect(controller.navigatedObject().getCapability("editor")) .toBeDefined(); // Shouldn't have been the mock capability we provided - expect(mockScope.navigatedObject.getCapability("editor")) + expect(controller.navigatedObject().getCapability("editor")) .not.toEqual(mockCapability); }); @@ -79,6 +79,23 @@ define( .toHaveBeenCalledWith(navCallback); }); + it("exposes a warning message for unload", function () { + var obj = controller.navigatedObject(), + mockEditor = jasmine.createSpyObj('editor', ['dirty']); + + // Normally, should be undefined + expect(controller.getUnloadWarning()).toBeUndefined(); + + // Override the object's editor capability, make it look + // like there are unsaved changes. + obj.getCapability = jasmine.createSpy(); + obj.getCapability.andReturn(mockEditor); + mockEditor.dirty.andReturn(true); + + // Should have some warning message here now + expect(controller.getUnloadWarning()).toEqual(jasmine.any(String)); + }); + }); } ); \ No newline at end of file diff --git a/platform/commonUI/edit/test/directives/MCTBeforeUnloadSpec.js b/platform/commonUI/edit/test/directives/MCTBeforeUnloadSpec.js new file mode 100644 index 0000000000..e26652444e --- /dev/null +++ b/platform/commonUI/edit/test/directives/MCTBeforeUnloadSpec.js @@ -0,0 +1,95 @@ +/*global define,describe,it,expect,beforeEach,jasmine*/ + +define( + ["../../src/directives/MCTBeforeUnload"], + function (MCTBeforeUnload) { + "use strict"; + + describe("The mct-before-unload directive", function () { + var mockWindow, + mockScope, + testAttrs, + mockEvent, + directive; + + function fireListener(eventType, value) { + mockScope.$on.calls.forEach(function (call) { + if (call.args[0] === eventType) { + call.args[1](value); + } + }); + } + + beforeEach(function () { + mockWindow = jasmine.createSpyObj("$window", ['confirm']); + mockScope = jasmine.createSpyObj("$scope", ['$eval', '$on']); + testAttrs = { mctBeforeUnload: "someExpression" }; + mockEvent = jasmine.createSpyObj("event", ["preventDefault"]); + directive = new MCTBeforeUnload(mockWindow); + directive.link(mockScope, {}, testAttrs); + }); + + it("can be used only as an attribute", function () { + expect(directive.restrict).toEqual('A'); + }); + + it("listens for beforeunload", function () { + expect(mockWindow.onbeforeunload).toEqual(jasmine.any(Function)); + }); + + it("listens for route changes", function () { + expect(mockScope.$on).toHaveBeenCalledWith( + "$locationChangeStart", + jasmine.any(Function) + ); + }); + + it("listens for its scope's destroy event", function () { + expect(mockScope.$on).toHaveBeenCalledWith( + "$destroy", + jasmine.any(Function) + ); + }); + + it("uses result of evaluated expression as a warning", function () { + mockScope.$eval.andReturn(undefined); + expect(mockWindow.onbeforeunload(mockEvent)).toBeUndefined(); + mockScope.$eval.andReturn("some message"); + expect(mockWindow.onbeforeunload(mockEvent)).toEqual("some message"); + // Verify that the right expression was evaluated + expect(mockScope.$eval).toHaveBeenCalledWith(testAttrs.mctBeforeUnload); + }); + + it("confirms route changes", function () { + // First, try with no unsaved changes; + // should not confirm or preventDefault + mockScope.$eval.andReturn(undefined); + fireListener("$locationChangeStart", mockEvent); + expect(mockWindow.confirm).not.toHaveBeenCalled(); + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + + // Next, try with unsaved changes that the user confirms; + // should prompt, but not preventDefault + mockScope.$eval.andReturn("some message"); + mockWindow.confirm.andReturn(true); + fireListener("$locationChangeStart", mockEvent); + expect(mockWindow.confirm).toHaveBeenCalledWith("some message"); + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + + // Finally, act as if the user said no to this dialog; + // this should preventDefault on the location change. + mockWindow.confirm.andReturn(false); + fireListener("$locationChangeStart", mockEvent); + expect(mockWindow.confirm).toHaveBeenCalledWith("some message"); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + it("cleans up listeners when destroyed", function () { + fireListener("$destroy", mockEvent); + expect(mockWindow.onbeforeunload).toBeUndefined(); + }); + + + }); + } +); \ No newline at end of file diff --git a/platform/commonUI/edit/test/suite.json b/platform/commonUI/edit/test/suite.json index 49fffec4a7..8a239f35b2 100644 --- a/platform/commonUI/edit/test/suite.json +++ b/platform/commonUI/edit/test/suite.json @@ -14,6 +14,7 @@ "controllers/EditActionController", "controllers/EditController", "controllers/EditPanesController", + "directives/MCTBeforeUnload", "objects/EditableDomainObject", "objects/EditableDomainObjectCache", "objects/EditableModelCache",