Merge release/2.1.2 into master (#5946)

* Bump version to `2.1.2`

* Ensure properties stay in sync and are committed only once (#5717)

* Ensure form properties stay in sync
* Separate out overlay based forms and custom forms
* Use a transaction to save properties
* Fix GaugeController to not depend on event emitted from FormsAPI
* refactor showForms to call showCustomForm

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>

* Fix persistence timestamp (#5916)

* Calculate persisted timestamp last
* Added regression tests
* Correct transaction handling code
* Code cleanup

* Fix typo for publish (#5936)

* Add e2e tests to npm package (#5930)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
This commit is contained in:
Jesse Mazzella 2022-11-03 13:49:03 -07:00 committed by GitHub
parent 42a0e503cc
commit 091f6406a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 216 additions and 156 deletions

View File

@ -28,6 +28,6 @@ jobs:
node-version: 16 node-version: 16
registry-url: https://registry.npmjs.org/ registry-url: https://registry.npmjs.org/
- run: npm install - run: npm install
- run: npm publish --access public --tag unstable - run: npm publish --access=public --tag unstable
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -21,4 +21,10 @@
!copyright-notice.html !copyright-notice.html
!index.html !index.html
!openmct.js !openmct.js
!SECURITY.md !SECURITY.md
# Add e2e tests to npm package
!/e2e/**/*
# ... except our test-data folder files.
/e2e/test-data/*.json

View File

@ -23,13 +23,11 @@
import FormController from './FormController'; import FormController from './FormController';
import FormProperties from './components/FormProperties.vue'; import FormProperties from './components/FormProperties.vue';
import EventEmitter from 'EventEmitter';
import Vue from 'vue'; import Vue from 'vue';
import _ from 'lodash';
export default class FormsAPI extends EventEmitter { export default class FormsAPI {
constructor(openmct) { constructor(openmct) {
super();
this.openmct = openmct; this.openmct = openmct;
this.formController = new FormController(openmct); this.formController = new FormController(openmct);
} }
@ -92,29 +90,75 @@ export default class FormsAPI extends EventEmitter {
/** /**
* Show form inside an Overlay dialog with given form structure * Show form inside an Overlay dialog with given form structure
* @public
* @param {Array<Section>} formStructure a form structure, array of section
* @param {Object} options
* @property {function} onChange a callback function when any changes detected
*/
showForm(formStructure, {
onChange
} = {}) {
let overlay;
const self = this;
const overlayEl = document.createElement('div');
overlayEl.classList.add('u-contents');
overlay = self.openmct.overlays.overlay({
element: overlayEl,
size: 'dialog'
});
let formSave;
let formCancel;
const promise = new Promise((resolve, reject) => {
formSave = resolve;
formCancel = reject;
});
this.showCustomForm(formStructure, {
element: overlayEl,
onChange
})
.then((response) => {
overlay.dismiss();
formSave(response);
})
.catch((response) => {
overlay.dismiss();
formCancel(response);
});
return promise;
}
/**
* Show form as a child of the element provided with given form structure
* *
* @public * @public
* @param {Array<Section>} formStructure a form structure, array of section * @param {Array<Section>} formStructure a form structure, array of section
* @param {Object} options * @param {Object} options
* @property {HTMLElement} element Parent Element to render a Form * @property {HTMLElement} element Parent Element to render a Form
* @property {function} onChange a callback function when any changes detected * @property {function} onChange a callback function when any changes detected
* @property {function} onSave a callback function when form is submitted
* @property {function} onDismiss a callback function when form is dismissed
*/ */
showForm(formStructure, { showCustomForm(formStructure, {
element, element,
onChange onChange
} = {}) { } = {}) {
const changes = {}; if (element === undefined) {
let overlay; throw Error('Required element parameter not provided');
let onDismiss; }
let onSave;
const self = this; const self = this;
const changes = {};
let formSave;
let formCancel;
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
onSave = onFormAction(resolve); formSave = onFormAction(resolve);
onDismiss = onFormAction(reject); formCancel = onFormAction(reject);
}); });
const vm = new Vue({ const vm = new Vue({
@ -126,26 +170,17 @@ export default class FormsAPI extends EventEmitter {
return { return {
formStructure, formStructure,
onChange: onFormPropertyChange, onChange: onFormPropertyChange,
onDismiss, onCancel: formCancel,
onSave onSave: formSave
}; };
}, },
template: '<FormProperties :model="formStructure" @onChange="onChange" @onDismiss="onDismiss" @onSave="onSave"></FormProperties>' template: '<FormProperties :model="formStructure" @onChange="onChange" @onCancel="onCancel" @onSave="onSave"></FormProperties>'
}).$mount(); }).$mount();
const formElement = vm.$el; const formElement = vm.$el;
if (element) { element.append(formElement);
element.append(formElement);
} else {
overlay = self.openmct.overlays.overlay({
element: vm.$el,
size: 'dialog',
onDestroy: () => vm.$destroy()
});
}
function onFormPropertyChange(data) { function onFormPropertyChange(data) {
self.emit('onFormPropertyChange', data);
if (onChange) { if (onChange) {
onChange(data); onChange(data);
} }
@ -158,17 +193,14 @@ export default class FormsAPI extends EventEmitter {
key = property.join('.'); key = property.join('.');
} }
changes[key] = data.value; _.set(changes, key, data.value);
} }
} }
function onFormAction(callback) { function onFormAction(callback) {
return () => { return () => {
if (element) { formElement.remove();
formElement.remove(); vm.$destroy();
} else {
overlay.dismiss();
}
if (callback) { if (callback) {
callback(changes); callback(changes);

View File

@ -133,7 +133,7 @@ describe('The Forms API', () => {
}); });
it('when container element is provided', (done) => { it('when container element is provided', (done) => {
openmct.forms.showForm(formStructure, { element }).catch(() => { openmct.forms.showCustomForm(formStructure, { element }).catch(() => {
done(); done();
}); });
const titleElement = element.querySelector('.c-overlay__dialog-title'); const titleElement = element.querySelector('.c-overlay__dialog-title');

View File

@ -73,7 +73,7 @@
tabindex="0" tabindex="0"
class="c-button js-cancel-button" class="c-button js-cancel-button"
aria-label="Cancel" aria-label="Cancel"
@click="onDismiss" @click="onCancel"
> >
{{ cancelLabel }} {{ cancelLabel }}
</button> </button>
@ -164,8 +164,8 @@ export default {
this.$emit('onChange', data); this.$emit('onChange', data);
}, },
onDismiss() { onCancel() {
this.$emit('onDismiss'); this.$emit('onCancel');
}, },
onSave() { onSave() {
this.$emit('onSave'); this.$emit('onSave');

View File

@ -75,11 +75,7 @@ class MutableDomainObject {
return eventOff; return eventOff;
} }
$set(path, value) { $set(path, value) {
_.set(this, path, value); MutableDomainObject.mutateObject(this, path, value);
if (path !== 'persisted' && path !== 'modified') {
_.set(this, 'modified', Date.now());
}
//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);
@ -136,8 +132,11 @@ class MutableDomainObject {
} }
static mutateObject(object, path, value) { static mutateObject(object, path, value) {
if (path !== 'persisted') {
_.set(object, 'modified', Date.now());
}
_.set(object, path, value); _.set(object, path, value);
_.set(object, 'modified', Date.now());
} }
} }

View File

@ -363,7 +363,6 @@ export default class ObjectAPI {
} else if (this.#hasAlreadyBeenPersisted(domainObject)) { } else if (this.#hasAlreadyBeenPersisted(domainObject)) {
result = Promise.resolve(true); result = Promise.resolve(true);
} else { } else {
const persistedTime = Date.now();
const username = await this.#getCurrentUsername(); const username = await this.#getCurrentUsername();
const isNewObject = domainObject.persisted === undefined; const isNewObject = domainObject.persisted === undefined;
let savedResolve; let savedResolve;
@ -375,15 +374,20 @@ export default class ObjectAPI {
savedReject = reject; savedReject = reject;
}); });
this.#mutate(domainObject, 'persisted', persistedTime);
this.#mutate(domainObject, 'modifiedBy', username); this.#mutate(domainObject, 'modifiedBy', username);
if (isNewObject) { if (isNewObject) {
const persistedTime = Date.now();
this.#mutate(domainObject, 'persisted', persistedTime);
this.#mutate(domainObject, 'created', persistedTime); this.#mutate(domainObject, 'created', persistedTime);
this.#mutate(domainObject, 'createdBy', username); this.#mutate(domainObject, 'createdBy', username);
savedObjectPromise = provider.create(domainObject); savedObjectPromise = provider.create(domainObject);
} else { } else {
const persistedTime = Date.now();
this.#mutate(domainObject, 'persisted', persistedTime);
savedObjectPromise = provider.update(domainObject); savedObjectPromise = provider.update(domainObject);
} }

View File

@ -94,6 +94,35 @@ describe("The Object API", () => {
expect(mockProvider.create).not.toHaveBeenCalled(); expect(mockProvider.create).not.toHaveBeenCalled();
expect(mockProvider.update).toHaveBeenCalled(); expect(mockProvider.update).toHaveBeenCalled();
}); });
describe("the persisted timestamp for existing objects", () => {
let persistedTimestamp;
beforeEach(() => {
persistedTimestamp = Date.now() - FIFTEEN_MINUTES;
mockDomainObject.persisted = persistedTimestamp;
mockDomainObject.modified = Date.now();
});
it("is updated", async () => {
await objectAPI.save(mockDomainObject);
expect(mockDomainObject.persisted).toBeDefined();
expect(mockDomainObject.persisted > persistedTimestamp).toBe(true);
});
it("is >= modified timestamp", async () => {
await objectAPI.save(mockDomainObject);
expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true);
});
});
describe("the persisted timestamp for new objects", () => {
it("is updated", async () => {
await objectAPI.save(mockDomainObject);
expect(mockDomainObject.persisted).toBeDefined();
});
it("is >= modified timestamp", async () => {
await objectAPI.save(mockDomainObject);
expect(mockDomainObject.persisted >= mockDomainObject.modified).toBe(true);
});
});
it("Sets the current user for 'createdBy' on new objects", async () => { it("Sets the current user for 'createdBy' on new objects", async () => {
await objectAPI.save(mockDomainObject); await objectAPI.save(mockDomainObject);
expect(mockDomainObject.createdBy).toBe(USERNAME); expect(mockDomainObject.createdBy).toBe(USERNAME);

View File

@ -17,6 +17,7 @@ class Overlay extends EventEmitter {
dismissable = true, dismissable = true,
element, element,
onDestroy, onDestroy,
onDismiss,
size size
} = {}) { } = {}) {
super(); super();
@ -32,7 +33,7 @@ class Overlay extends EventEmitter {
OverlayComponent: OverlayComponent OverlayComponent: OverlayComponent
}, },
provide: { provide: {
dismiss: this.dismiss.bind(this), dismiss: this.notifyAndDismiss.bind(this),
element, element,
buttons, buttons,
dismissable: this.dismissable dismissable: this.dismissable
@ -43,6 +44,10 @@ class Overlay extends EventEmitter {
if (onDestroy) { if (onDestroy) {
this.once('destroy', onDestroy); this.once('destroy', onDestroy);
} }
if (onDismiss) {
this.once('dismiss', onDismiss);
}
} }
dismiss() { dismiss() {
@ -51,6 +56,12 @@ class Overlay extends EventEmitter {
this.component.$destroy(); this.component.$destroy();
} }
//Ensures that any callers are notified that the overlay is dismissed
notifyAndDismiss() {
this.emit('dismiss');
this.dismiss();
}
/** /**
* @private * @private
**/ **/

View File

@ -55,7 +55,7 @@ class OverlayAPI {
dismissLastOverlay() { dismissLastOverlay() {
let lastOverlay = this.activeOverlays[this.activeOverlays.length - 1]; let lastOverlay = this.activeOverlays[this.activeOverlays.length - 1];
if (lastOverlay && lastOverlay.dismissable) { if (lastOverlay && lastOverlay.dismissable) {
lastOverlay.dismiss(); lastOverlay.notifyAndDismiss();
} }
} }

View File

@ -24,6 +24,7 @@ import PropertiesAction from './PropertiesAction';
import CreateWizard from './CreateWizard'; import CreateWizard from './CreateWizard';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import _ from 'lodash';
export default class CreateAction extends PropertiesAction { export default class CreateAction extends PropertiesAction {
constructor(openmct, type, parentDomainObject) { constructor(openmct, type, parentDomainObject) {
@ -50,19 +51,15 @@ export default class CreateAction extends PropertiesAction {
return; return;
} }
const properties = key.split('.'); const existingValue = this.domainObject[key];
let object = this.domainObject; if (!(existingValue instanceof Array) && (typeof existingValue === 'object')) {
const propertiesLength = properties.length; value = {
properties.forEach((property, index) => { ...existingValue,
const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1; ...value
if (isComplexProperty && object[property] !== null) { };
object = object[property]; }
} else {
object[property] = value;
}
});
object = value; _.set(this.domainObject, key, value);
}); });
const parentDomainObject = parentDomainObjectPath[0]; const parentDomainObject = parentDomainObjectPath[0];
@ -94,6 +91,12 @@ export default class CreateAction extends PropertiesAction {
dialog.dismiss(); dialog.dismiss();
} }
/**
* @private
*/
_onCancel() {
//do Nothing
}
/** /**
* @private * @private
*/ */
@ -151,6 +154,7 @@ export default class CreateAction extends PropertiesAction {
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));
} }
} }

