Fix plot composition (#6206)

* Fixed composition

* Remove unnecessary guard code

* Removing deprecated code

* Use valid key for stacked plot v-for

* Fixed object API specs to expect old values as well as new values in mutation callbacks

* Fixed existing tests

* Added E2E test

* Fixed linting error

---------

Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
This commit is contained in:
Andrew Henry
2023-01-30 22:01:00 -08:00
committed by GitHub
parent cc1bf47f5a
commit 871362d469
13 changed files with 158 additions and 91 deletions

View File

@ -0,0 +1,79 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, 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.
*****************************************************************************/
/*
Tests to verify log plot functionality. Note this test suite if very much under active development and should not
necessarily be used for reference when writing new tests in this area.
*/
const { test, expect } = require('../../../../pluginFixtures');
const { createDomainObjectWithDefaults } = require('../../../../appActions');
test.describe('Stacked Plot', () => {
test('Using the remove action removes the correct plot', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
const overlayPlot = await createDomainObjectWithDefaults(page, {
type: "Overlay Plot"
});
await createDomainObjectWithDefaults(page, {
type: "Sine Wave Generator",
name: 'swg a',
parent: overlayPlot.uuid
});
await createDomainObjectWithDefaults(page, {
type: "Sine Wave Generator",
name: 'swg b',
parent: overlayPlot.uuid
});
await createDomainObjectWithDefaults(page, {
type: "Sine Wave Generator",
name: 'swg c',
parent: overlayPlot.uuid
});
await page.goto(overlayPlot.url);
await page.click('button[title="Edit"]');
// Expand the elements pool vertically
await page.locator('.l-pane__handle').nth(2).hover({ trial: true });
await page.mouse.down();
await page.mouse.move(0, 100);
await page.mouse.up();
await page.locator('.js-elements-pool__tree >> text=swg b').click({ button: 'right' });
await page.locator('li[role="menuitem"]:has-text("Remove")').click();
await page.locator('.js-overlay .js-overlay__button >> text=OK').click();
// Wait until the number of elements in the elements pool has changed, and then confirm that the correct children were retained
// await page.waitForFunction(() => {
// return Array.from(document.querySelectorAll('.js-elements-pool__tree .js-elements-pool__item')).length === 2;
// });
// Wait until there are only two items in the elements pool (ie the remove action has completed)
await expect(page.locator('.js-elements-pool__tree .js-elements-pool__item')).toHaveCount(2);
// Confirm that the elements pool contains the items we expect
await expect(page.locator('.js-elements-pool__tree >> text=swg a')).toHaveCount(1);
await expect(page.locator('.js-elements-pool__tree >> text=swg b')).toHaveCount(0);
await expect(page.locator('.js-elements-pool__tree >> text=swg c')).toHaveCount(1);
});
});

View File

@ -1,47 +1,35 @@
import CompositionAPI from './CompositionAPI'; import { createOpenMct, resetApplicationState } from '../../utils/testing';
import CompositionCollection from './CompositionCollection'; import CompositionCollection from './CompositionCollection';
describe('The Composition API', function () { describe('The Composition API', function () {
let publicAPI; let publicAPI;
let compositionAPI; let compositionAPI;
let topicService;
let mutationTopic;
beforeEach(function () { beforeEach(function (done) {
publicAPI = createOpenMct();
compositionAPI = publicAPI.composition;
mutationTopic = jasmine.createSpyObj('mutationTopic', [ const mockObjectProvider = jasmine.createSpyObj("mock provider", [
'listen' "create",
]); "update",
topicService = jasmine.createSpy('topicService'); "get"
topicService.and.returnValue(mutationTopic);
publicAPI = {};
publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [
'get',
'mutate',
'observe',
'areIdsEqual'
]); ]);
publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) { mockObjectProvider.create.and.returnValue(Promise.resolve(true));
return id1.namespace === id2.namespace && id1.key === id2.key; mockObjectProvider.update.and.returnValue(Promise.resolve(true));
mockObjectProvider.get.and.callFake((identifier) => {
return Promise.resolve({identifier});
}); });
publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [ publicAPI.objects.addProvider('test', mockObjectProvider);
'checkPolicy' publicAPI.objects.addProvider('custom', mockObjectProvider);
]);
publicAPI.composition.checkPolicy.and.returnValue(true);
publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [ publicAPI.on('start', done);
'on' publicAPI.startHeadless();
]); });
publicAPI.objects.get.and.callFake(function (identifier) {
return Promise.resolve({identifier: identifier}); afterEach(() => {
}); return resetApplicationState(publicAPI);
publicAPI.$injector = jasmine.createSpyObj('$injector', [
'get'
]);
publicAPI.$injector.get.and.returnValue(topicService);
compositionAPI = new CompositionAPI(publicAPI);
}); });
it('returns falsy if an object does not support composition', function () { it('returns falsy if an object does not support composition', function () {
@ -106,6 +94,9 @@ describe('The Composition API', function () {
let listener; let listener;
beforeEach(function () { beforeEach(function () {
listener = jasmine.createSpy('reorderListener'); listener = jasmine.createSpy('reorderListener');
spyOn(publicAPI.objects, 'mutate');
publicAPI.objects.mutate.and.callThrough();
composition.on('reorder', listener); composition.on('reorder', listener);
return composition.load(); return composition.load();
@ -136,18 +127,20 @@ describe('The Composition API', function () {
}); });
}); });
it('supports adding an object to composition', function () { it('supports adding an object to composition', function () {
let addListener = jasmine.createSpy('addListener');
let mockChildObject = { let mockChildObject = {
identifier: { identifier: {
key: 'mock-key', key: 'mock-key',
namespace: '' namespace: ''
} }
}; };
composition.on('add', addListener);
composition.add(mockChildObject);
expect(domainObject.composition.length).toBe(4); return new Promise((resolve) => {
expect(domainObject.composition[3]).toEqual(mockChildObject.identifier); composition.on('add', resolve);
composition.add(mockChildObject);
}).then(() => {
expect(domainObject.composition.length).toBe(4);
expect(domainObject.composition[3]).toEqual(mockChildObject.identifier);
});
}); });
}); });

