[Edit Mode] Canceling edit mode with unsaved changes now shows confirmation dialog to user. Fixes #664

This commit is contained in:
Henry
2016-06-15 17:10:29 -07:00
parent 717ceff02c
commit c2517c1670
8 changed files with 121 additions and 55 deletions

View File

@ -93,11 +93,9 @@ define([
"$scope", "$scope",
"$route", "$route",
"$location", "$location",
"$window",
"objectService", "objectService",
"navigationService", "navigationService",
"urlService", "urlService",
"policyService",
"DEFAULT_PATH" "DEFAULT_PATH"
] ]
}, },
@ -201,7 +199,9 @@ define([
"implementation": NavigateAction, "implementation": NavigateAction,
"depends": [ "depends": [
"navigationService", "navigationService",
"$q" "$q",
"policyService",
"$window"
] ]
}, },
{ {

View File

@ -44,11 +44,9 @@ define(
$scope, $scope,
$route, $route,
$location, $location,
$window,
objectService, objectService,
navigationService, navigationService,
urlService, urlService,
policyService,
defaultPath defaultPath
) { ) {
var path = [ROOT_ID].concat( var path = [ROOT_ID].concat(
@ -75,25 +73,10 @@ define(
} }
// Callback for updating the in-scope reference to the object function setScopeObjects(domainObject, navigationAllowed) {
// that is currently navigated-to.
function setNavigation(domainObject) {
var navigationAllowed = true;
if (domainObject === $scope.navigatedObject) {
//do nothing;
return;
}
policyService.allow("navigation", $scope.navigatedObject, domainObject, function (message) {
navigationAllowed = $window.confirm(message + "\r\n\r\n" +
" Are you sure you want to continue?");
});
if (navigationAllowed) { if (navigationAllowed) {
$scope.navigatedObject = domainObject; $scope.navigatedObject = domainObject;
$scope.treeModel.selectedObject = domainObject; $scope.treeModel.selectedObject = domainObject;
navigationService.setNavigation(domainObject);
updateRoute(domainObject); updateRoute(domainObject);
} else { } else {
//If navigation was unsuccessful (ie. blocked), reset //If navigation was unsuccessful (ie. blocked), reset
@ -103,6 +86,20 @@ define(
} }
} }
// Callback for updating the in-scope reference to the object
// that is currently navigated-to.
function setNavigation(domainObject) {
if (domainObject === $scope.navigatedObject) {
//do nothing;
return;
}
if (domainObject) {
domainObject.getCapability("action").perform("navigate").then(setScopeObjects.bind(undefined, domainObject));
} else {
setScopeObjects(domainObject, true);
}
}
function navigateTo(domainObject) { function navigateTo(domainObject) {
// Check if an object has been navigated-to already... // Check if an object has been navigated-to already...

View File

@ -33,10 +33,12 @@ define(
* @constructor * @constructor
* @implements {Action} * @implements {Action}
*/ */
function NavigateAction(navigationService, $q, context) { function NavigateAction(navigationService, $q, policyService, $window, context) {
this.domainObject = context.domainObject; this.domainObject = context.domainObject;
this.$q = $q; this.$q = $q;
this.navigationService = navigationService; this.navigationService = navigationService;
this.policyService = policyService;
this.$window = $window;
} }
/** /**
@ -45,9 +47,20 @@ define(
* navigation has been updated * navigation has been updated
*/ */
NavigateAction.prototype.perform = function () { NavigateAction.prototype.perform = function () {
var self = this,
navigationAllowed = true;
function allow() {
self.policyService.allow("navigation", self.navigationService.getNavigation(), self.domainObject, function (message) {
navigationAllowed = self.$window.confirm(message + "\r\n\r\n" +
" Are you sure you want to continue?");
});
return navigationAllowed;
}
// Set navigation, and wrap like a promise // Set navigation, and wrap like a promise
return this.$q.when( return this.$q.when(
this.navigationService.setNavigation(this.domainObject) allow() && this.navigationService.setNavigation(this.domainObject)
); );
}; };

View File

@ -37,9 +37,8 @@ define(
mockUrlService, mockUrlService,
mockDomainObject, mockDomainObject,
mockNextObject, mockNextObject,
mockWindow,
mockPolicyService,
testDefaultRoot, testDefaultRoot,
mockActionCapability,
controller; controller;
function mockPromise(value) { function mockPromise(value) {
@ -55,25 +54,14 @@ define(
mockScope, mockScope,
mockRoute, mockRoute,
mockLocation, mockLocation,
mockWindow,
mockObjectService, mockObjectService,
mockNavigationService, mockNavigationService,
mockUrlService, mockUrlService,
mockPolicyService,
testDefaultRoot testDefaultRoot
); );
} }
beforeEach(function () { beforeEach(function () {
mockWindow = jasmine.createSpyObj('$window', [
"confirm"
]);
mockWindow.confirm.andReturn(true);
mockPolicyService = jasmine.createSpyObj('policyService', [
'allow'
]);
testDefaultRoot = "some-root-level-domain-object"; testDefaultRoot = "some-root-level-domain-object";
mockScope = jasmine.createSpyObj( mockScope = jasmine.createSpyObj(
@ -128,6 +116,8 @@ define(
mockNextObject.getId.andReturn("next"); mockNextObject.getId.andReturn("next");
mockDomainObject.getId.andReturn(testDefaultRoot); mockDomainObject.getId.andReturn(testDefaultRoot);
mockActionCapability = jasmine.createSpyObj('actionCapability', ['perform']);
instantiateController(); instantiateController();
}); });
@ -211,8 +201,13 @@ define(
mockContext.getPath.andReturn( mockContext.getPath.andReturn(
[mockRootObject, mockDomainObject, mockNextObject] [mockRootObject, mockDomainObject, mockNextObject]
); );
//Return true from navigate action
mockActionCapability.perform.andReturn(mockPromise(true));
mockNextObject.getCapability.andCallFake(function (c) { mockNextObject.getCapability.andCallFake(function (c) {
return c === 'context' && mockContext; return (c === 'context' && mockContext) ||
(c === 'action' && mockActionCapability);
}); });
mockScope.$on.andReturn(mockUnlisten); mockScope.$on.andReturn(mockUnlisten);
// Provide a navigation change // Provide a navigation change
@ -225,6 +220,7 @@ define(
mockLocation.path.andReturn("/browse/"); mockLocation.path.andReturn("/browse/");
mockNavigationService.setNavigation.andReturn(true); mockNavigationService.setNavigation.andReturn(true);
mockActionCapability.perform.andReturn(mockPromise(true));
// Exercise the Angular workaround // Exercise the Angular workaround
mockNavigationService.addListener.mostRecentCall.args[0](); mockNavigationService.addListener.mostRecentCall.args[0]();
@ -243,6 +239,9 @@ define(
mockScope.navigatedObject = mockDomainObject; mockScope.navigatedObject = mockDomainObject;
mockNavigationService.setNavigation.andReturn(true); mockNavigationService.setNavigation.andReturn(true);
mockActionCapability.perform.andReturn(mockPromise(true));
mockNextObject.getCapability.andReturn(mockActionCapability);
//Simulate a change in selected tree object //Simulate a change in selected tree object
mockScope.treeModel = {selectedObject: mockDomainObject}; mockScope.treeModel = {selectedObject: mockDomainObject};
mockScope.$watch.mostRecentCall.args[1](mockNextObject); mockScope.$watch.mostRecentCall.args[1](mockNextObject);
@ -254,11 +253,10 @@ define(
it("after failed navigation event resets the selected tree" + it("after failed navigation event resets the selected tree" +
" object", function () { " object", function () {
mockScope.navigatedObject = mockDomainObject; mockScope.navigatedObject = mockDomainObject;
mockWindow.confirm.andReturn(false);
mockPolicyService.allow.andCallFake(function (category, object, context, callback) { //Return false from navigation action
callback("unsaved changes"); mockActionCapability.perform.andReturn(mockPromise(false));
return false; mockNextObject.getCapability.andReturn(mockActionCapability);
});
//Simulate a change in selected tree object //Simulate a change in selected tree object
mockScope.treeModel = {selectedObject: mockDomainObject}; mockScope.treeModel = {selectedObject: mockDomainObject};

View File

@ -31,6 +31,8 @@ define(
var mockNavigationService, var mockNavigationService,
mockQ, mockQ,
mockDomainObject, mockDomainObject,
mockPolicyService,
mockWindow,
action; action;
function mockPromise(value) { function mockPromise(value) {
@ -44,25 +46,70 @@ define(
beforeEach(function () { beforeEach(function () {
mockNavigationService = jasmine.createSpyObj( mockNavigationService = jasmine.createSpyObj(
"navigationService", "navigationService",
["setNavigation"] [
"setNavigation",
"getNavigation"
]
); );
mockNavigationService.getNavigation.andReturn({});
mockQ = { when: mockPromise }; mockQ = { when: mockPromise };
mockDomainObject = jasmine.createSpyObj( mockDomainObject = jasmine.createSpyObj(
"domainObject", "domainObject",
["getId", "getModel", "getCapability"] ["getId", "getModel", "getCapability"]
); );
mockPolicyService = jasmine.createSpyObj("policyService",
[
"allow"
]);
mockWindow = jasmine.createSpyObj("$window",
[
"confirm"
]);
action = new NavigateAction( action = new NavigateAction(
mockNavigationService, mockNavigationService,
mockQ, mockQ,
mockPolicyService,
mockWindow,
{ domainObject: mockDomainObject } { domainObject: mockDomainObject }
); );
}); });
it("invokes the navigate service when performed", function () { it("invokes the policy service to determine if navigation" +
" allowed", function () {
action.perform(); action.perform();
expect(mockNavigationService.setNavigation) expect(mockPolicyService.allow)
.toHaveBeenCalledWith(mockDomainObject); .toHaveBeenCalledWith("navigation", jasmine.any(Object), jasmine.any(Object), jasmine.any(Function));
});
it("prompts user if policy rejection", function () {
action.perform();
expect(mockPolicyService.allow).toHaveBeenCalled();
mockPolicyService.allow.mostRecentCall.args[3]();
expect(mockWindow.confirm).toHaveBeenCalled();
});
describe("shows a prompt", function () {
beforeEach(function () {
// Ensure the allow callback is called synchronously
mockPolicyService.allow.andCallFake(function () {
return arguments[3]();
});
});
it("does not navigate on prompt rejection", function () {
mockWindow.confirm.andReturn(false);
action.perform();
expect(mockNavigationService.setNavigation)
.not.toHaveBeenCalled();
});
it("does navigate on prompt acceptance", function () {
mockWindow.confirm.andReturn(true);
action.perform();
expect(mockNavigationService.setNavigation)
.toHaveBeenCalled();
});
}); });
it("is only applicable when a domain object is in context", function () { it("is only applicable when a domain object is in context", function () {

View File

@ -50,18 +50,24 @@ define(
//If the object existed already, navigate to refresh view //If the object existed already, navigate to refresh view
// with previous object state. // with previous object state.
if (domainObject.getModel().persisted) { if (domainObject.getModel().persisted) {
domainObject.getCapability("action").perform("navigate"); return domainObject.getCapability("action").perform("navigate");
} else { } else {
//If the object was new, and user has cancelled, then //If the object was new, and user has cancelled, then
//navigate back to parent because nothing to show. //navigate back to parent because nothing to show.
domainObject.getCapability("location").getOriginal().then(function (original) { return domainObject.getCapability("location").getOriginal().then(function (original) {
parent = original.getCapability("context").getParent(); parent = original.getCapability("context").getParent();
parent.getCapability("action").perform("navigate"); parent.getCapability("action").perform("navigate");
}); });
} }
} }
return this.domainObject.getCapability("editor").cancel()
.then(returnToBrowse); function cancel(allowed) {
return allowed && domainObject.getCapability("editor").cancel();
}
//Do navigation first in order to trigger unsaved changes dialog
return returnToBrowse()
.then(cancel);
}; };
/** /**

View File

@ -125,6 +125,9 @@ define(
it("invokes the editor capability's cancel functionality when" + it("invokes the editor capability's cancel functionality when" +
" performed", function () { " performed", function () {
mockDomainObject.getModel.andReturn({persisted: 1});
//Return true from navigate action
capabilities.action.perform.andReturn(mockPromise(true));
action.perform(); action.perform();
// Should have called cancel // Should have called cancel
@ -134,13 +137,15 @@ define(
expect(capabilities.editor.save).not.toHaveBeenCalled(); expect(capabilities.editor.save).not.toHaveBeenCalled();
}); });
it("navigates to object if existing", function () { it("navigates to object if existing using navigate action", function () {
mockDomainObject.getModel.andReturn({persisted: 1}); mockDomainObject.getModel.andReturn({persisted: 1});
//Return true from navigate action
capabilities.action.perform.andReturn(mockPromise(true));
action.perform(); action.perform();
expect(capabilities.action.perform).toHaveBeenCalledWith("navigate"); expect(capabilities.action.perform).toHaveBeenCalledWith("navigate");
}); });
it("navigates to parent if new", function () { it("navigates to parent if new using navigate action", function () {
mockDomainObject.getModel.andReturn({persisted: undefined}); mockDomainObject.getModel.andReturn({persisted: undefined});
action.perform(); action.perform();
expect(parentCapabilities.action.perform).toHaveBeenCalledWith("navigate"); expect(parentCapabilities.action.perform).toHaveBeenCalledWith("navigate");

View File

@ -72,10 +72,10 @@ define(
* Maintain a configuration object on scope that stores column * Maintain a configuration object on scope that stores column
* configuration. On change, synchronize with object model. * configuration. On change, synchronize with object model.
*/ */
$scope.$watchCollection('configuration.table.columns', function (columns) { $scope.$watchCollection('configuration.table.columns', function (newColumns, oldColumns) {
if (columns) { if (newColumns !== oldColumns) {
self.domainObject.useCapability('mutation', function (model) { self.domainObject.useCapability('mutation', function (model) {
model.configuration.table.columns = columns; model.configuration.table.columns = newColumns;
}); });
self.domainObject.getCapability('persistence').persist(); self.domainObject.getCapability('persistence').persist();
} }