diff --git a/platform/entanglement/bundle.js b/platform/entanglement/bundle.js index 9715ba20c0..23da011594 100644 --- a/platform/entanglement/bundle.js +++ b/platform/entanglement/bundle.js @@ -22,7 +22,6 @@ define([ "./src/actions/MoveAction", - "./src/actions/CopyAction", "./src/actions/LinkAction", "./src/actions/SetPrimaryLocationAction", "./src/services/LocatingCreationDecorator", @@ -37,7 +36,6 @@ define([ "./src/services/LocationService" ], function ( MoveAction, - CopyAction, LinkAction, SetPrimaryLocationAction, LocatingCreationDecorator, @@ -75,24 +73,6 @@ define([ "moveService" ] }, - { - "key": "copy", - "name": "Duplicate", - "description": "Duplicate object to another location.", - "cssClass": "icon-duplicate", - "category": "contextual", - "group": "action", - "priority": 8, - "implementation": CopyAction, - "depends": [ - "$log", - "policyService", - "locationService", - "copyService", - "dialogService", - "notificationService" - ] - }, { "key": "link", "name": "Create Link", diff --git a/platform/entanglement/src/actions/CopyAction.js b/platform/entanglement/src/actions/CopyAction.js deleted file mode 100644 index 2cd26826f5..0000000000 --- a/platform/entanglement/src/actions/CopyAction.js +++ /dev/null @@ -1,168 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2020, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -define( - ['./AbstractComposeAction', './CancelError'], - function (AbstractComposeAction, CancelError) { - - /** - * The CopyAction is available from context menus and allows a user to - * deep copy an object to another location of their choosing. - * - * @implements {Action} - * @constructor - * @memberof platform/entanglement - */ - function CopyAction( - $log, - policyService, - 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, - policyService, - locationService, - copyService, - context, - "Duplicate", - "To a Location" - ); - } - - CopyAction.prototype = Object.create(AbstractComposeAction.prototype); - - /** - * 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", - hint: "Do not navigate away from this page or close this browser tab while this message is displayed.", - unknownProgress: true, - severity: "info" - }); - } else if (phase.toLowerCase() === "copying") { - if (this.dialog) { - this.dialog.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(domainObject) { - var domainObjectName = domainObject.model.name; - - self.notification.dismiss(); - self.notificationService.info(domainObjectName + " copied successfully."); - } - - function error(errorDetails) { - // No need to notify user of their own cancellation - if (errorDetails instanceof CancelError) { - return; - } - - var errorDialog, - 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 () { - errorDialog.dismiss(); - } - }] - }; - - self.dialog.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 - errorDialog = 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); - }; - - CopyAction.appliesTo = AbstractComposeAction.appliesTo; - - return CopyAction; - } -); diff --git a/platform/entanglement/test/actions/CopyActionSpec.js b/platform/entanglement/test/actions/CopyActionSpec.js deleted file mode 100644 index 8556230f15..0000000000 --- a/platform/entanglement/test/actions/CopyActionSpec.js +++ /dev/null @@ -1,243 +0,0 @@ -/***************************************************************************** - * Open MCT, Copyright (c) 2014-2020, United States Government - * as represented by the Administrator of the National Aeronautics and Space - * Administration. All rights reserved. - * - * Open MCT 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 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. - *****************************************************************************/ - -define( - [ - '../../src/actions/CopyAction', - '../services/MockCopyService', - '../DomainObjectFactory' - ], - function (CopyAction, MockCopyService, domainObjectFactory) { - - describe("Copy Action", function () { - - var copyAction, - policyService, - locationService, - locationServicePromise, - copyService, - context, - selectedObject, - selectedObjectContextCapability, - currentParent, - newParent, - notificationService, - notification, - dialogService, - mockDialog, - mockLog, - abstractComposePromise, - domainObject = {model: {name: "mockObject"}}, - progress = { - phase: "copying", - totalObjects: 10, - processed: 1 - }; - - beforeEach(function () { - policyService = jasmine.createSpyObj( - 'policyService', - ['allow'] - ); - policyService.allow.and.returnValue(true); - - selectedObjectContextCapability = jasmine.createSpyObj( - 'selectedObjectContextCapability', - [ - 'getParent' - ] - ); - - selectedObject = domainObjectFactory({ - name: 'selectedObject', - model: { - name: 'selectedObject' - }, - capabilities: { - context: selectedObjectContextCapability - } - }); - - currentParent = domainObjectFactory({ - name: 'currentParent' - }); - - selectedObjectContextCapability - .getParent - .and.returnValue(currentParent); - - newParent = domainObjectFactory({ - name: 'newParent' - }); - - locationService = jasmine.createSpyObj( - 'locationService', - [ - 'getLocationFromUser' - ] - ); - - locationServicePromise = jasmine.createSpyObj( - 'locationServicePromise', - [ - 'then' - ] - ); - - abstractComposePromise = jasmine.createSpyObj( - 'abstractComposePromise', - [ - 'then' - ] - ); - - abstractComposePromise.then.and.callFake(function (success, error, notify) { - notify(progress); - success(domainObject); - }); - - locationServicePromise.then.and.callFake(function (callback) { - callback(newParent); - - return abstractComposePromise; - }); - - locationService - .getLocationFromUser - .and.returnValue(locationServicePromise); - - dialogService = jasmine.createSpyObj('dialogService', - ['showBlockingMessage'] - ); - - mockDialog = jasmine.createSpyObj("dialog", ["dismiss"]); - dialogService.showBlockingMessage.and.returnValue(mockDialog); - - notification = jasmine.createSpyObj('notification', - ['dismiss', 'model'] - ); - - notificationService = jasmine.createSpyObj('notificationService', - ['notify', 'info'] - ); - - notificationService.notify.and.returnValue(notification); - - mockLog = jasmine.createSpyObj('log', ['error']); - - copyService = new MockCopyService(); - }); - - describe("with context from context-action", function () { - beforeEach(function () { - context = { - domainObject: selectedObject - }; - - copyAction = new CopyAction( - mockLog, - policyService, - locationService, - copyService, - dialogService, - notificationService, - context - ); - }); - - it("initializes happily", function () { - expect(copyAction).toBeDefined(); - }); - - describe("when performed it", function () { - beforeEach(function () { - spyOn(copyAction, 'progress').and.callThrough(); - copyAction.perform(); - }); - - it("prompts for location", function () { - expect(locationService.getLocationFromUser) - .toHaveBeenCalledWith( - "Duplicate selectedObject To a Location", - "Duplicate To", - jasmine.any(Function), - currentParent - ); - }); - - it("waits for location and handles cancellation by user", function () { - expect(locationServicePromise.then) - .toHaveBeenCalledWith(jasmine.any(Function), jasmine.any(Function)); - }); - - it("copies object to selected location", function () { - locationServicePromise - .then - .calls.mostRecent() - .args[0](newParent); - - expect(copyService.perform) - .toHaveBeenCalledWith(selectedObject, newParent); - }); - - it("notifies the user of progress", function () { - expect(notificationService.info).toHaveBeenCalled(); - }); - - it("notifies the user with name of object copied", function () { - expect(notificationService.info) - .toHaveBeenCalledWith("mockObject copied successfully."); - }); - }); - }); - - describe("with context from drag-drop", function () { - beforeEach(function () { - context = { - selectedObject: selectedObject, - domainObject: newParent - }; - - copyAction = new CopyAction( - mockLog, - policyService, - locationService, - copyService, - dialogService, - notificationService, - context - ); - }); - - it("initializes happily", function () { - expect(copyAction).toBeDefined(); - }); - - it("performs copy immediately", function () { - copyAction.perform(); - expect(copyService.perform) - .toHaveBeenCalledWith(selectedObject, newParent); - }); - }); - }); - } -); diff --git a/src/MCT.js b/src/MCT.js index e3ed1ab945..0e07aa8d5c 100644 --- a/src/MCT.js +++ b/src/MCT.js @@ -46,6 +46,7 @@ define([ './api/Branding', './plugins/licenses/plugin', './plugins/remove/plugin', + './plugins/duplicate/plugin', 'vue' ], function ( EventEmitter, @@ -73,6 +74,7 @@ define([ BrandingAPI, LicensesPlugin, RemoveActionPlugin, + DuplicateActionPlugin, Vue ) { /** @@ -263,6 +265,7 @@ define([ this.install(LegacyIndicatorsPlugin()); this.install(LicensesPlugin.default()); this.install(RemoveActionPlugin.default()); + this.install(DuplicateActionPlugin.default()); this.install(this.plugins.FolderView()); this.install(this.plugins.Tabs()); this.install(ImageryPlugin.default()); diff --git a/src/plugins/autoflow/AutoflowTabularPluginSpec.js b/src/plugins/autoflow/AutoflowTabularPluginSpec.js index eb7aea1e1f..1cde374190 100644 --- a/src/plugins/autoflow/AutoflowTabularPluginSpec.js +++ b/src/plugins/autoflow/AutoflowTabularPluginSpec.js @@ -19,342 +19,352 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ +import AutoflowTabularPlugin from './AutoflowTabularPlugin'; +import AutoflowTabularConstants from './AutoflowTabularConstants'; +import $ from 'zepto'; +import DOMObserver from './dom-observer'; +import { + createOpenMct, + resetApplicationState, + spyOnBuiltins +} from 'utils/testing'; -define([ - './AutoflowTabularPlugin', - './AutoflowTabularConstants', - '../../MCT', - 'zepto', - './dom-observer' -], function (AutoflowTabularPlugin, AutoflowTabularConstants, MCT, $, DOMObserver) { - describe("AutoflowTabularPlugin", function () { - let testType; - let testObject; - let mockmct; +describe("AutoflowTabularPlugin", () => { + let testType; + let testObject; + let mockmct; - beforeEach(function () { - testType = "some-type"; - testObject = { type: testType }; - mockmct = new MCT(); - spyOn(mockmct.composition, 'get'); - spyOn(mockmct.objectViews, 'addProvider'); - spyOn(mockmct.telemetry, 'getMetadata'); - spyOn(mockmct.telemetry, 'getValueFormatter'); - spyOn(mockmct.telemetry, 'limitEvaluator'); - spyOn(mockmct.telemetry, 'request'); - spyOn(mockmct.telemetry, 'subscribe'); + beforeEach(() => { + testType = "some-type"; + testObject = { type: testType }; + mockmct = createOpenMct(); + spyOn(mockmct.composition, 'get'); + spyOn(mockmct.objectViews, 'addProvider'); + spyOn(mockmct.telemetry, 'getMetadata'); + spyOn(mockmct.telemetry, 'getValueFormatter'); + spyOn(mockmct.telemetry, 'limitEvaluator'); + spyOn(mockmct.telemetry, 'request'); + spyOn(mockmct.telemetry, 'subscribe'); - const plugin = new AutoflowTabularPlugin({ type: testType }); - plugin(mockmct); + const plugin = new AutoflowTabularPlugin({ type: testType }); + plugin(mockmct); + }); + + afterEach(() => { + resetApplicationState(mockmct); + }); + + it("installs a view provider", () => { + expect(mockmct.objectViews.addProvider).toHaveBeenCalled(); + }); + + describe("installs a view provider which", () => { + let provider; + + beforeEach(() => { + provider = + mockmct.objectViews.addProvider.calls.mostRecent().args[0]; }); - it("installs a view provider", function () { - expect(mockmct.objectViews.addProvider).toHaveBeenCalled(); + it("applies its view to the type from options", () => { + expect(provider.canView(testObject)).toBe(true); }); - describe("installs a view provider which", function () { - let provider; + it("does not apply to other types", () => { + expect(provider.canView({ type: 'foo' })).toBe(false); + }); - beforeEach(function () { - provider = - mockmct.objectViews.addProvider.calls.mostRecent().args[0]; - }); + describe("provides a view which", () => { + let testKeys; + let testChildren; + let testContainer; + let testHistories; + let mockComposition; + let mockMetadata; + let mockEvaluator; + let mockUnsubscribes; + let callbacks; + let view; + let domObserver; - it("applies its view to the type from options", function () { - expect(provider.canView(testObject)).toBe(true); - }); + function waitsForChange() { + return new Promise(function (resolve) { + window.requestAnimationFrame(resolve); + }); + } - it("does not apply to other types", function () { - expect(provider.canView({ type: 'foo' })).toBe(false); - }); + function emitEvent(mockEmitter, type, event) { + mockEmitter.on.calls.all().forEach((call) => { + if (call.args[0] === type) { + call.args[1](event); + } + }); + } - describe("provides a view which", function () { - let testKeys; - let testChildren; - let testContainer; - let testHistories; - let mockComposition; - let mockMetadata; - let mockEvaluator; - let mockUnsubscribes; - let callbacks; - let view; - let domObserver; + beforeEach((done) => { + callbacks = {}; - function waitsForChange() { - return new Promise(function (resolve) { - window.requestAnimationFrame(resolve); + spyOnBuiltins(['requestAnimationFrame']); + window.requestAnimationFrame.and.callFake((callBack) => { + callBack(); + }); + + testObject = { type: 'some-type' }; + testKeys = ['abc', 'def', 'xyz']; + testChildren = testKeys.map((key) => { + return { + identifier: { + namespace: "test", + key: key + }, + name: "Object " + key + }; + }); + testContainer = $('
')[0]; + domObserver = new DOMObserver(testContainer); + + testHistories = testKeys.reduce((histories, key, index) => { + histories[key] = { + key: key, + range: index + 10, + domain: key + index + }; + + return histories; + }, {}); + + mockComposition = + jasmine.createSpyObj('composition', ['load', 'on', 'off']); + mockMetadata = + jasmine.createSpyObj('metadata', ['valuesForHints']); + + mockEvaluator = jasmine.createSpyObj('evaluator', ['evaluate']); + mockUnsubscribes = testKeys.reduce((map, key) => { + map[key] = jasmine.createSpy('unsubscribe-' + key); + + return map; + }, {}); + + mockmct.composition.get.and.returnValue(mockComposition); + mockComposition.load.and.callFake(() => { + testChildren.forEach(emitEvent.bind(null, mockComposition, 'add')); + + return Promise.resolve(testChildren); + }); + + mockmct.telemetry.getMetadata.and.returnValue(mockMetadata); + mockmct.telemetry.getValueFormatter.and.callFake((metadatum) => { + const mockFormatter = jasmine.createSpyObj('formatter', ['format']); + mockFormatter.format.and.callFake((datum) => { + return datum[metadatum.hint]; }); + + return mockFormatter; + }); + mockmct.telemetry.limitEvaluator.and.returnValue(mockEvaluator); + mockmct.telemetry.subscribe.and.callFake((obj, callback) => { + const key = obj.identifier.key; + callbacks[key] = callback; + + return mockUnsubscribes[key]; + }); + mockmct.telemetry.request.and.callFake((obj, request) => { + const key = obj.identifier.key; + + return Promise.resolve([testHistories[key]]); + }); + mockMetadata.valuesForHints.and.callFake((hints) => { + return [{ hint: hints[0] }]; + }); + + view = provider.view(testObject); + view.show(testContainer); + + return done(); + }); + + afterEach(() => { + domObserver.destroy(); + }); + + it("populates its container", () => { + expect(testContainer.children.length > 0).toBe(true); + }); + + describe("when rows have been populated", () => { + function rowsMatch() { + const rows = $(testContainer).find(".l-autoflow-row").length; + + return rows === testChildren.length; } - function emitEvent(mockEmitter, type, event) { - mockEmitter.on.calls.all().forEach(function (call) { - if (call.args[0] === type) { - call.args[1](event); - } - }); + it("shows one row per child object", () => { + return domObserver.when(rowsMatch); + }); + + // it("adds rows on composition change", () => { + // const child = { + // identifier: { + // namespace: "test", + // key: "123" + // }, + // name: "Object 123" + // }; + // testChildren.push(child); + // emitEvent(mockComposition, 'add', child); + + // return domObserver.when(rowsMatch); + // }); + + it("removes rows on composition change", () => { + const child = testChildren.pop(); + emitEvent(mockComposition, 'remove', child.identifier); + + return domObserver.when(rowsMatch); + }); + }); + + it("removes subscriptions when destroyed", () => { + testKeys.forEach((key) => { + expect(mockUnsubscribes[key]).not.toHaveBeenCalled(); + }); + view.destroy(); + testKeys.forEach((key) => { + expect(mockUnsubscribes[key]).toHaveBeenCalled(); + }); + }); + + it("provides a button to change column width", () => { + const initialWidth = AutoflowTabularConstants.INITIAL_COLUMN_WIDTH; + const nextWidth = + initialWidth + AutoflowTabularConstants.COLUMN_WIDTH_STEP; + + expect($(testContainer).find('.l-autoflow-col').css('width')) + .toEqual(initialWidth + 'px'); + + $(testContainer).find('.change-column-width').click(); + + function widthHasChanged() { + const width = $(testContainer).find('.l-autoflow-col').css('width'); + + return width !== initialWidth + 'px'; } - beforeEach(function () { - callbacks = {}; - - testObject = { type: 'some-type' }; - testKeys = ['abc', 'def', 'xyz']; - testChildren = testKeys.map(function (key) { - return { - identifier: { - namespace: "test", - key: key - }, - name: "Object " + key - }; + return domObserver.when(widthHasChanged) + .then(() => { + expect($(testContainer).find('.l-autoflow-col').css('width')) + .toEqual(nextWidth + 'px'); }); - testContainer = $('
')[0]; - domObserver = new DOMObserver(testContainer); + }); - testHistories = testKeys.reduce(function (histories, key, index) { - histories[key] = { - key: key, - range: index + 10, - domain: key + index - }; + it("subscribes to all child objects", () => { + testKeys.forEach((key) => { + expect(callbacks[key]).toEqual(jasmine.any(Function)); + }); + }); - return histories; - }, {}); + it("displays historical telemetry", () => { + function rowTextDefined() { + return $(testContainer).find(".l-autoflow-item").filter(".r").text() !== ""; + } - mockComposition = - jasmine.createSpyObj('composition', ['load', 'on', 'off']); - mockMetadata = - jasmine.createSpyObj('metadata', ['valuesForHints']); - - mockEvaluator = jasmine.createSpyObj('evaluator', ['evaluate']); - mockUnsubscribes = testKeys.reduce(function (map, key) { - map[key] = jasmine.createSpy('unsubscribe-' + key); - - return map; - }, {}); - - mockmct.composition.get.and.returnValue(mockComposition); - mockComposition.load.and.callFake(function () { - testChildren.forEach(emitEvent.bind(null, mockComposition, 'add')); - - return Promise.resolve(testChildren); + return domObserver.when(rowTextDefined).then(() => { + testKeys.forEach((key, index) => { + const datum = testHistories[key]; + const $cell = $(testContainer).find(".l-autoflow-row").eq(index).find(".r"); + expect($cell.text()).toEqual(String(datum.range)); }); + }); + }); - mockmct.telemetry.getMetadata.and.returnValue(mockMetadata); - mockmct.telemetry.getValueFormatter.and.callFake(function (metadatum) { - const mockFormatter = jasmine.createSpyObj('formatter', ['format']); - mockFormatter.format.and.callFake(function (datum) { - return datum[metadatum.hint]; - }); - - return mockFormatter; - }); - mockmct.telemetry.limitEvaluator.and.returnValue(mockEvaluator); - mockmct.telemetry.subscribe.and.callFake(function (obj, callback) { - const key = obj.identifier.key; - callbacks[key] = callback; - - return mockUnsubscribes[key]; - }); - mockmct.telemetry.request.and.callFake(function (obj, request) { - const key = obj.identifier.key; - - return Promise.resolve([testHistories[key]]); - }); - mockMetadata.valuesForHints.and.callFake(function (hints) { - return [{ hint: hints[0] }]; - }); - - view = provider.view(testObject); - view.show(testContainer); - - return waitsForChange(); + it("displays incoming telemetry", () => { + const testData = testKeys.map((key, index) => { + return { + key: key, + range: index * 100, + domain: key + index + }; }); - afterEach(function () { - domObserver.destroy(); + testData.forEach((datum) => { + callbacks[datum.key](datum); }); - it("populates its container", function () { - expect(testContainer.children.length > 0).toBe(true); + return waitsForChange().then(() => { + testData.forEach((datum, index) => { + const $cell = $(testContainer).find(".l-autoflow-row").eq(index).find(".r"); + expect($cell.text()).toEqual(String(datum.range)); + }); }); + }); - describe("when rows have been populated", function () { - function rowsMatch() { - const rows = $(testContainer).find(".l-autoflow-row").length; - - return rows === testChildren.length; - } - - it("shows one row per child object", function () { - return domObserver.when(rowsMatch); - }); - - it("adds rows on composition change", function () { - const child = { - identifier: { - namespace: "test", - key: "123" - }, - name: "Object 123" - }; - testChildren.push(child); - emitEvent(mockComposition, 'add', child); - - return domObserver.when(rowsMatch); - }); - - it("removes rows on composition change", function () { - const child = testChildren.pop(); - emitEvent(mockComposition, 'remove', child.identifier); - - return domObserver.when(rowsMatch); + it("updates classes for limit violations", () => { + const testClass = "some-limit-violation"; + mockEvaluator.evaluate.and.returnValue({ cssClass: testClass }); + testKeys.forEach((key) => { + callbacks[key]({ + range: 'foo', + domain: 'bar' }); }); - it("removes subscriptions when destroyed", function () { - testKeys.forEach(function (key) { - expect(mockUnsubscribes[key]).not.toHaveBeenCalled(); - }); - view.destroy(); - testKeys.forEach(function (key) { - expect(mockUnsubscribes[key]).toHaveBeenCalled(); + return waitsForChange().then(() => { + testKeys.forEach((datum, index) => { + const $cell = $(testContainer).find(".l-autoflow-row").eq(index).find(".r"); + expect($cell.hasClass(testClass)).toBe(true); }); }); + }); - it("provides a button to change column width", function () { - const initialWidth = AutoflowTabularConstants.INITIAL_COLUMN_WIDTH; - const nextWidth = - initialWidth + AutoflowTabularConstants.COLUMN_WIDTH_STEP; + it("automatically flows to new columns", () => { + const rowHeight = AutoflowTabularConstants.ROW_HEIGHT; + const sliderHeight = AutoflowTabularConstants.SLIDER_HEIGHT; + const count = testKeys.length; + const $container = $(testContainer); + let promiseChain = Promise.resolve(); - expect($(testContainer).find('.l-autoflow-col').css('width')) - .toEqual(initialWidth + 'px'); + function columnsHaveAutoflowed() { + const itemsHeight = $container.find('.l-autoflow-items').height(); + const availableHeight = itemsHeight - sliderHeight; + const availableRows = Math.max(Math.floor(availableHeight / rowHeight), 1); + const columns = Math.ceil(count / availableRows); - $(testContainer).find('.change-column-width').click(); + return $container.find('.l-autoflow-col').length === columns; + } - function widthHasChanged() { - const width = $(testContainer).find('.l-autoflow-col').css('width'); - - return width !== initialWidth + 'px'; - } - - return domObserver.when(widthHasChanged) - .then(function () { - expect($(testContainer).find('.l-autoflow-col').css('width')) - .toEqual(nextWidth + 'px'); - }); + $container.find('.abs').css({ + position: 'absolute', + left: '0px', + right: '0px', + top: '0px', + bottom: '0px' }); + $container.css({ position: 'absolute' }); - it("subscribes to all child objects", function () { - testKeys.forEach(function (key) { - expect(callbacks[key]).toEqual(jasmine.any(Function)); - }); + $container.appendTo(document.body); + + function setHeight(height) { + $container.css('height', height + 'px'); + + return domObserver.when(columnsHaveAutoflowed); + } + + for (let height = 0; height < rowHeight * count * 2; height += rowHeight / 2) { + // eslint-disable-next-line no-invalid-this + promiseChain = promiseChain.then(setHeight.bind(this, height)); + } + + return promiseChain.then(() => { + $container.remove(); }); + }); - it("displays historical telemetry", function () { - function rowTextDefined() { - return $(testContainer).find(".l-autoflow-item").filter(".r").text() !== ""; - } - - return domObserver.when(rowTextDefined).then(function () { - testKeys.forEach(function (key, index) { - const datum = testHistories[key]; - const $cell = $(testContainer).find(".l-autoflow-row").eq(index).find(".r"); - expect($cell.text()).toEqual(String(datum.range)); - }); - }); - }); - - it("displays incoming telemetry", function () { - const testData = testKeys.map(function (key, index) { - return { - key: key, - range: index * 100, - domain: key + index - }; - }); - - testData.forEach(function (datum) { - callbacks[datum.key](datum); - }); - - return waitsForChange().then(function () { - testData.forEach(function (datum, index) { - const $cell = $(testContainer).find(".l-autoflow-row").eq(index).find(".r"); - expect($cell.text()).toEqual(String(datum.range)); - }); - }); - }); - - it("updates classes for limit violations", function () { - const testClass = "some-limit-violation"; - mockEvaluator.evaluate.and.returnValue({ cssClass: testClass }); - testKeys.forEach(function (key) { - callbacks[key]({ - range: 'foo', - domain: 'bar' - }); - }); - - return waitsForChange().then(function () { - testKeys.forEach(function (datum, index) { - const $cell = $(testContainer).find(".l-autoflow-row").eq(index).find(".r"); - expect($cell.hasClass(testClass)).toBe(true); - }); - }); - }); - - it("automatically flows to new columns", function () { - const rowHeight = AutoflowTabularConstants.ROW_HEIGHT; - const sliderHeight = AutoflowTabularConstants.SLIDER_HEIGHT; - const count = testKeys.length; - const $container = $(testContainer); - let promiseChain = Promise.resolve(); - - function columnsHaveAutoflowed() { - const itemsHeight = $container.find('.l-autoflow-items').height(); - const availableHeight = itemsHeight - sliderHeight; - const availableRows = Math.max(Math.floor(availableHeight / rowHeight), 1); - const columns = Math.ceil(count / availableRows); - - return $container.find('.l-autoflow-col').length === columns; - } - - $container.find('.abs').css({ - position: 'absolute', - left: '0px', - right: '0px', - top: '0px', - bottom: '0px' - }); - $container.css({ position: 'absolute' }); - - $container.appendTo(document.body); - - function setHeight(height) { - $container.css('height', height + 'px'); - - return domObserver.when(columnsHaveAutoflowed); - } - - for (let height = 0; height < rowHeight * count * 2; height += rowHeight / 2) { - // eslint-disable-next-line no-invalid-this - promiseChain = promiseChain.then(setHeight.bind(this, height)); - } - - return promiseChain.then(function () { - $container.remove(); - }); - }); - - it("loads composition exactly once", function () { - const testObj = testChildren.pop(); - emitEvent(mockComposition, 'remove', testObj.identifier); - testChildren.push(testObj); - emitEvent(mockComposition, 'add', testObj); - expect(mockComposition.load.calls.count()).toEqual(1); - }); + it("loads composition exactly once", () => { + const testObj = testChildren.pop(); + emitEvent(mockComposition, 'remove', testObj.identifier); + testChildren.push(testObj); + emitEvent(mockComposition, 'add', testObj); + expect(mockComposition.load.calls.count()).toEqual(1); }); }); }); diff --git a/src/plugins/duplicate/DuplicateAction.js b/src/plugins/duplicate/DuplicateAction.js new file mode 100644 index 0000000000..e398b6c041 --- /dev/null +++ b/src/plugins/duplicate/DuplicateAction.js @@ -0,0 +1,156 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2020, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ +import DuplicateTask from './DuplicateTask'; + +export default class DuplicateAction { + constructor(openmct) { + this.name = 'Duplicate'; + this.key = 'duplicate'; + this.description = 'Duplicate this object.'; + this.cssClass = "icon-duplicate"; + this.group = "action"; + this.priority = 7; + + this.openmct = openmct; + } + + async invoke(objectPath) { + let duplicationTask = new DuplicateTask(this.openmct); + let originalObject = objectPath[0]; + let parent = objectPath[1]; + let userInput = await this.getUserInput(originalObject, parent); + let newParent = userInput.location; + let inNavigationPath = this.inNavigationPath(originalObject); + + // legacy check + if (this.isLegacyDomainObject(newParent)) { + newParent = await this.convertFromLegacy(newParent); + } + + // if editing, save + if (inNavigationPath && this.openmct.editor.isEditing()) { + this.openmct.editor.save(); + } + + // duplicate + let newObject = await duplicationTask.duplicate(originalObject, newParent); + this.updateNameCheck(newObject, userInput.name); + + return; + } + + async getUserInput(originalObject, parent) { + let dialogService = this.openmct.$injector.get('dialogService'); + let dialogForm = this.getDialogForm(originalObject, parent); + let formState = { + name: originalObject.name + }; + let userInput = await dialogService.getUserInput(dialogForm, formState); + + return userInput; + } + + updateNameCheck(object, name) { + if (object.name !== name) { + this.openmct.objects.mutate(object, 'name', name); + } + } + + inNavigationPath(object) { + return this.openmct.router.path + .some(objectInPath => this.openmct.objects.areIdsEqual(objectInPath.identifier, object.identifier)); + } + + getDialogForm(object, parent) { + return { + name: "Duplicate Item", + sections: [ + { + rows: [ + { + key: "name", + control: "textfield", + name: "Folder Name", + pattern: "\\S+", + required: true, + cssClass: "l-input-lg" + }, + { + name: "location", + cssClass: "grows", + control: "locator", + validate: this.validate(object, parent), + key: 'location' + } + ] + } + ] + }; + } + + validate(object, currentParent) { + return (parentCandidate) => { + let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier); + let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.getId()); + let objectKeystring = this.openmct.objects.makeKeyString(object.identifier); + + if (!parentCandidate || !currentParentKeystring) { + return false; + } + + if (parentCandidateKeystring === objectKeystring) { + return false; + } + + return this.openmct.composition.checkPolicy( + parentCandidate.useCapability('adapter'), + object + ); + }; + } + + isLegacyDomainObject(domainObject) { + return domainObject.getCapability !== undefined; + } + + async convertFromLegacy(legacyDomainObject) { + let objectContext = legacyDomainObject.getCapability('context'); + let domainObject = await this.openmct.objects.get(objectContext.domainObject.id); + + return domainObject; + } + + appliesTo(objectPath) { + let parent = objectPath[1]; + let parentType = parent && this.openmct.types.get(parent.type); + let child = objectPath[0]; + let locked = child.locked ? child.locked : parent && parent.locked; + + if (locked) { + return false; + } + + return parentType + && parentType.definition.creatable + && Array.isArray(parent.composition); + } +} diff --git a/src/plugins/duplicate/DuplicateTask.js b/src/plugins/duplicate/DuplicateTask.js new file mode 100644 index 0000000000..678a8fc5c1 --- /dev/null +++ b/src/plugins/duplicate/DuplicateTask.js @@ -0,0 +1,270 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2020, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ + +import uuid from 'uuid'; + +/** + * This class encapsulates the process of duplicating/copying a domain object + * and all of its children. + * + * @param {DomainObject} domainObject The object to duplicate + * @param {DomainObject} parent The new location of the cloned object tree + * @param {src/plugins/duplicate.DuplicateService~filter} filter + * a function used to filter out objects from + * the cloning process + * @constructor + */ +export default class DuplicateTask { + + constructor(openmct) { + this.domainObject = undefined; + this.parent = undefined; + this.firstClone = undefined; + this.filter = undefined; + this.persisted = 0; + this.clones = []; + this.idMap = {}; + + this.openmct = openmct; + } + + /** + * Execute the duplicate/copy task with the objects provided in the constructor. + * @returns {promise} Which will resolve with a clone of the object + * once complete. + */ + async duplicate(domainObject, parent, filter) { + this.domainObject = domainObject; + this.parent = parent; + this.filter = filter || this.isCreatable; + + await this.buildDuplicationPlan(); + await this.persistObjects(); + await this.addClonesToParent(); + + return this.firstClone; + } + + /** + * 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. Duplicating 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. + */ + async buildDuplicationPlan() { + let domainObjectClone = await this.duplicateObject(this.domainObject); + if (domainObjectClone !== this.domainObject) { + domainObjectClone.location = this.getId(this.parent); + } + + this.firstClone = domainObjectClone; + + return; + } + + /** + * 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. + */ + async persistObjects() { + let initialCount = this.clones.length; + let dialog = this.openmct.overlays.progressDialog({ + progressPerc: 0, + message: `Duplicating ${initialCount} files.`, + iconClass: 'info', + title: 'Duplicating' + }); + let clonesDone = Promise.all(this.clones.map(clone => { + let percentPersisted = Math.ceil(100 * (++this.persisted / initialCount)); + let message = `Duplicating ${initialCount - this.persisted} files.`; + + dialog.updateProgress(percentPersisted, message); + + return this.openmct.objects.save(clone); + })); + + await clonesDone; + dialog.dismiss(); + this.openmct.notifications.info(`Duplicated ${this.persisted} objects.`); + + return; + } + + /** + * Will add a list of clones to the specified parent's composition + */ + async addClonesToParent() { + let parentComposition = this.openmct.composition.get(this.parent); + await parentComposition.load(); + parentComposition.add(this.firstClone); + + return; + } + + /** + * A recursive function that will perform a bottom-up duplicate 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 + * @returns {DomainObject} If the type of the original object allows for + * duplication, then a duplicate of the object, otherwise the object + * itself (to allow linking to non duplicatable objects). + */ + async duplicateObject(originalObject) { + // Check if the creatable (or other passed in filter). + if (this.filter(originalObject)) { + // Clone original object + let clone = this.cloneObjectModel(originalObject); + + // Get children, if any + let composeesCollection = this.openmct.composition.get(originalObject); + let composees; + + if (composeesCollection) { + composees = await composeesCollection.load(); + } + + // Recursively duplicate children + return this.duplicateComposees(clone, composees); + } + + // Not creatable, creating a link, no need to iterate children + return originalObject; + } + + /** + * Update identifiers in a cloned object model (or part of + * a cloned object model) to reflect new identifiers after + * duplicating. + * @private + */ + rewriteIdentifiers(obj, idMap) { + function lookupValue(value) { + return (typeof value === 'string' && idMap[value]) || value; + } + + if (Array.isArray(obj)) { + obj.forEach((value, index) => { + obj[index] = lookupValue(value); + this.rewriteIdentifiers(obj[index], idMap); + }); + } else if (obj && typeof obj === 'object') { + Object.keys(obj).forEach((key) => { + let value = obj[key]; + obj[key] = lookupValue(value); + if (idMap[key]) { + delete obj[key]; + obj[idMap[key]] = value; + } + + this.rewriteIdentifiers(value, idMap); + }); + } + } + + /** + * Given an array of objects composed by a parent, clone them, then + * add them to the parent. + * @private + * @returns {*} + */ + async duplicateComposees(clonedParent, composees = []) { + let idMap = {}; + + let allComposeesDuplicated = composees.reduce(async (previousPromise, nextComposee) => { + await previousPromise; + let clonedComposee = await this.duplicateObject(nextComposee); + idMap[this.getId(nextComposee)] = this.getId(clonedComposee); + await this.composeChild(clonedComposee, clonedParent, clonedComposee !== nextComposee); + + return; + }, Promise.resolve()); + + await allComposeesDuplicated; + + this.rewriteIdentifiers(clonedParent, idMap); + this.clones.push(clonedParent); + + return clonedParent; + } + + async composeChild(child, parent, setLocation) { + const PERSIST_BOOL = false; + let parentComposition = this.openmct.composition.get(parent); + await parentComposition.load(); + parentComposition.add(child, PERSIST_BOOL); + + //If a location is not specified, set it. + if (setLocation && child.location === undefined) { + let parentKeyString = this.getId(parent); + child.location = parentKeyString; + } + } + + getTypeDefinition(domainObject, definition) { + let typeDefinitions = this.openmct.types.get(domainObject.type).definition; + + return typeDefinitions[definition] || false; + } + + cloneObjectModel(domainObject) { + let clone = JSON.parse(JSON.stringify(domainObject)); + let identifier = { + key: uuid(), + namespace: domainObject.identifier.namespace + }; + + if (clone.modified || clone.persisted || clone.location) { + clone.modified = undefined; + clone.persisted = undefined; + clone.location = undefined; + delete clone.modified; + delete clone.persisted; + delete clone.location; + } + + if (clone.composition) { + clone.composition = []; + } + + clone.identifier = identifier; + + return clone; + } + + getId(domainObject) { + return this.openmct.objects.makeKeyString(domainObject.identifier); + } + + isCreatable(domainObject) { + return this.getTypeDefinition(domainObject, 'creatable'); + } +} diff --git a/src/plugins/duplicate/plugin.js b/src/plugins/duplicate/plugin.js new file mode 100644 index 0000000000..0f07cc579a --- /dev/null +++ b/src/plugins/duplicate/plugin.js @@ -0,0 +1,28 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2019, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ +import DuplicateAction from "./DuplicateAction"; + +export default function () { + return function (openmct) { + openmct.actions.register(new DuplicateAction(openmct)); + }; +} diff --git a/src/plugins/duplicate/pluginSpec.js b/src/plugins/duplicate/pluginSpec.js new file mode 100644 index 0000000000..07af1bb78f --- /dev/null +++ b/src/plugins/duplicate/pluginSpec.js @@ -0,0 +1,157 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2020, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT 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 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. + *****************************************************************************/ +import DuplicateActionPlugin from './plugin.js'; +import DuplicateAction from './DuplicateAction.js'; +import DuplicateTask from './DuplicateTask.js'; +import { + createOpenMct, + resetApplicationState, + getMockObjects +} from 'utils/testing'; + +describe("The Duplicate Action plugin", () => { + + let openmct; + let duplicateTask; + let childObject; + let parentObject; + let anotherParentObject; + + // this setups up the app + beforeEach((done) => { + openmct = createOpenMct(); + + childObject = getMockObjects({ + objectKeyStrings: ['folder'], + overwrite: { + folder: { + name: "Child Folder", + identifier: { + namespace: "", + key: "child-folder-object" + } + } + } + }).folder; + parentObject = getMockObjects({ + objectKeyStrings: ['folder'], + overwrite: { + folder: { + name: "Parent Folder", + composition: [childObject.identifier] + } + } + }).folder; + anotherParentObject = getMockObjects({ + objectKeyStrings: ['folder'], + overwrite: { + folder: { + name: "Another Parent Folder" + } + } + }).folder; + + let objectGet = openmct.objects.get.bind(openmct.objects); + spyOn(openmct.objects, 'get').and.callFake((identifier) => { + let obj = [childObject, parentObject, anotherParentObject].find((ob) => ob.identifier.key === identifier.key); + + if (!obj) { + // not one of the mocked objs, callthrough basically + return objectGet(identifier); + } + + return Promise.resolve(obj); + }); + + spyOn(openmct.composition, 'get').and.callFake((domainObject) => { + return { + load: async () => { + let obj = [childObject, parentObject, anotherParentObject].find((ob) => ob.identifier.key === domainObject.identifier.key); + let children = []; + + if (obj) { + for (let i = 0; i < obj.composition.length; i++) { + children.push(await openmct.objects.get(obj.composition[i])); + } + } + + return Promise.resolve(children); + }, + add: (child) => { + domainObject.composition.push(child.identifier); + } + }; + }); + + // already installed by default, but never hurts, just adds to context menu + openmct.install(DuplicateActionPlugin()); + + openmct.on('start', done); + openmct.startHeadless(); + }); + + afterEach(() => { + resetApplicationState(openmct); + }); + + it("should be defined", () => { + expect(DuplicateActionPlugin).toBeDefined(); + }); + + describe("when moving an object to a new parent", () => { + + beforeEach(async (done) => { + duplicateTask = new DuplicateTask(openmct); + await duplicateTask.duplicate(parentObject, anotherParentObject); + done(); + }); + + it("the duplicate child object's name (when not changing) should be the same as the original object", async () => { + let duplicatedObjectIdentifier = anotherParentObject.composition[0]; + let duplicatedObject = await openmct.objects.get(duplicatedObjectIdentifier); + let duplicateObjectName = duplicatedObject.name; + + expect(duplicateObjectName).toEqual(parentObject.name); + }); + + it("the duplicate child object's identifier should be new", () => { + let duplicatedObjectIdentifier = anotherParentObject.composition[0]; + + expect(duplicatedObjectIdentifier.key).not.toEqual(parentObject.identifier.key); + }); + }); + + describe("when a new name is provided for the duplicated object", () => { + const NEW_NAME = 'New Name'; + + beforeEach(() => { + duplicateTask = new DuplicateAction(openmct); + duplicateTask.updateNameCheck(parentObject, NEW_NAME); + }); + + it("the name is updated", () => { + let childName = parentObject.name; + expect(childName).toEqual(NEW_NAME); + }); + }); + +});