Merge pull request #249 from nasa/open127

[UI] Progress indicator for pending operations (e.g. duplicate)
This commit is contained in:
Victor Woeltjen 2015-11-06 10:57:42 -08:00
commit 96a7c12d69
8 changed files with 494 additions and 117 deletions

View File

@ -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
*/

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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