View File

@ -224,7 +224,7 @@ export default class CompositionProvider {
* @private * @private
* @param {DomainObject} oldDomainObject * @param {DomainObject} oldDomainObject
*/ */
#onMutation(oldDomainObject) { #onMutation(newDomainObject, oldDomainObject) {
const id = objectUtils.makeKeyString(oldDomainObject.identifier); const id = objectUtils.makeKeyString(oldDomainObject.identifier);
const listeners = this.#listeningTo[id]; const listeners = this.#listeningTo[id];
@ -232,8 +232,8 @@ export default class CompositionProvider {
return; return;
} }
const oldComposition = listeners.composition.map(objectUtils.makeKeyString); const oldComposition = oldDomainObject.composition.map(objectUtils.makeKeyString);
const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString); const newComposition = newDomainObject.composition.map(objectUtils.makeKeyString);
const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString); const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString);
const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString); const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString);
@ -248,8 +248,6 @@ export default class CompositionProvider {
}; };
} }
listeners.composition = newComposition.map(objectUtils.parseKeyString);
added.forEach(function (addedChild) { added.forEach(function (addedChild) {
listeners.add.forEach(notify(addedChild)); listeners.add.forEach(notify(addedChild));
}); });

View File

@ -99,8 +99,7 @@ export default class DefaultCompositionProvider extends CompositionProvider {
objectListeners = this.listeningTo[keyString] = { objectListeners = this.listeningTo[keyString] = {
add: [], add: [],
remove: [], remove: [],
reorder: [], reorder: []
composition: [].slice.apply(domainObject.composition)
}; };
} }
@ -172,8 +171,9 @@ export default class DefaultCompositionProvider extends CompositionProvider {
*/ */
add(parent, childId) { add(parent, childId) {
if (!this.includes(parent, childId)) { if (!this.includes(parent, childId)) {
parent.composition.push(childId); const composition = structuredClone(parent.composition);
this.publicAPI.objects.mutate(parent, 'composition', parent.composition); composition.push(childId);
this.publicAPI.objects.mutate(parent, 'composition', composition);
} }
} }

View File

@ -75,21 +75,23 @@ class MutableDomainObject {
return eventOff; return eventOff;
} }
$set(path, value) { $set(path, value) {
const oldModel = structuredClone(this);
const oldValue = _.get(oldModel, path);
MutableDomainObject.mutateObject(this, path, value); MutableDomainObject.mutateObject(this, path, value);
//Emit secret synchronization event first, so that all objects are in sync before subsequent events fired. //Emit secret synchronization event first, so that all objects are in sync before subsequent events fired.
this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this); this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this);
//Emit a general "any object" event //Emit a general "any object" event
this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this); this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this, oldModel);
//Emit wildcard event, with path so that callback knows what changed //Emit wildcard event, with path so that callback knows what changed
this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value); this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value, oldModel, oldValue);
//Emit events specific to properties affected //Emit events specific to properties affected
let parentPropertiesList = path.split('.'); let parentPropertiesList = path.split('.');
for (let index = parentPropertiesList.length; index > 0; index--) { for (let index = parentPropertiesList.length; index > 0; index--) {
let parentPropertyPath = parentPropertiesList.slice(0, index).join('.'); let parentPropertyPath = parentPropertiesList.slice(0, index).join('.');
this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath)); this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath), _.get(oldModel, parentPropertyPath));
} }
//TODO: Emit events for listeners of child properties when parent changes. //TODO: Emit events for listeners of child properties when parent changes.
@ -124,7 +126,7 @@ class MutableDomainObject {
Object.assign(mutable, object); Object.assign(mutable, object);
mutable.$observe('$_synchronize_model', (updatedObject) => { mutable.$observe('$_synchronize_model', (updatedObject) => {
let clone = JSON.parse(JSON.stringify(updatedObject)); let clone = structuredClone(updatedObject);
utils.refresh(mutable, clone); utils.refresh(mutable, clone);
}); });

View File

@ -648,7 +648,7 @@ export default class ObjectAPI {
* @param {module:openmct.DomainObject} object the object to observe * @param {module:openmct.DomainObject} object the object to observe
* @param {string} path the property to observe * @param {string} path the property to observe
* @param {Function} callback a callback to invoke when new values for * @param {Function} callback a callback to invoke when new values for
* this property are observed * this property are observed.
* @method observe * @method observe
* @memberof module:openmct.ObjectAPI# * @memberof module:openmct.ObjectAPI#
*/ */

View File

@ -399,7 +399,7 @@ describe("The Object API", () => {
unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback); unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback);
objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value'); objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value');
}).then(function () { }).then(function () {
expect(mutationCallback).toHaveBeenCalledWith('some-new-value'); expect(mutationCallback).toHaveBeenCalledWith('some-new-value', 'other-attribute-value');
unlisten(); unlisten();
}); });
}); });
@ -419,14 +419,20 @@ describe("The Object API", () => {
objectAPI.mutate(mutable, 'objectAttribute.embeddedObject.embeddedKey', 'updated-embedded-value'); objectAPI.mutate(mutable, 'objectAttribute.embeddedObject.embeddedKey', 'updated-embedded-value');
}).then(function () { }).then(function () {
expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value'); expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value', 'embedded-value');
expect(embeddedObjectCallback).toHaveBeenCalledWith({ expect(embeddedObjectCallback).toHaveBeenCalledWith({
embeddedKey: 'updated-embedded-value' embeddedKey: 'updated-embedded-value'
}, {
embeddedKey: 'embedded-value'
}); });
expect(objectAttributeCallback).toHaveBeenCalledWith({ expect(objectAttributeCallback).toHaveBeenCalledWith({
embeddedObject: { embeddedObject: {
embeddedKey: 'updated-embedded-value' embeddedKey: 'updated-embedded-value'
} }
}, {
embeddedObject: {
embeddedKey: 'embedded-value'
}
}); });
listeners.forEach(listener => listener()); listeners.forEach(listener => listener());

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="c-overlay"> <div class="c-overlay js-overlay">
<div <div
class="c-overlay__blocker" class="c-overlay__blocker"
@click="destroy" @click="destroy"
@ -26,7 +26,7 @@
v-for="(button, index) in buttons" v-for="(button, index) in buttons"
ref="buttons" ref="buttons"
:key="index" :key="index"
class="c-button" class="c-button js-overlay__button"
tabindex="0" tabindex="0"
:class="{'c-button--major': focusIndex===index}" :class="{'c-button--major': focusIndex===index}"
@focus="focusIndex=index" @focus="focusIndex=index"

View File

@ -59,7 +59,7 @@ export default class CreateAction extends PropertiesAction {
_.set(this.domainObject, key, value); _.set(this.domainObject, key, value);
}); });
const parentDomainObject = parentDomainObjectPath[0]; const parentDomainObject = this.openmct.objects.toMutable(parentDomainObjectPath[0]);
this.domainObject.modified = Date.now(); this.domainObject.modified = Date.now();
this.domainObject.location = this.openmct.objects.makeKeyString(parentDomainObject.identifier); this.domainObject.location = this.openmct.objects.makeKeyString(parentDomainObject.identifier);
@ -85,6 +85,7 @@ export default class CreateAction extends PropertiesAction {
console.error(err); console.error(err);
this.openmct.notifications.error(`Error saving objects: ${err}`); this.openmct.notifications.error(`Error saving objects: ${err}`);
} finally { } finally {
this.openmct.objects.destroyMutable(parentDomainObject);
dialog.dismiss(); dialog.dismiss();
} }
@ -142,18 +143,21 @@ export default class CreateAction extends PropertiesAction {
} }
}; };
this.domainObject = domainObject; this.domainObject = this.openmct.objects.toMutable(domainObject);
if (definition.initialize) { if (definition.initialize) {
definition.initialize(domainObject); definition.initialize(this.domainObject);
} }
const createWizard = new CreateWizard(this.openmct, domainObject, this.parentDomainObject); const createWizard = new CreateWizard(this.openmct, this.domainObject, this.parentDomainObject);
const formStructure = createWizard.getFormStructure(true); const formStructure = createWizard.getFormStructure(true);
formStructure.title = 'Create a New ' + definition.name; formStructure.title = 'Create a New ' + definition.name;
this.openmct.forms.showForm(formStructure) this.openmct.forms.showForm(formStructure)
.then(this._onSave.bind(this)) .then(this._onSave.bind(this))
.catch(this._onCancel.bind(this)); .catch(this._onCancel.bind(this))
.finally(() => {
this.openmct.objects.destroyMutable(this.domainObject);
});
} }
} }