View File

@ -22,6 +22,7 @@
import PropertiesAction from './PropertiesAction'; import PropertiesAction from './PropertiesAction';
import CreateWizard from './CreateWizard'; import CreateWizard from './CreateWizard';
export default class EditPropertiesAction extends PropertiesAction { export default class EditPropertiesAction extends PropertiesAction {
constructor(openmct) { constructor(openmct) {
super(openmct); super(openmct);
@ -52,24 +53,31 @@ export default class EditPropertiesAction extends PropertiesAction {
* @private * @private
*/ */
_onSave(changes) { _onSave(changes) {
if (!this.openmct.objects.isTransactionActive()) {
this.openmct.objects.startTransaction();
}
try { try {
Object.entries(changes).forEach(([key, value]) => { Object.entries(changes).forEach(([key, value]) => {
const properties = key.split('.'); const existingValue = this.domainObject[key];
let object = this.domainObject; if (!(Array.isArray(existingValue)) && (typeof existingValue === 'object')) {
const propertiesLength = properties.length; value = {
properties.forEach((property, index) => { ...existingValue,
const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1; ...value
if (isComplexProperty && object[property] !== null) { };
object = object[property]; }
} else {
object[property] = value; this.openmct.objects.mutate(this.domainObject, key, value);
} });
const transaction = this.openmct.objects.getActiveTransaction();
return transaction.commit()
.catch(error => {
throw error;
}).finally(() => {
this.openmct.objects.endTransaction();
}); });
object = value;
this.openmct.objects.mutate(this.domainObject, key, value);
this.openmct.notifications.info('Save successful');
});
} catch (error) { } catch (error) {
this.openmct.notifications.error('Error saving objects'); this.openmct.notifications.error('Error saving objects');
console.error(error); console.error(error);

View File

@ -24,6 +24,7 @@ import {
createOpenMct, createOpenMct,
resetApplicationState resetApplicationState
} from 'utils/testing'; } from 'utils/testing';
import Vue from 'vue';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
@ -101,10 +102,15 @@ describe('EditPropertiesAction plugin', () => {
composition: [] composition: []
}; };
const deBouncedFormChange = debounce(handleFormPropertyChange, 500); editPropertiesAction.invoke([domainObject])
openmct.forms.on('onFormPropertyChange', deBouncedFormChange); .then(() => {
done();
})
.catch(() => {
done();
});
function handleFormPropertyChange(data) { Vue.nextTick(() => {
const form = document.querySelector('.js-form'); const form = document.querySelector('.js-form');
const title = form.querySelector('input'); const title = form.querySelector('input');
expect(title.value).toEqual(domainObject.name); expect(title.value).toEqual(domainObject.name);
@ -118,17 +124,7 @@ describe('EditPropertiesAction plugin', () => {
const clickEvent = createMouseEvent('click'); const clickEvent = createMouseEvent('click');
buttons[1].dispatchEvent(clickEvent); buttons[1].dispatchEvent(clickEvent);
});
openmct.forms.off('onFormPropertyChange', deBouncedFormChange);
}
editPropertiesAction.invoke([domainObject])
.then(() => {
done();
})
.catch(() => {
done();
});
}); });
it('edit properties action saves changes', (done) => { it('edit properties action saves changes', (done) => {
@ -159,11 +155,9 @@ describe('EditPropertiesAction plugin', () => {
const deBouncedCallback = debounce(callback, 300); const deBouncedCallback = debounce(callback, 300);
unObserve = openmct.objects.observe(domainObject, '*', deBouncedCallback); unObserve = openmct.objects.observe(domainObject, '*', deBouncedCallback);
let changed = false; editPropertiesAction.invoke([domainObject]);
const deBouncedFormChange = debounce(handleFormPropertyChange, 500);
openmct.forms.on('onFormPropertyChange', deBouncedFormChange);
function handleFormPropertyChange(data) { Vue.nextTick(() => {
const form = document.querySelector('.js-form'); const form = document.querySelector('.js-form');
const title = form.querySelector('input'); const title = form.querySelector('input');
const notes = form.querySelector('textArea'); const notes = form.querySelector('textArea');
@ -172,27 +166,18 @@ describe('EditPropertiesAction plugin', () => {
expect(buttons[0].textContent.trim()).toEqual('OK'); expect(buttons[0].textContent.trim()).toEqual('OK');
expect(buttons[1].textContent.trim()).toEqual('Cancel'); expect(buttons[1].textContent.trim()).toEqual('Cancel');
if (!changed) { expect(title.value).toEqual(domainObject.name);
expect(title.value).toEqual(domainObject.name); expect(notes.value).toEqual(domainObject.notes);
expect(notes.value).toEqual(domainObject.notes);
// change input field value and dispatch event for it // change input field value and dispatch event for it
title.focus(); title.focus();
title.value = newName; title.value = newName;
title.dispatchEvent(new Event('input')); title.dispatchEvent(new Event('input'));
title.blur(); title.blur();
changed = true; const clickEvent = createMouseEvent('click');
} else { buttons[0].dispatchEvent(clickEvent);
// click ok to save form changes });
const clickEvent = createMouseEvent('click');
buttons[0].dispatchEvent(clickEvent);
openmct.forms.off('onFormPropertyChange', deBouncedFormChange);
}
}
editPropertiesAction.invoke([domainObject]);
}); });
it('edit properties action discards changes', (done) => { it('edit properties action discards changes', (done) => {
@ -217,7 +202,6 @@ describe('EditPropertiesAction plugin', () => {
}) })
.catch(() => { .catch(() => {
expect(domainObject.name).toEqual(name); expect(domainObject.name).toEqual(name);
done(); done();
}); });

View File

@ -100,6 +100,7 @@ export default {
components: { components: {
ToggleSwitch ToggleSwitch
}, },
inject: ["openmct"],
props: { props: {
model: { model: {
type: Object, type: Object,
@ -107,11 +108,10 @@ export default {
} }
}, },
data() { data() {
this.changes = {};
return { return {
isUseTelemetryLimits: this.model.value.isUseTelemetryLimits, isUseTelemetryLimits: this.model.value.isUseTelemetryLimits,
isDisplayMinMax: this.model.value.isDisplayMinMax,
isDisplayCurVal: this.model.value.isDisplayCurVal,
isDisplayUnits: this.model.value.isDisplayUnits,
limitHigh: this.model.value.limitHigh, limitHigh: this.model.value.limitHigh,
limitLow: this.model.value.limitLow, limitLow: this.model.value.limitLow,
max: this.model.value.max, max: this.model.value.max,
@ -120,24 +120,15 @@ export default {
}, },
methods: { methods: {
onChange(event) { onChange(event) {
const data = { let data = {
model: this.model, model: {}
value: {
gaugeType: this.model.value.gaugeType,
isDisplayMinMax: this.isDisplayMinMax,
isDisplayCurVal: this.isDisplayCurVal,
isDisplayUnits: this.isDisplayUnits,
isUseTelemetryLimits: this.isUseTelemetryLimits,
limitLow: this.limitLow,
limitHigh: this.limitHigh,
max: this.max,
min: this.min,
precision: this.model.value.precision
}
}; };
if (event) { if (event) {
const target = event.target; const target = event.target;
const property = target.dataset.fieldName;
data.model.property = Array.from(this.model.property).concat([property]);
data.value = this[property];
const targetIndicator = target.parentElement.querySelector('.req-indicator'); const targetIndicator = target.parentElement.querySelector('.req-indicator');
if (targetIndicator.classList.contains('req')) { if (targetIndicator.classList.contains('req')) {
targetIndicator.classList.add('visited'); targetIndicator.classList.add('visited');
@ -160,13 +151,13 @@ export default {
}, },
toggleUseTelemetryLimits() { toggleUseTelemetryLimits() {
this.isUseTelemetryLimits = !this.isUseTelemetryLimits; this.isUseTelemetryLimits = !this.isUseTelemetryLimits;
const data = {
this.onChange(); model: {
}, property: Array.from(this.model.property).concat(['isUseTelemetryLimits'])
toggleMinMax() { },
this.isDisplayMinMax = !this.isDisplayMinMax; value: this.isUseTelemetryLimits
};
this.onChange(); this.$emit('onChange', data);
} }
} }
}; };

View File

@ -889,32 +889,24 @@ export default {
this.syncUrlWithPageAndSection(); this.syncUrlWithPageAndSection();
this.filterAndSortEntries(); this.filterAndSortEntries();
}, },
activeTransaction() {
return this.openmct.objects.getActiveTransaction();
},
startTransaction() { startTransaction() {
if (!this.openmct.editor.isEditing()) { if (!this.openmct.objects.isTransactionActive()) {
this.openmct.objects.startTransaction(); this.transaction = this.openmct.objects.startTransaction();
} }
}, },
saveTransaction() { saveTransaction() {
const transaction = this.activeTransaction(); if (this.transaction !== undefined) {
this.transaction.commit()
if (!transaction || this.openmct.editor.isEditing()) { .catch(error => {
return; throw error;
}).finally(() => {
this.openmct.objects.endTransaction();
});
} }
return transaction.commit()
.catch(error => {
throw error;
}).finally(() => {
this.openmct.objects.endTransaction();
});
}, },
cancelTransaction() { cancelTransaction() {
if (!this.openmct.editor.isEditing()) { if (this.transaction !== undefined) {
const transaction = this.activeTransaction(); this.transaction.cancel()
transaction.cancel()
.catch(error => { .catch(error => {
throw error; throw error;
}).finally(() => { }).finally(() => {