diff --git a/.github/workflows/npm-prerelease.yml b/.github/workflows/npm-prerelease.yml index b92d217902..0f141e3628 100644 --- a/.github/workflows/npm-prerelease.yml +++ b/.github/workflows/npm-prerelease.yml @@ -28,6 +28,6 @@ jobs: node-version: 16 registry-url: https://registry.npmjs.org/ - run: npm install - - run: npm publish --access public --tag unstable + - run: npm publish --access=public --tag unstable env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.npmignore b/.npmignore index 57b7d8d12b..1bdde6a5f5 100644 --- a/.npmignore +++ b/.npmignore @@ -21,4 +21,10 @@ !copyright-notice.html !index.html !openmct.js -!SECURITY.md \ No newline at end of file +!SECURITY.md + +# Add e2e tests to npm package +!/e2e/**/* + +# ... except our test-data folder files. +/e2e/test-data/*.json diff --git a/src/api/forms/FormsAPI.js b/src/api/forms/FormsAPI.js index c0bdc78c65..95fc6a3f7c 100644 --- a/src/api/forms/FormsAPI.js +++ b/src/api/forms/FormsAPI.js @@ -23,13 +23,11 @@ import FormController from './FormController'; import FormProperties from './components/FormProperties.vue'; -import EventEmitter from 'EventEmitter'; import Vue from 'vue'; +import _ from 'lodash'; -export default class FormsAPI extends EventEmitter { +export default class FormsAPI { constructor(openmct) { - super(); - this.openmct = 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 + * @public + * @param {Array
} 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 * @param {Array
} formStructure a form structure, array of section * @param {Object} options * @property {HTMLElement} element Parent Element to render a Form * @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, onChange } = {}) { - const changes = {}; - let overlay; - let onDismiss; - let onSave; + if (element === undefined) { + throw Error('Required element parameter not provided'); + } const self = this; + const changes = {}; + let formSave; + let formCancel; + const promise = new Promise((resolve, reject) => { - onSave = onFormAction(resolve); - onDismiss = onFormAction(reject); + formSave = onFormAction(resolve); + formCancel = onFormAction(reject); }); const vm = new Vue({ @@ -126,26 +170,17 @@ export default class FormsAPI extends EventEmitter { return { formStructure, onChange: onFormPropertyChange, - onDismiss, - onSave + onCancel: formCancel, + onSave: formSave }; }, - template: '' + template: '' }).$mount(); const formElement = vm.$el; - if (element) { - element.append(formElement); - } else { - overlay = self.openmct.overlays.overlay({ - element: vm.$el, - size: 'dialog', - onDestroy: () => vm.$destroy() - }); - } + element.append(formElement); function onFormPropertyChange(data) { - self.emit('onFormPropertyChange', data); if (onChange) { onChange(data); } @@ -158,17 +193,14 @@ export default class FormsAPI extends EventEmitter { key = property.join('.'); } - changes[key] = data.value; + _.set(changes, key, data.value); } } function onFormAction(callback) { return () => { - if (element) { - formElement.remove(); - } else { - overlay.dismiss(); - } + formElement.remove(); + vm.$destroy(); if (callback) { callback(changes); diff --git a/src/api/forms/FormsAPISpec.js b/src/api/forms/FormsAPISpec.js index ac7f0fc9fb..362e398da7 100644 --- a/src/api/forms/FormsAPISpec.js +++ b/src/api/forms/FormsAPISpec.js @@ -133,7 +133,7 @@ describe('The Forms API', () => { }); it('when container element is provided', (done) => { - openmct.forms.showForm(formStructure, { element }).catch(() => { + openmct.forms.showCustomForm(formStructure, { element }).catch(() => { done(); }); const titleElement = element.querySelector('.c-overlay__dialog-title'); diff --git a/src/api/forms/components/FormProperties.vue b/src/api/forms/components/FormProperties.vue index 2ca84d0a73..9f631e108b 100644 --- a/src/api/forms/components/FormProperties.vue +++ b/src/api/forms/components/FormProperties.vue @@ -73,7 +73,7 @@ tabindex="0" class="c-button js-cancel-button" aria-label="Cancel" - @click="onDismiss" + @click="onCancel" > {{ cancelLabel }} @@ -164,8 +164,8 @@ export default { this.$emit('onChange', data); }, - onDismiss() { - this.$emit('onDismiss'); + onCancel() { + this.$emit('onCancel'); }, onSave() { this.$emit('onSave'); diff --git a/src/api/objects/MutableDomainObject.js b/src/api/objects/MutableDomainObject.js index 046f8b0697..df0965d3b5 100644 --- a/src/api/objects/MutableDomainObject.js +++ b/src/api/objects/MutableDomainObject.js @@ -75,11 +75,7 @@ class MutableDomainObject { return eventOff; } $set(path, value) { - _.set(this, path, value); - - if (path !== 'persisted' && path !== 'modified') { - _.set(this, 'modified', Date.now()); - } + MutableDomainObject.mutateObject(this, path, value); //Emit secret synchronization event first, so that all objects are in sync before subsequent events fired. this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this); @@ -136,8 +132,11 @@ class MutableDomainObject { } static mutateObject(object, path, value) { + if (path !== 'persisted') { + _.set(object, 'modified', Date.now()); + } + _.set(object, path, value); - _.set(object, 'modified', Date.now()); } } diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js index 0ebeba5da8..986644350a 100644 --- a/src/api/objects/ObjectAPI.js +++ b/src/api/objects/ObjectAPI.js @@ -363,7 +363,6 @@ export default class ObjectAPI { } else if (this.#hasAlreadyBeenPersisted(domainObject)) { result = Promise.resolve(true); } else { - const persistedTime = Date.now(); const username = await this.#getCurrentUsername(); const isNewObject = domainObject.persisted === undefined; let savedResolve; @@ -375,15 +374,20 @@ export default class ObjectAPI { savedReject = reject; }); - this.#mutate(domainObject, 'persisted', persistedTime); this.#mutate(domainObject, 'modifiedBy', username); if (isNewObject) { + const persistedTime = Date.now(); + + this.#mutate(domainObject, 'persisted', persistedTime); this.#mutate(domainObject, 'created', persistedTime); this.#mutate(domainObject, 'createdBy', username); savedObjectPromise = provider.create(domainObject); } else { + const persistedTime = Date.now(); + this.#mutate(domainObject, 'persisted', persistedTime); + savedObjectPromise = provider.update(domainObject); } diff --git a/src/api/objects/ObjectAPISpec.js b/src/api/objects/ObjectAPISpec.js index c15b3c7db5..6940b7426d 100644 --- a/src/api/objects/ObjectAPISpec.js +++ b/src/api/objects/ObjectAPISpec.js @@ -94,6 +94,35 @@ describe("The Object API", () => { expect(mockProvider.create).not.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 () => { await objectAPI.save(mockDomainObject); expect(mockDomainObject.createdBy).toBe(USERNAME); diff --git a/src/api/overlays/Overlay.js b/src/api/overlays/Overlay.js index c63285a2e8..632d881b1a 100644 --- a/src/api/overlays/Overlay.js +++ b/src/api/overlays/Overlay.js @@ -17,6 +17,7 @@ class Overlay extends EventEmitter { dismissable = true, element, onDestroy, + onDismiss, size } = {}) { super(); @@ -32,7 +33,7 @@ class Overlay extends EventEmitter { OverlayComponent: OverlayComponent }, provide: { - dismiss: this.dismiss.bind(this), + dismiss: this.notifyAndDismiss.bind(this), element, buttons, dismissable: this.dismissable @@ -43,6 +44,10 @@ class Overlay extends EventEmitter { if (onDestroy) { this.once('destroy', onDestroy); } + + if (onDismiss) { + this.once('dismiss', onDismiss); + } } dismiss() { @@ -51,6 +56,12 @@ class Overlay extends EventEmitter { this.component.$destroy(); } + //Ensures that any callers are notified that the overlay is dismissed + notifyAndDismiss() { + this.emit('dismiss'); + this.dismiss(); + } + /** * @private **/ diff --git a/src/api/overlays/OverlayAPI.js b/src/api/overlays/OverlayAPI.js index 5587df3161..80c6238de2 100644 --- a/src/api/overlays/OverlayAPI.js +++ b/src/api/overlays/OverlayAPI.js @@ -55,7 +55,7 @@ class OverlayAPI { dismissLastOverlay() { let lastOverlay = this.activeOverlays[this.activeOverlays.length - 1]; if (lastOverlay && lastOverlay.dismissable) { - lastOverlay.dismiss(); + lastOverlay.notifyAndDismiss(); } } diff --git a/src/plugins/formActions/CreateAction.js b/src/plugins/formActions/CreateAction.js index aa42a611d6..f43b523e61 100644 --- a/src/plugins/formActions/CreateAction.js +++ b/src/plugins/formActions/CreateAction.js @@ -24,6 +24,7 @@ import PropertiesAction from './PropertiesAction'; import CreateWizard from './CreateWizard'; import { v4 as uuid } from 'uuid'; +import _ from 'lodash'; export default class CreateAction extends PropertiesAction { constructor(openmct, type, parentDomainObject) { @@ -50,19 +51,15 @@ export default class CreateAction extends PropertiesAction { return; } - const properties = key.split('.'); - let object = this.domainObject; - const propertiesLength = properties.length; - properties.forEach((property, index) => { - const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1; - if (isComplexProperty && object[property] !== null) { - object = object[property]; - } else { - object[property] = value; - } - }); + const existingValue = this.domainObject[key]; + if (!(existingValue instanceof Array) && (typeof existingValue === 'object')) { + value = { + ...existingValue, + ...value + }; + } - object = value; + _.set(this.domainObject, key, value); }); const parentDomainObject = parentDomainObjectPath[0]; @@ -94,6 +91,12 @@ export default class CreateAction extends PropertiesAction { dialog.dismiss(); } + /** + * @private + */ + _onCancel() { + //do Nothing + } /** * @private */ @@ -151,6 +154,7 @@ export default class CreateAction extends PropertiesAction { formStructure.title = 'Create a New ' + definition.name; this.openmct.forms.showForm(formStructure) - .then(this._onSave.bind(this)); + .then(this._onSave.bind(this)) + .catch(this._onCancel.bind(this)); } } diff --git a/src/plugins/formActions/EditPropertiesAction.js b/src/plugins/formActions/EditPropertiesAction.js index 65ceaaadd1..f2cb232f56 100644 --- a/src/plugins/formActions/EditPropertiesAction.js +++ b/src/plugins/formActions/EditPropertiesAction.js @@ -22,6 +22,7 @@ import PropertiesAction from './PropertiesAction'; import CreateWizard from './CreateWizard'; + export default class EditPropertiesAction extends PropertiesAction { constructor(openmct) { super(openmct); @@ -52,24 +53,31 @@ export default class EditPropertiesAction extends PropertiesAction { * @private */ _onSave(changes) { + if (!this.openmct.objects.isTransactionActive()) { + this.openmct.objects.startTransaction(); + } + try { Object.entries(changes).forEach(([key, value]) => { - const properties = key.split('.'); - let object = this.domainObject; - const propertiesLength = properties.length; - properties.forEach((property, index) => { - const isComplexProperty = propertiesLength > 1 && index !== propertiesLength - 1; - if (isComplexProperty && object[property] !== null) { - object = object[property]; - } else { - object[property] = value; - } + const existingValue = this.domainObject[key]; + if (!(Array.isArray(existingValue)) && (typeof existingValue === 'object')) { + value = { + ...existingValue, + ...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) { this.openmct.notifications.error('Error saving objects'); console.error(error); diff --git a/src/plugins/formActions/pluginSpec.js b/src/plugins/formActions/pluginSpec.js index 232ff0d303..ad5f24472c 100644 --- a/src/plugins/formActions/pluginSpec.js +++ b/src/plugins/formActions/pluginSpec.js @@ -24,6 +24,7 @@ import { createOpenMct, resetApplicationState } from 'utils/testing'; +import Vue from 'vue'; import { debounce } from 'lodash'; @@ -101,10 +102,15 @@ describe('EditPropertiesAction plugin', () => { composition: [] }; - const deBouncedFormChange = debounce(handleFormPropertyChange, 500); - openmct.forms.on('onFormPropertyChange', deBouncedFormChange); + editPropertiesAction.invoke([domainObject]) + .then(() => { + done(); + }) + .catch(() => { + done(); + }); - function handleFormPropertyChange(data) { + Vue.nextTick(() => { const form = document.querySelector('.js-form'); const title = form.querySelector('input'); expect(title.value).toEqual(domainObject.name); @@ -118,17 +124,7 @@ describe('EditPropertiesAction plugin', () => { const clickEvent = createMouseEvent('click'); buttons[1].dispatchEvent(clickEvent); - - openmct.forms.off('onFormPropertyChange', deBouncedFormChange); - } - - editPropertiesAction.invoke([domainObject]) - .then(() => { - done(); - }) - .catch(() => { - done(); - }); + }); }); it('edit properties action saves changes', (done) => { @@ -159,11 +155,9 @@ describe('EditPropertiesAction plugin', () => { const deBouncedCallback = debounce(callback, 300); unObserve = openmct.objects.observe(domainObject, '*', deBouncedCallback); - let changed = false; - const deBouncedFormChange = debounce(handleFormPropertyChange, 500); - openmct.forms.on('onFormPropertyChange', deBouncedFormChange); + editPropertiesAction.invoke([domainObject]); - function handleFormPropertyChange(data) { + Vue.nextTick(() => { const form = document.querySelector('.js-form'); const title = form.querySelector('input'); const notes = form.querySelector('textArea'); @@ -172,27 +166,18 @@ describe('EditPropertiesAction plugin', () => { expect(buttons[0].textContent.trim()).toEqual('OK'); expect(buttons[1].textContent.trim()).toEqual('Cancel'); - if (!changed) { - expect(title.value).toEqual(domainObject.name); - expect(notes.value).toEqual(domainObject.notes); + expect(title.value).toEqual(domainObject.name); + expect(notes.value).toEqual(domainObject.notes); - // change input field value and dispatch event for it - title.focus(); - title.value = newName; - title.dispatchEvent(new Event('input')); - title.blur(); + // change input field value and dispatch event for it + title.focus(); + title.value = newName; + title.dispatchEvent(new Event('input')); + title.blur(); - changed = true; - } else { - // click ok to save form changes - const clickEvent = createMouseEvent('click'); - buttons[0].dispatchEvent(clickEvent); - - openmct.forms.off('onFormPropertyChange', deBouncedFormChange); - } - } - - editPropertiesAction.invoke([domainObject]); + const clickEvent = createMouseEvent('click'); + buttons[0].dispatchEvent(clickEvent); + }); }); it('edit properties action discards changes', (done) => { @@ -217,7 +202,6 @@ describe('EditPropertiesAction plugin', () => { }) .catch(() => { expect(domainObject.name).toEqual(name); - done(); }); diff --git a/src/plugins/gauge/components/GaugeFormController.vue b/src/plugins/gauge/components/GaugeFormController.vue index b9556e79bc..73a224e313 100644 --- a/src/plugins/gauge/components/GaugeFormController.vue +++ b/src/plugins/gauge/components/GaugeFormController.vue @@ -100,6 +100,7 @@ export default { components: { ToggleSwitch }, + inject: ["openmct"], props: { model: { type: Object, @@ -107,11 +108,10 @@ export default { } }, data() { + this.changes = {}; + return { 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, limitLow: this.model.value.limitLow, max: this.model.value.max, @@ -120,24 +120,15 @@ export default { }, methods: { onChange(event) { - const data = { - model: this.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 - } + let data = { + model: {} }; if (event) { 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'); if (targetIndicator.classList.contains('req')) { targetIndicator.classList.add('visited'); @@ -160,13 +151,13 @@ export default { }, toggleUseTelemetryLimits() { this.isUseTelemetryLimits = !this.isUseTelemetryLimits; - - this.onChange(); - }, - toggleMinMax() { - this.isDisplayMinMax = !this.isDisplayMinMax; - - this.onChange(); + const data = { + model: { + property: Array.from(this.model.property).concat(['isUseTelemetryLimits']) + }, + value: this.isUseTelemetryLimits + }; + this.$emit('onChange', data); } } }; diff --git a/src/plugins/notebook/components/Notebook.vue b/src/plugins/notebook/components/Notebook.vue index 14a5cc48f1..c242d13563 100644 --- a/src/plugins/notebook/components/Notebook.vue +++ b/src/plugins/notebook/components/Notebook.vue @@ -889,32 +889,24 @@ export default { this.syncUrlWithPageAndSection(); this.filterAndSortEntries(); }, - activeTransaction() { - return this.openmct.objects.getActiveTransaction(); - }, startTransaction() { - if (!this.openmct.editor.isEditing()) { - this.openmct.objects.startTransaction(); + if (!this.openmct.objects.isTransactionActive()) { + this.transaction = this.openmct.objects.startTransaction(); } }, saveTransaction() { - const transaction = this.activeTransaction(); - - if (!transaction || this.openmct.editor.isEditing()) { - return; + if (this.transaction !== undefined) { + this.transaction.commit() + .catch(error => { + throw error; + }).finally(() => { + this.openmct.objects.endTransaction(); + }); } - - return transaction.commit() - .catch(error => { - throw error; - }).finally(() => { - this.openmct.objects.endTransaction(); - }); }, cancelTransaction() { - if (!this.openmct.editor.isEditing()) { - const transaction = this.activeTransaction(); - transaction.cancel() + if (this.transaction !== undefined) { + this.transaction.cancel() .catch(error => { throw error; }).finally(() => {