View File

@ -35,10 +35,10 @@
/> />
<div class="l-view-section"> <div class="l-view-section">
<stacked-plot-item <stacked-plot-item
v-for="object in compositionObjects" v-for="objectWrapper in compositionObjects"
:key="object.id" :key="objectWrapper.keyString"
class="c-plot--stacked-container" class="c-plot--stacked-container"
:child-object="object" :child-object="objectWrapper.object"
:options="options" :options="options"
:grid-lines="gridLines" :grid-lines="gridLines"
:color-palette="colorPalette" :color-palette="colorPalette"
@ -169,7 +169,10 @@ export default {
this.$set(this.tickWidthMap, id, 0); this.$set(this.tickWidthMap, id, 0);
this.compositionObjects.push(child); this.compositionObjects.push({
object: child,
keyString: id
});
}, },
removeChild(childIdentifier) { removeChild(childIdentifier) {
@ -180,6 +183,7 @@ export default {
const configIndex = this.domainObject.configuration.series.findIndex((seriesConfig) => { const configIndex = this.domainObject.configuration.series.findIndex((seriesConfig) => {
return this.openmct.objects.areIdsEqual(seriesConfig.identifier, childIdentifier); return this.openmct.objects.areIdsEqual(seriesConfig.identifier, childIdentifier);
}); });
if (configIndex > -1) { if (configIndex > -1) {
this.domainObject.configuration.series.splice(configIndex, 1); this.domainObject.configuration.series.splice(configIndex, 1);
} }
@ -188,15 +192,11 @@ export default {
keyString: id keyString: id
}); });
const childObj = this.compositionObjects.filter((c) => { this.compositionObjects = this.compositionObjects.filter((c) => {
const identifier = this.openmct.objects.makeKeyString(c.identifier); const identifier = c.keyString;
return identifier === id; return identifier !== id;
})[0]; });
if (childObj) {
const index = this.compositionObjects.indexOf(childObj);
this.compositionObjects.splice(index, 1);
}
}, },
compositionReorder(reorderPlan) { compositionReorder(reorderPlan) {

View File

@ -30,7 +30,7 @@
@drop="emitDropEvent" @drop="emitDropEvent"
> >
<div <div
class="c-tree__item c-elements-pool__item" class="c-tree__item c-elements-pool__item js-elements-pool__item"
:class="{ :class="{
'is-context-clicked': contextClickActive, 'is-context-clicked': contextClickActive,
'hover': hover, 'hover': hover,

View File

@ -34,7 +34,7 @@
<ul <ul
v-if="hasElements" v-if="hasElements"
id="inspector-elements-tree" id="inspector-elements-tree"
class="c-tree c-elements-pool__tree" class="c-tree c-elements-pool__tree js-elements-pool__tree"
> >
<div class="c-elements-pool__instructions"> Select and drag an element to move it into a different axis. </div> <div class="c-elements-pool__instructions"> Select and drag an element to move it into a different axis. </div>
<element-item-group <element-item-group

View File

@ -14,7 +14,6 @@
<script> <script>
import CreateAction from '@/plugins/formActions/CreateAction'; import CreateAction from '@/plugins/formActions/CreateAction';
import objectUtils from 'objectUtils';
export default { export default {
inject: ['openmct'], inject: ['openmct'],
@ -74,23 +73,9 @@ export default {
this.openmct.menus.showSuperMenu(x, y, this.sortedItems, menuOptions); this.openmct.menus.showSuperMenu(x, y, this.sortedItems, menuOptions);
}, },
create(key) { create(key) {
// Hack for support. TODO: rewrite create action. const createAction = new CreateAction(this.openmct, key, this.openmct.router.path[0]);
// 1. Get contextual object from navigation
// 2. Get legacy type from legacy api
// 3. Instantiate create action with type, parent, context
// 4. perform action.
return this.openmct.objects.get(this.openmct.router.path[0].identifier)
.then((currentObject) => {
const createAction = new CreateAction(this.openmct, key, currentObject);
createAction.invoke(); createAction.invoke();
});
},
convertToLegacy(domainObject) {
let keyString = objectUtils.makeKeyString(domainObject.identifier);
let oldModel = objectUtils.toOldFormat(domainObject);
return this.openmct.$injector.get('instantiate')(oldModel, keyString);
} }
} }
}; };