Merge branch 'open1035' into open-master

Merge WTD-1035; resolving conflicts to avoid WTD-1069

Conflicts:
	platform/commonUI/edit/src/controllers/EditController.js
	platform/commonUI/edit/src/objects/EditableDomainObjectCache.js
This commit is contained in:
Victor Woeltjen 2015-04-06 08:32:54 -07:00
commit 3c3dd0ad17
10 changed files with 275 additions and 8 deletions

View File

@ -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`

View File

@ -23,6 +23,13 @@
"depends": [ "$scope" ]
}
],
"directives": [
{
"key": "mctBeforeUnload",
"implementation": "directives/MCTBeforeUnload.js",
"depends": [ "$window" ]
}
],
"actions": [
{
"key": "edit",

View File

@ -1,8 +1,9 @@
<div content="jquery-wrapper"
class="abs holder-all edit-mode"
ng-controller="EditController">
ng-controller="EditController as editMode"
mct-before-unload="editMode.getUnloadWarning()">
<mct-representation key="'edit-object'" mct-object="navigatedObject">
<mct-representation key="'edit-object'" mct-object="editMode.navigatedObject()">
</mct-representation>
<mct-include key="'bottombar'"></mct-include>

View File

@ -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();
}
};
};

View File

@ -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;

View File

@ -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;
}
);

View File

@ -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;
}
};
}

View File

@ -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));
});
});
}
);

View File

@ -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();
});
});
}
);

View File

@ -14,6 +14,7 @@
"controllers/EditActionController",
"controllers/EditController",
"controllers/EditPanesController",
"directives/MCTBeforeUnload",
"objects/EditableDomainObject",
"objects/EditableDomainObjectCache",
"objects/EditableModelCache",