diff --git a/platform/commonUI/dialog/src/DialogService.js b/platform/commonUI/dialog/src/DialogService.js index 27ffa9ae8b..0d480f3455 100644 --- a/platform/commonUI/dialog/src/DialogService.js +++ b/platform/commonUI/dialog/src/DialogService.js @@ -181,7 +181,7 @@ define( * @typedef DialogOption * @property {string} label a label to be displayed as the button * text for this action - * @property {function} action a function to be called when the + * @property {function} callback a function to be called when the * button is clicked */ diff --git a/platform/commonUI/themes/snow/res/sass/_constants.scss b/platform/commonUI/themes/snow/res/sass/_constants.scss index 1338b2c335..8963c30b8b 100644 --- a/platform/commonUI/themes/snow/res/sass/_constants.scss +++ b/platform/commonUI/themes/snow/res/sass/_constants.scss @@ -72,20 +72,6 @@ $colorInputBg: $colorGenBg; $colorInputFg: $colorBodyFg; $colorFormText: pushBack($colorBodyFg, 10%); $colorInputIcon: pushBack($colorBodyFg, 25%); - -// Status colors, mainly used for messaging and item ancillary symbols -$colorStatusFg: #fff; -$colorStatusDefault: #ccc; -$colorStatusInfo: #60ba7b; -$colorStatusAlert: #ffb66c; -$colorStatusError: #c96b68; -$colorProgressBarOuter: rgba(#000, 0.1); -$colorProgressBarAmt: #0a0; -$progressBarHOverlay: 15px; -$progressBarStripeW: 20px; -$shdwStatusIc: rgba(white, 0.8) 0 0px 5px; - -// Selects $colorSelectBg: #ddd; $colorSelectFg: $colorBodyFg; diff --git a/platform/entanglement/bundle.json b/platform/entanglement/bundle.json index d8cde0ada6..714d9f7f79 100644 --- a/platform/entanglement/bundle.json +++ b/platform/entanglement/bundle.json @@ -20,7 +20,8 @@ "glyph": "+", "category": "contextual", "implementation": "actions/CopyAction.js", - "depends": ["locationService", "copyService"] + "depends": ["$log", "locationService", "copyService", + "dialogService", "notificationService"] }, { "key": "link", @@ -84,7 +85,8 @@ "name": "Copy Service", "description": "Provides a service for copying objects", "implementation": "services/CopyService.js", - "depends": ["$q", "creationService", "policyService"] + "depends": ["$q", "creationService", "policyService", + "persistenceService", "now"] }, { "key": "locationService", diff --git a/platform/entanglement/src/actions/CopyAction.js b/platform/entanglement/src/actions/CopyAction.js index 3411fdba85..cdcefeb935 100644 --- a/platform/entanglement/src/actions/CopyAction.js +++ b/platform/entanglement/src/actions/CopyAction.js @@ -34,16 +34,103 @@ define( * @constructor * @memberof platform/entanglement */ - function CopyAction(locationService, copyService, context) { - return new AbstractComposeAction( - locationService, - copyService, - context, - "Duplicate", - "to a location" - ); + function CopyAction($log, locationService, copyService, dialogService, + notificationService, context) { + this.dialog = undefined; + this.notification = undefined; + this.dialogService = dialogService; + this.notificationService = notificationService; + this.$log = $log; + //Extend the behaviour of the Abstract Compose Action + AbstractComposeAction.call(this, locationService, copyService, + context, "Duplicate", "to a location"); } + /** + * Updates user about progress of copy. Should not be invoked by + * client code under any circumstances. + * + * @private + * @param phase + * @param totalObjects + * @param processed + */ + CopyAction.prototype.progress = function(phase, totalObjects, processed){ + /* + Copy has two distinct phases. In the first phase a copy plan is + made in memory. During this phase of execution, the user is + shown a blocking 'modal' dialog. + + In the second phase, the copying is taking place, and the user + is shown non-invasive banner notifications at the bottom of the screen. + */ + if (phase.toLowerCase() === 'preparing' && !this.dialog){ + this.dialog = this.dialogService.showBlockingMessage({ + title: "Preparing to copy objects", + unknownProgress: true, + severity: "info" + }); + } else if (phase.toLowerCase() === "copying") { + this.dialogService.dismiss(); + if (!this.notification) { + this.notification = this.notificationService + .notify({ + title: "Copying objects", + unknownProgress: false, + severity: "info" + }); + } + this.notification.model.progress = (processed / totalObjects) * 100; + this.notification.model.title = ["Copied ", processed, "of ", + totalObjects, "objects"].join(" "); + } + }; + + /** + * Executes the CopyAction. The CopyAction uses the default behaviour of + * the AbstractComposeAction, but extends it to support notification + * updates of progress on copy. + */ + CopyAction.prototype.perform = function() { + var self = this; + + function success(){ + self.notification.dismiss(); + self.notificationService.info("Copying complete."); + } + + function error(errorDetails){ + var errorMessage = { + title: "Error copying objects.", + severity: "error", + hint: errorDetails.message, + minimized: true, // want the notification to be minimized initially (don't show banner) + options: [{ + label: "OK", + callback: function() { + self.dialogService.dismiss(); + } + }] + }; + + self.dialogService.dismiss(); + if (self.notification) { + self.notification.dismiss(); // Clear the progress notification + } + self.$log.error("Error copying objects. ", errorDetails); + //Show a minimized notification of error for posterity + self.notificationService.notify(errorMessage); + //Display a blocking message + self.dialogService.showBlockingMessage(errorMessage); + + } + function notification(details){ + self.progress(details.phase, details.totalObjects, details.processed); + } + + return AbstractComposeAction.prototype.perform.call(this) + .then(success, error, notification); + }; return CopyAction; } ); diff --git a/platform/entanglement/src/services/CopyService.js b/platform/entanglement/src/services/CopyService.js index 48ba3e7ce5..29c6be4d10 100644 --- a/platform/entanglement/src/services/CopyService.js +++ b/platform/entanglement/src/services/CopyService.js @@ -23,7 +23,11 @@ /*global define */ define( - function () { + [ + "uuid", + "./CopyTask" + ], + function (uuid, CopyTask) { "use strict"; /** @@ -34,10 +38,12 @@ define( * @memberof platform/entanglement * @implements {platform/entanglement.AbstractComposeService} */ - function CopyService($q, creationService, policyService) { + function CopyService($q, creationService, policyService, persistenceService, now) { this.$q = $q; this.creationService = creationService; this.policyService = policyService; + this.persistenceService = persistenceService; + this.now = now; } CopyService.prototype.validate = function (object, parentCandidate) { @@ -54,45 +60,25 @@ define( ); }; + /** + * Creates a duplicate of the object tree starting at domainObject to + * the new parent specified. + * @param domainObject + * @param parent + * @param progress + * @returns a promise that will be completed with the clone of + * domainObject when the duplication is successful. + */ CopyService.prototype.perform = function (domainObject, parent) { - var model = JSON.parse(JSON.stringify(domainObject.getModel())), - $q = this.$q, - self = this; - - // Wrapper for the recursive step - function duplicateObject(domainObject, parent) { - return self.perform(domainObject, parent); - } - - if (!this.validate(domainObject, parent)) { + var $q = this.$q, + copyTask = new CopyTask(domainObject, parent, this.persistenceService, this.$q, this.now); + if (this.validate(domainObject, parent)) { + return copyTask.perform(); + } else { throw new Error( "Tried to copy objects without validating first." ); } - - if (domainObject.hasCapability('composition')) { - model.composition = []; - } - - return this.creationService - .createObject(model, parent) - .then(function (newObject) { - if (!domainObject.hasCapability('composition')) { - return; - } - - return domainObject - .useCapability('composition') - .then(function (composees) { - // Duplicate composition serially to prevent - // write conflicts. - return composees.reduce(function (promise, composee) { - return promise.then(function () { - return duplicateObject(composee, newObject); - }); - }, $q.when(undefined)); - }); - }); }; return CopyService; diff --git a/platform/entanglement/src/services/CopyTask.js b/platform/entanglement/src/services/CopyTask.js new file mode 100644 index 0000000000..f484856448 --- /dev/null +++ b/platform/entanglement/src/services/CopyTask.js @@ -0,0 +1,198 @@ +/***************************************************************************** + * Open MCT Web, Copyright (c) 2014-2015, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT Web is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT Web includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/*global define */ + +define( + ["uuid"], + function (uuid) { + "use strict"; + + /** + * This class encapsulates the process of copying a domain object + * and all of its children. + * + * @param domainObject The object to copy + * @param parent The new location of the cloned object tree + * @param persistenceService + * @param $q + * @param now + * @constructor + */ + function CopyTask (domainObject, parent, persistenceService, $q, now){ + this.domainObject = domainObject; + this.parent = parent; + this.$q = $q; + this.deferred = undefined; + this.persistenceService = persistenceService; + this.persisted = 0; + this.now = now; + this.clones = []; + } + + function composeChild(child, parent) { + //Once copied, associate each cloned + // composee with its parent clone + child.model.location = parent.id; + parent.model.composition = parent.model.composition || []; + return parent.model.composition.push(child.id); + } + + function cloneObjectModel(objectModel) { + var clone = JSON.parse(JSON.stringify(objectModel)); + + delete clone.composition; + delete clone.persisted; + delete clone.modified; + + return clone; + } + + /** + * Will persist a list of {@link objectClones}. It will persist all + * simultaneously, irrespective of order in the list. This may + * result in automatic request batching by the browser. + */ + function persistObjects(self) { + + return self.$q.all(self.clones.map(function(clone){ + clone.model.persisted = self.now(); + return self.persistenceService.createObject(clone.persistenceSpace, clone.id, clone.model) + .then(function(){ + self.deferred.notify({phase: "copying", totalObjects: self.clones.length, processed: ++self.persisted}); + }); + })).then(function(){ + return self; + }); + } + + /** + * Will add a list of clones to the specified parent's composition + */ + function addClonesToParent(self) { + var parentClone = self.clones[self.clones.length-1]; + + if (!self.parent.hasCapability('composition')){ + return self.$q.reject(); + } + + return self.persistenceService + .updateObject(parentClone.persistenceSpace, parentClone.id, parentClone.model) + .then(function(){return self.parent.getCapability("composition").add(parentClone.id);}) + .then(function(){return self.parent.getCapability("persistence").persist();}) + .then(function(){return parentClone;}); + // Ensure the clone of the original domainObject is returned + } + + /** + * Given an array of objects composed by a parent, clone them, then + * add them to the parent. + * @private + * @returns {*} + */ + CopyTask.prototype.copyComposees = function(composees, clonedParent, originalParent){ + var self = this; + + return (composees || []).reduce(function(promise, composee){ + //If the composee is composed of other + // objects, chain a promise.. + return promise.then(function(){ + // ...to recursively copy it (and its children) + return self.copy(composee, originalParent).then(function(composee){ + composeChild(composee, clonedParent); + }); + });}, self.$q.when(undefined) + ); + }; + + /** + * A recursive function that will perform a bottom-up copy of + * the object tree with originalObject at the root. Recurses to + * the farthest leaf, then works its way back up again, + * cloning objects, and composing them with their child clones + * as it goes + * @private + * @param originalObject + * @param originalParent + * @returns {*} + */ + CopyTask.prototype.copy = function(originalObject, originalParent) { + var self = this, + modelClone = { + id: uuid(), + model: cloneObjectModel(originalObject.getModel()), + persistenceSpace: originalParent.hasCapability('persistence') && originalParent.getCapability('persistence').getSpace() + }; + + return this.$q.when(originalObject.useCapability('composition')).then(function(composees){ + self.deferred.notify({phase: "preparing"}); + //Duplicate the object's children, and their children, and + // so on down to the leaf nodes of the tree. + return self.copyComposees(composees, modelClone, originalObject).then(function (){ + //Add the clone to the list of clones that will + //be returned by this function + self.clones.push(modelClone); + return modelClone; + }); + }); + }; + + /** + * Will build a graph of an object and all of its child objects in + * memory + * @private + * @param domainObject The original object to be copied + * @param parent The parent of the original object to be copied + * @returns {Promise} resolved with an array of clones of the models + * of the object tree being copied. Copying is done in a bottom-up + * fashion, so that the last member in the array is a clone of the model + * object being copied. The clones are all full composed with + * references to their own children. + */ + CopyTask.prototype.buildCopyPlan = function() { + var self = this; + + return this.copy(self.domainObject, self.parent).then(function(domainObjectClone){ + domainObjectClone.model.location = self.parent.getId(); + return self; + }); + }; + + /** + * Execute the copy task with the objects provided in the constructor. + * @returns {promise} Which will resolve with a clone of the object + * once complete. + */ + CopyTask.prototype.perform = function(){ + this.deferred = this.$q.defer(); + + this.buildCopyPlan() + .then(persistObjects) + .then(addClonesToParent) + .then(this.deferred.resolve, this.deferred.reject); + + return this.deferred.promise; + }; + + return CopyTask; + } +); \ No newline at end of file diff --git a/platform/entanglement/test/actions/CopyActionSpec.js b/platform/entanglement/test/actions/CopyActionSpec.js index 4284a22e59..fc8e615960 100644 --- a/platform/entanglement/test/actions/CopyActionSpec.js +++ b/platform/entanglement/test/actions/CopyActionSpec.js @@ -20,7 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -/*global define,describe,beforeEach,it,jasmine,expect */ +/*global define,describe,beforeEach,it,jasmine,expect,spyOn */ define( [ @@ -41,7 +41,13 @@ define( selectedObject, selectedObjectContextCapability, currentParent, - newParent; + newParent, + notificationService, + notification, + dialogService, + mockLog, + abstractComposePromise, + progress = {phase: "copying", totalObjects: 10, processed: 1}; beforeEach(function () { selectedObjectContextCapability = jasmine.createSpyObj( @@ -87,10 +93,43 @@ define( ] ); + abstractComposePromise = jasmine.createSpyObj( + 'abstractComposePromise', + [ + 'then' + ] + ); + + abstractComposePromise.then.andCallFake(function(success, error, notify){ + notify(progress); + success(); + }); + + locationServicePromise.then.andCallFake(function(callback){ + callback(newParent); + return abstractComposePromise; + }); + locationService .getLocationFromUser .andReturn(locationServicePromise); + dialogService = jasmine.createSpyObj('dialogService', + ['showBlockingMessage', 'dismiss'] + ); + + notification = jasmine.createSpyObj('notification', + ['dismiss', 'model'] + ); + + notificationService = jasmine.createSpyObj('notificationService', + ['notify', 'info'] + ); + + notificationService.notify.andReturn(notification); + + mockLog = jasmine.createSpyObj('log', ['error']); + copyService = new MockCopyService(); }); @@ -102,8 +141,11 @@ define( }; copyAction = new CopyAction( + mockLog, locationService, copyService, + dialogService, + notificationService, context ); }); @@ -114,6 +156,7 @@ define( describe("when performed it", function () { beforeEach(function () { + spyOn(copyAction, 'progress').andCallThrough(); copyAction.perform(); }); @@ -132,7 +175,7 @@ define( .toHaveBeenCalledWith(jasmine.any(Function)); }); - it("copys object to selected location", function () { + it("copies object to selected location", function () { locationServicePromise .then .mostRecentCall @@ -141,6 +184,11 @@ define( expect(copyService.perform) .toHaveBeenCalledWith(selectedObject, newParent); }); + + it("notifies the user of progress", function(){ + expect(notificationService.info).toHaveBeenCalled(); + }); + }); }); @@ -152,8 +200,11 @@ define( }; copyAction = new CopyAction( + mockLog, locationService, copyService, + dialogService, + notificationService, context ); }); diff --git a/platform/entanglement/test/services/CopyServiceSpec.js b/platform/entanglement/test/services/CopyServiceSpec.js index 2788fcefa8..391d90913c 100644 --- a/platform/entanglement/test/services/CopyServiceSpec.js +++ b/platform/entanglement/test/services/CopyServiceSpec.js @@ -31,6 +31,10 @@ define( "use strict"; function synchronousPromise(value) { + if (value && value.then) { + return value; + } + var promise = { then: function (callback) { return synchronousPromise(callback(value)); @@ -122,13 +126,19 @@ define( describe("perform", function () { var mockQ, + mockDeferred, creationService, createObjectPromise, copyService, + mockPersistenceService, + mockNow, object, newParent, copyResult, - copyFinished; + copyFinished, + persistObjectPromise, + parentPersistenceCapability, + resolvedValue; beforeEach(function () { creationService = jasmine.createSpyObj( @@ -138,44 +148,93 @@ define( createObjectPromise = synchronousPromise(undefined); creationService.createObject.andReturn(createObjectPromise); policyService.allow.andReturn(true); + + mockPersistenceService = jasmine.createSpyObj( + 'persistenceService', + ['createObject', 'updateObject'] + ); + persistObjectPromise = synchronousPromise(undefined); + mockPersistenceService.createObject.andReturn(persistObjectPromise); + mockPersistenceService.updateObject.andReturn(persistObjectPromise); + + parentPersistenceCapability = jasmine.createSpyObj( + "persistence", + [ "persist", "getSpace" ] + ); + + parentPersistenceCapability.persist.andReturn(persistObjectPromise); + parentPersistenceCapability.getSpace.andReturn("testSpace"); + + mockNow = jasmine.createSpyObj("mockNow", ["now"]); + mockNow.now.andCallFake(function(){ + return 1234; + }); + + mockDeferred = jasmine.createSpyObj('mockDeferred', ['notify', 'resolve']); + mockDeferred.notify.andCallFake(function(notification){}); + mockDeferred.resolve.andCallFake(function(value){resolvedValue = value;}); + mockDeferred.promise = { + then: function(callback){ + return synchronousPromise(callback(resolvedValue)); + } + }; + + mockQ = jasmine.createSpyObj('mockQ', ['when', 'all', 'reject', 'defer']); + mockQ.when.andCallFake(synchronousPromise); + mockQ.all.andCallFake(function (promises) { + var result = {}; + Object.keys(promises).forEach(function (k) { + promises[k].then(function (v) { result[k] = v; }); + }); + return synchronousPromise(result); + }); + mockQ.defer.andReturn(mockDeferred); + }); describe("on domain object without composition", function () { beforeEach(function () { - object = domainObjectFactory({ - name: 'object', - id: 'abc', - model: { - name: 'some object' - } - }); newParent = domainObjectFactory({ name: 'newParent', id: '456', model: { composition: [] + }, + capabilities: { + persistence: parentPersistenceCapability } }); - copyService = new CopyService(null, creationService, policyService); + + object = domainObjectFactory({ + name: 'object', + id: 'abc', + model: { + name: 'some object', + location: newParent.id, + persisted: mockNow.now() + } + }); + + copyService = new CopyService(mockQ, creationService, policyService, mockPersistenceService, mockNow.now); copyResult = copyService.perform(object, newParent); copyFinished = jasmine.createSpy('copyFinished'); copyResult.then(copyFinished); }); - it("uses creation service", function () { - expect(creationService.createObject) - .toHaveBeenCalledWith(jasmine.any(Object), newParent); - - expect(createObjectPromise.then) - .toHaveBeenCalledWith(jasmine.any(Function)); - }); + it("uses persistence service", function () { + expect(mockPersistenceService.createObject) + .toHaveBeenCalledWith(parentPersistenceCapability.getSpace(), jasmine.any(String), object.getModel()); + expect(persistObjectPromise.then) + .toHaveBeenCalledWith(jasmine.any(Function)); + }); + it("deep clones object model", function () { - var newModel = creationService + //var newModel = creationService + var newModel = mockPersistenceService .createObject .mostRecentCall - .args[0]; - + .args[2]; expect(newModel).toEqual(object.model); expect(newModel).not.toBe(object.model); }); @@ -191,11 +250,15 @@ define( var newObject, childObject, compositionCapability, + locationCapability, compositionPromise; beforeEach(function () { - mockQ = jasmine.createSpyObj('mockQ', ['when']); - mockQ.when.andCallFake(synchronousPromise); + + + locationCapability = jasmine.createSpyObj('locationCapability', ['isLink']); + locationCapability.isLink.andReturn(true); + childObject = domainObjectFactory({ name: 'childObject', id: 'def', @@ -205,24 +268,28 @@ define( }); compositionCapability = jasmine.createSpyObj( 'compositionCapability', - ['invoke'] + ['invoke', 'add'] ); compositionPromise = jasmine.createSpyObj( 'compositionPromise', ['then'] ); + compositionCapability .invoke - .andReturn(compositionPromise); + .andReturn(synchronousPromise([childObject])); + object = domainObjectFactory({ name: 'object', id: 'abc', model: { name: 'some object', - composition: ['def'] + composition: ['def'], + location: 'testLocation' }, capabilities: { - composition: compositionCapability + composition: compositionCapability, + location: locationCapability } }); newObject = domainObjectFactory({ @@ -241,45 +308,45 @@ define( id: '456', model: { composition: [] + }, + capabilities: { + composition: compositionCapability, + persistence: parentPersistenceCapability } }); createObjectPromise = synchronousPromise(newObject); creationService.createObject.andReturn(createObjectPromise); - copyService = new CopyService(mockQ, creationService, policyService); - copyResult = copyService.perform(object, newParent); - copyFinished = jasmine.createSpy('copyFinished'); - copyResult.then(copyFinished); + copyService = new CopyService(mockQ, creationService, policyService, mockPersistenceService, mockNow.now); }); - it("uses creation service", function () { - expect(creationService.createObject) - .toHaveBeenCalledWith(jasmine.any(Object), newParent); + describe("the cloning process", function(){ + beforeEach(function() { + copyResult = copyService.perform(object, newParent); + copyFinished = jasmine.createSpy('copyFinished'); + copyResult.then(copyFinished); + }); - expect(createObjectPromise.then) - .toHaveBeenCalledWith(jasmine.any(Function)); - }); + it("copies object and children in a bottom-up" + + " fashion", function () { + expect(mockPersistenceService.createObject.calls[0].args[2].name).toEqual(childObject.model.name); + expect(mockPersistenceService.createObject.calls[1].args[2].name).toEqual(object.model.name); + }); - it("clears model composition", function () { - var newModel = creationService - .createObject - .mostRecentCall - .args[0]; + it("returns a promise", function () { + expect(copyResult.then).toBeDefined(); + expect(copyFinished).toHaveBeenCalled(); + }); - expect(newModel.composition.length).toBe(0); - expect(newModel.name).toBe('some object'); - }); + it("clears modified and sets persisted", function () { + expect(copyFinished.mostRecentCall.args[0].model.modified).toBeUndefined(); + expect(copyFinished.mostRecentCall.args[0].model.persisted).toBe(mockNow.now()); + }); - it("recursively clones it's children", function () { - expect(creationService.createObject.calls.length).toBe(1); - expect(compositionCapability.invoke).toHaveBeenCalled(); - compositionPromise.then.mostRecentCall.args[0]([childObject]); - expect(creationService.createObject.calls.length).toBe(2); - }); + it ("correctly locates cloned objects", function() { + expect(mockPersistenceService.createObject.calls[0].args[2].location).toEqual(mockPersistenceService.createObject.calls[1].args[1]); + }); - it("returns a promise", function () { - expect(copyResult.then).toBeDefined(); - expect(copyFinished).toHaveBeenCalled(); }); }); @@ -301,7 +368,7 @@ define( it("throws an error", function () { var copyService = - new CopyService(mockQ, creationService, policyService); + new CopyService(mockQ, creationService, policyService, mockPersistenceService, mockNow.now); function perform() { copyService.perform(object, newParent);