Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nasa/openmct.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScott Bell <scott@traclabs.com>2022-09-30 20:32:11 +0300
committerGitHub <noreply@github.com>2022-09-30 20:32:11 +0300
commitce463babfff0f800d4eac6bbbb1ea9d838d0dce9 (patch)
treee641c223f3d3f0cf437ddaa571b30f528fcedc01
parent27c30132d23421de79a271b737f98c53f59bd065 (diff)
5734 synchronization for new tags on notebook entries (#5763)
* trying this again * wip * wip * wip * one annotation per tag * fixed too many events firing * syncing works mostly * syncing properly across existing annotations * search with multiple tags * resolve conflicts between different tag editors * resolve conflicts * fix annotation tests * combine search results * modify tests * prevent infinite loop creating annotation * add modified and deleted * revert index checkin * change to standard couch deleted flag * revert throwing of error * resolve conflict issues * work in progress, but load annotations once from notebook * works to add * attempt 1 * wip * last changes * listening works, though still getting conflicts * rename to annotationLastCreated * use local mutable again * works with new tags syncing * listeners wont fire if modification is null * clean up code * fixed local search * cleaned up log messages * remove on more log * add e2e test for network traffic * lint * change to use good old for each * add some local variables for clarity * Update src/api/objects/ObjectAPI.js Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com> * Update src/api/objects/ObjectAPI.js Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com> * Update src/plugins/notebook/components/Notebook.vue Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com> * press enter for last entry * add test explanation of numbers * fix spread typo * add some nice jsdoc * throw some errors * use really small integer instead * remove unneeded binding * make method public and jsdoc it * use mutables * clean up tests * clean up tests * use aria labels for tests * add some proper tsdoc to annotation api * add undelete test Co-authored-by: John Hill <john.c.hill@nasa.gov> Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
-rw-r--r--docs/src/index.md4
-rw-r--r--e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js232
-rw-r--r--e2e/tests/functional/plugins/notebook/tags.e2e.spec.js21
-rw-r--r--src/api/annotation/AnnotationAPI.js122
-rw-r--r--src/api/annotation/AnnotationAPISpec.js43
-rw-r--r--src/api/composition/CompositionCollection.js2
-rw-r--r--src/api/objects/InMemorySearchProvider.js58
-rw-r--r--src/api/objects/InMemorySearchWorker.js31
-rw-r--r--src/api/objects/ObjectAPI.js43
-rw-r--r--src/api/objects/ObjectAPISpec.js2
-rw-r--r--src/plugins/notebook/components/Notebook.vue55
-rw-r--r--src/plugins/notebook/components/NotebookEntry.vue18
-rw-r--r--src/plugins/notebook/components/Sidebar.vue2
-rw-r--r--src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js61
-rw-r--r--src/plugins/notebook/notebook-constants.js9
-rw-r--r--src/plugins/operatorStatus/AbstractStatusIndicator.js2
-rw-r--r--src/plugins/persistence/couch/CouchObjectProvider.js6
-rw-r--r--src/plugins/persistence/couch/CouchSearchProvider.js44
-rw-r--r--src/selection/Selection.js2
-rw-r--r--src/ui/components/tags/TagEditor.vue98
-rw-r--r--src/ui/components/tags/TagSelection.vue5
-rw-r--r--webpack.dev.js2
22 files changed, 567 insertions, 295 deletions
diff --git a/docs/src/index.md b/docs/src/index.md
index 3166ae601..52781a373 100644
--- a/docs/src/index.md
+++ b/docs/src/index.md
@@ -15,8 +15,8 @@
## Sections
- * The [API](api/) document is generated from inline documentation
- using [JSDoc](http://usejsdoc.org/), and describes the JavaScript objects and
+ * The [API](api/) uses inline documentation
+ using [TypeScript](https://www.typescriptlang.org) and some legacy [JSDoc](https://jsdoc.app/). It describes the JavaScript objects and
functions that make up the software platform.
* The [Development Process](process/) document describes the
diff --git a/e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js b/e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js
new file mode 100644
index 000000000..4edb1ff28
--- /dev/null
+++ b/e2e/tests/functional/plugins/notebook/notebookWithCouchDB.e2e.spec.js
@@ -0,0 +1,232 @@
+/*****************************************************************************
+ * 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.
+ *****************************************************************************/
+
+/*
+This test suite is dedicated to tests which verify the basic operations surrounding Notebooks with CouchDB.
+*/
+
+const { test, expect } = require('../../../../baseFixtures');
+const { createDomainObjectWithDefaults } = require('../../../../appActions');
+
+test.describe('Notebook Network Request Inspection @couchdb', () => {
+ let testNotebook;
+ test.beforeEach(async ({ page }) => {
+ //Navigate to baseURL
+ await page.goto('./', { waitUntil: 'networkidle' });
+
+ // Create Notebook
+ testNotebook = await createDomainObjectWithDefaults(page, {
+ type: 'Notebook',
+ name: "TestNotebook"
+ });
+ });
+
+ test('Inspect Notebook Entry Network Requests', async ({ page }) => {
+ // Expand sidebar
+ await page.locator('.c-notebook__toggle-nav-button').click();
+
+ // Collect all request events to count and assert after notebook action
+ let addingNotebookElementsRequests = [];
+ page.on('request', (request) => addingNotebookElementsRequests.push(request));
+
+ let [notebookUrlRequest, allDocsRequest] = await Promise.all([
+ // Waits for the next request with the specified url
+ page.waitForRequest(`**/openmct/${testNotebook.uuid}`),
+ page.waitForRequest('**/openmct/_all_docs?include_docs=true'),
+ // Triggers the request
+ page.click('[aria-label="Add Page"]'),
+ // Ensures that there are no other network requests
+ page.waitForLoadState('networkidle')
+ ]);
+ // Assert that only two requests are made
+ // Network Requests are:
+ // 1) The actual POST to create the page
+ // 2) The shared worker event from 👆 request
+ expect(addingNotebookElementsRequests.length).toBe(2);
+
+ // Assert on request object
+ expect(notebookUrlRequest.postDataJSON().metadata.name).toBe('TestNotebook');
+ expect(notebookUrlRequest.postDataJSON().model.persisted).toBeGreaterThanOrEqual(notebookUrlRequest.postDataJSON().model.modified);
+ expect(allDocsRequest.postDataJSON().keys).toContain(testNotebook.uuid);
+
+ // Add an entry
+ // Network Requests are:
+ // 1) The actual POST to create the entry
+ // 2) The shared worker event from 👆 POST request
+ addingNotebookElementsRequests = [];
+ await page.locator('text=To start a new entry, click here or drag and drop any object').click();
+ await page.locator('[aria-label="Notebook Entry Input"]').click();
+ await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
+ await page.waitForLoadState('networkidle');
+ expect(addingNotebookElementsRequests.length).toBeLessThanOrEqual(2);
+
+ // Add some tags
+ // Network Requests are for each tag creation are:
+ // 1) Getting the original path of the parent object
+ // 2) Getting the original path of the grandparent object (recursive call)
+ // 3) Creating the annotation/tag object
+ // 4) The shared worker event from 👆 POST request
+ // 5) Mutate notebook domain object's annotationModified property
+ // 6) The shared worker event from 👆 POST request
+ // 7) Notebooks fetching new annotations due to annotationModified changed
+ // 8) The update of the notebook domain's object's modified property
+ // 9) The shared worker event from 👆 POST request
+ // 10) Entry is timestamped
+ // 11) The shared worker event from 👆 POST request
+
+ addingNotebookElementsRequests = [];
+ await page.hover(`button:has-text("Add Tag")`);
+ await page.locator(`button:has-text("Add Tag")`).click();
+ await page.locator('[placeholder="Type to select tag"]').click();
+ await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")');
+ page.waitForLoadState('networkidle');
+ expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11);
+
+ addingNotebookElementsRequests = [];
+ await page.hover(`button:has-text("Add Tag")`);
+ await page.locator(`button:has-text("Add Tag")`).click();
+ await page.locator('[placeholder="Type to select tag"]').click();
+ await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")');
+ page.waitForLoadState('networkidle');
+ expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11);
+
+ addingNotebookElementsRequests = [];
+ await page.hover(`button:has-text("Add Tag")`);
+ await page.locator(`button:has-text("Add Tag")`).click();
+ await page.locator('[placeholder="Type to select tag"]').click();
+ await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Science")');
+ page.waitForLoadState('networkidle');
+ expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11);
+
+ // Delete all the tags
+ // Network requests are:
+ // 1) Send POST to mutate _delete property to true on annotation with tag
+ // 2) The shared worker event from 👆 POST request
+ // 3) Timestamp update on entry
+ // 4) The shared worker event from 👆 POST request
+ // This happens for 3 tags so 12 requests
+ addingNotebookElementsRequests = [];
+ await page.hover('[aria-label="Tag"]:has-text("Driving")');
+ await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")', {state: 'hidden'});
+ await page.hover('[aria-label="Tag"]:has-text("Drilling")');
+ await page.locator('[aria-label="Tag"]:has-text("Drilling") ~ .c-completed-tag-deletion').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")', {state: 'hidden'});
+ page.hover('[aria-label="Tag"]:has-text("Science")');
+ await page.locator('[aria-label="Tag"]:has-text("Science") ~ .c-completed-tag-deletion').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Science")', {state: 'hidden'});
+ page.waitForLoadState('networkidle');
+ expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(12);
+
+ // Add two more pages
+ await page.click('[aria-label="Add Page"]');
+ await page.click('[aria-label="Add Page"]');
+
+ // Add three entries
+ await page.locator('text=To start a new entry, click here or drag and drop any object').click();
+ await page.locator('[aria-label="Notebook Entry Input"]').click();
+ await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
+
+ await page.locator('text=To start a new entry, click here or drag and drop any object').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').fill(`Second Entry`);
+
+ await page.locator('text=To start a new entry, click here or drag and drop any object').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').fill(`Third Entry`);
+
+ // Add three tags
+ await page.hover(`button:has-text("Add Tag") >> nth=2`);
+ await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
+ await page.locator('[placeholder="Type to select tag"]').click();
+ await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Science")');
+
+ await page.hover(`button:has-text("Add Tag") >> nth=2`);
+ await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
+ await page.locator('[placeholder="Type to select tag"]').click();
+ await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")');
+
+ await page.hover(`button:has-text("Add Tag") >> nth=2`);
+ await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
+ await page.locator('[placeholder="Type to select tag"]').click();
+ await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click();
+ await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")');
+ page.waitForLoadState('networkidle');
+
+ // Add a fourth entry
+ // Network requests are:
+ // 1) Send POST to add new entry
+ // 2) The shared worker event from 👆 POST request
+ // 3) Timestamp update on entry
+ // 4) The shared worker event from 👆 POST request
+ addingNotebookElementsRequests = [];
+ await page.locator('text=To start a new entry, click here or drag and drop any object').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').fill(`Fourth Entry`);
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').press('Enter');
+ page.waitForLoadState('networkidle');
+
+ expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
+
+ // Add a fifth entry
+ // Network requests are:
+ // 1) Send POST to add new entry
+ // 2) The shared worker event from 👆 POST request
+ // 3) Timestamp update on entry
+ // 4) The shared worker event from 👆 POST request
+ addingNotebookElementsRequests = [];
+ await page.locator('text=To start a new entry, click here or drag and drop any object').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').fill(`Fifth Entry`);
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').press('Enter');
+ page.waitForLoadState('networkidle');
+
+ expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
+
+ // Add a sixth entry
+ // 1) Send POST to add new entry
+ // 2) The shared worker event from 👆 POST request
+ // 3) Timestamp update on entry
+ // 4) The shared worker event from 👆 POST request
+ addingNotebookElementsRequests = [];
+ await page.locator('text=To start a new entry, click here or drag and drop any object').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').click();
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').fill(`Sixth Entry`);
+ await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').press('Enter');
+ page.waitForLoadState('networkidle');
+
+ expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
+ });
+});
+
+// Try to reduce indeterminism of browser requests by only returning fetch requests.
+// Filter out preflight CORS, fetching stylesheets, page icons, etc. that can occur during tests
+function filterNonFetchRequests(requests) {
+ return requests.filter(request => {
+ return (request.resourceType() === 'fetch');
+ });
+}
diff --git a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js
index 11533197c..e2cda186a 100644
--- a/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js
+++ b/e2e/tests/functional/plugins/notebook/tags.e2e.spec.js
@@ -81,10 +81,8 @@ test.describe('Tagging in Notebooks @addInit', () => {
test('Can load tags', async ({ page }) => {
await createNotebookAndEntry(page);
- // Click text=To start a new entry, click here or drag and drop any object
await page.locator('button:has-text("Add Tag")').click();
- // Click [placeholder="Type to select tag"]
await page.locator('[placeholder="Type to select tag"]').click();
await expect(page.locator('[aria-label="Autocomplete Options"]')).toContainText("Science");
@@ -97,9 +95,7 @@ test.describe('Tagging in Notebooks @addInit', () => {
await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science");
await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Driving");
- // Click button:has-text("Add Tag")
await page.locator('button:has-text("Add Tag")').click();
- // Click [placeholder="Type to select tag"]
await page.locator('[placeholder="Type to select tag"]').click();
await expect(page.locator('[aria-label="Autocomplete Options"]')).not.toContainText("Science");
@@ -108,39 +104,31 @@ test.describe('Tagging in Notebooks @addInit', () => {
});
test('Can search for tags', async ({ page }) => {
await createNotebookEntryAndTags(page);
- // Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
- // Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science");
- await expect(page.locator('[aria-label="Search Result"]')).toContainText("Driving");
+ await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
- // Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
- // Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Sc');
await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science");
- await expect(page.locator('[aria-label="Search Result"]')).toContainText("Driving");
+ await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
- // Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
- // Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq');
await expect(page.locator('[aria-label="Search Result"]')).toBeHidden();
- await expect(page.locator('[aria-label="Search Result"]')).toBeHidden();
});
test('Can delete tags', async ({ page }) => {
await createNotebookEntryAndTags(page);
await page.locator('[aria-label="Notebook Entries"]').click();
// Delete Driving
- await page.hover('.c-tag__label:has-text("Driving")');
- await page.locator('.c-tag__label:has-text("Driving") ~ .c-completed-tag-deletion').click();
+ await page.hover('[aria-label="Tag"]:has-text("Driving")');
+ await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click();
await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science");
await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving");
- // Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
});
@@ -153,7 +141,6 @@ test.describe('Tagging in Notebooks @addInit', () => {
await page.locator('button:has-text("OK")').click();
await page.goto('./', { waitUntil: 'networkidle' });
- // Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Unnamed');
await expect(page.locator('text=No results found')).toBeVisible();
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sci');
diff --git a/src/api/annotation/AnnotationAPI.js b/src/api/annotation/AnnotationAPI.js
index 6b9e910be..148b1d3c4 100644
--- a/src/api/annotation/AnnotationAPI.js
+++ b/src/api/annotation/AnnotationAPI.js
@@ -22,6 +22,7 @@
import { v4 as uuid } from 'uuid';
import EventEmitter from 'EventEmitter';
+import _ from 'lodash';
/**
* @readonly
@@ -42,19 +43,28 @@ const ANNOTATION_TYPES = Object.freeze({
const ANNOTATION_TYPE = 'annotation';
+const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
+
/**
* @typedef {Object} Tag
* @property {String} key a unique identifier for the tag
* @property {String} backgroundColor eg. "#cc0000"
* @property {String} foregroundColor eg. "#ffffff"
*/
+
export default class AnnotationAPI extends EventEmitter {
+
+ /**
+ * @param {OpenMCT} openmct
+ */
constructor(openmct) {
super();
this.openmct = openmct;
this.availableTags = {};
this.ANNOTATION_TYPES = ANNOTATION_TYPES;
+ this.ANNOTATION_TYPE = ANNOTATION_TYPE;
+ this.ANNOTATION_LAST_CREATED = ANNOTATION_LAST_CREATED;
this.openmct.types.addType(ANNOTATION_TYPE, {
name: 'Annotation',
@@ -63,6 +73,7 @@ export default class AnnotationAPI extends EventEmitter {
cssClass: 'icon-notebook',
initialize: function (domainObject) {
domainObject.targets = domainObject.targets || {};
+ domainObject._deleted = domainObject._deleted || false;
domainObject.originalContextPath = domainObject.originalContextPath || '';
domainObject.tags = domainObject.tags || [];
domainObject.contentText = domainObject.contentText || '';
@@ -112,6 +123,7 @@ export default class AnnotationAPI extends EventEmitter {
namespace
},
tags,
+ _deleted: false,
annotationType,
contentText,
originalContextPath
@@ -127,6 +139,7 @@ export default class AnnotationAPI extends EventEmitter {
const success = await this.openmct.objects.save(createdObject);
if (success) {
this.emit('annotationCreated', createdObject);
+ this.#updateAnnotationModified(domainObject);
return createdObject;
} else {
@@ -134,14 +147,32 @@ export default class AnnotationAPI extends EventEmitter {
}
}
+ #updateAnnotationModified(domainObject) {
+ this.openmct.objects.mutate(domainObject, this.ANNOTATION_LAST_CREATED, Date.now());
+ }
+
+ /**
+ * @method defineTag
+ * @param {String} key a unique identifier for the tag
+ * @param {Tag} tagsDefinition the definition of the tag to add
+ */
defineTag(tagKey, tagsDefinition) {
this.availableTags[tagKey] = tagsDefinition;
}
+ /**
+ * @method isAnnotation
+ * @param {import('../objects/ObjectAPI').DomainObject} domainObject domainObject the domainObject in question
+ * @returns {Boolean} Returns true if the domain object is an annotation
+ */
isAnnotation(domainObject) {
return domainObject && (domainObject.type === ANNOTATION_TYPE);
}
+ /**
+ * @method getAvailableTags
+ * @returns {Tag[]} Returns an array of the available tags that have been loaded
+ */
getAvailableTags() {
if (this.availableTags) {
const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => {
@@ -157,18 +188,26 @@ export default class AnnotationAPI extends EventEmitter {
}
}
- async getAnnotation(query, searchType) {
- let foundAnnotation = null;
-
- const searchResults = (await Promise.all(this.openmct.objects.search(query, null, searchType))).flat();
- if (searchResults) {
- foundAnnotation = searchResults[0];
- }
+ /**
+ * @method getAnnotations
+ * @param {String} query - The keystring of the domain object to search for annotations for
+ * @returns {import('../objects/ObjectAPI').DomainObject[]} Returns an array of domain objects that match the search query
+ */
+ async getAnnotations(query) {
+ const searchResults = (await Promise.all(this.openmct.objects.search(query, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat();
- return foundAnnotation;
+ return searchResults;
}
- async addAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) {
+ /**
+ * @method addSingleAnnotationTag
+ * @param {import('../objects/ObjectAPI').DomainObject=} existingAnnotation - An optional annotation to add the tag to. If not specified, we will create an annotation.
+ * @param {import('../objects/ObjectAPI').DomainObject} targetDomainObject - The domain object the annotation will point to.
+ * @param {Object=} targetSpecificDetails - Optional object to add to the target object. E.g., for notebooks this would be an entryID
+ * @param {AnnotationType} annotationType - The type of annotation this is for.
+ * @returns {import('../objects/ObjectAPI').DomainObject[]} Returns the annotation that was either created or passed as an existingAnnotation
+ */
+ async addSingleAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) {
if (!existingAnnotation) {
const targets = {};
const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier);
@@ -186,27 +225,44 @@ export default class AnnotationAPI extends EventEmitter {
return newAnnotation;
} else {
- const tagArray = [tag, ...existingAnnotation.tags];
- this.openmct.objects.mutate(existingAnnotation, 'tags', tagArray);
+ if (!existingAnnotation.tags.includes(tag)) {
+ throw new Error(`Existing annotation did not contain tag ${tag}`);
+ }
+
+ if (existingAnnotation._deleted) {
+ this.unDeleteAnnotation(existingAnnotation);
+ }
return existingAnnotation;
}
}
- removeAnnotationTag(existingAnnotation, tagToRemove) {
- if (existingAnnotation && existingAnnotation.tags.includes(tagToRemove)) {
- const cleanedArray = existingAnnotation.tags.filter(extantTag => extantTag !== tagToRemove);
- this.openmct.objects.mutate(existingAnnotation, 'tags', cleanedArray);
- } else {
- throw new Error(`Asked to remove tag (${tagToRemove}) that doesn't exist`, existingAnnotation);
+ /**
+ * @method deleteAnnotations
+ * @param {import('../objects/ObjectAPI').DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true)
+ */
+ deleteAnnotations(annotations) {
+ if (!annotations) {
+ throw new Error('Asked to delete null annotations! 🙅‍♂️');
}
+
+ annotations.forEach(annotation => {
+ if (!annotation._deleted) {
+ this.openmct.objects.mutate(annotation, '_deleted', true);
+ }
+ });
}
- removeAnnotationTags(existingAnnotation) {
- // just removes tags on the annotation as we can't really delete objects
- if (existingAnnotation && existingAnnotation.tags) {
- this.openmct.objects.mutate(existingAnnotation, 'tags', []);
+ /**
+ * @method deleteAnnotations
+ * @param {import('../objects/ObjectAPI').DomainObject} existingAnnotation - An annotations to undelete (set _deleted to false)
+ */
+ unDeleteAnnotation(annotation) {
+ if (!annotation) {
+ throw new Error('Asked to undelete null annotation! 🙅‍♂️');
}
+
+ this.openmct.objects.mutate(annotation, '_deleted', false);
}
#getMatchingTags(query) {
@@ -266,16 +322,36 @@ export default class AnnotationAPI extends EventEmitter {
return modelAddedToResults;
}
+ #combineSameTargets(results) {
+ const combinedResults = [];
+ results.forEach(currentAnnotation => {
+ const existingAnnotation = combinedResults.find((annotationToFind) => {
+ return _.isEqual(currentAnnotation.targets, annotationToFind.targets);
+ });
+ if (!existingAnnotation) {
+ combinedResults.push(currentAnnotation);
+ } else {
+ existingAnnotation.tags.push(...currentAnnotation.tags);
+ }
+ });
+
+ return combinedResults;
+ }
+
/**
* @method searchForTags
* @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving"
- * @param {Object} abortController An optional abort method to stop the query
+ * @param {Object} [abortController] An optional abort method to stop the query
* @returns {Promise} returns a model of matching tags with their target domain objects attached
*/
async searchForTags(query, abortController) {
const matchingTagKeys = this.#getMatchingTags(query);
const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat();
- const appliedTagSearchResults = this.#addTagMetaInformationToResults(searchResults, matchingTagKeys);
+ const filteredDeletedResults = searchResults.filter((result) => {
+ return !(result._deleted);
+ });
+ const combinedSameTargets = this.#combineSameTargets(filteredDeletedResults);
+ const appliedTagSearchResults = this.#addTagMetaInformationToResults(combinedSameTargets, matchingTagKeys);
const appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults);
const resultsWithValidPath = appliedTargetsModels.filter(result => {
return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath);
diff --git a/src/api/annotation/AnnotationAPISpec.js b/src/api/annotation/AnnotationAPISpec.js
index 8aa45864d..a7e2a162d 100644
--- a/src/api/annotation/AnnotationAPISpec.js
+++ b/src/api/annotation/AnnotationAPISpec.js
@@ -126,34 +126,44 @@ describe("The Annotation API", () => {
describe("Tagging", () => {
it("can create a tag", async () => {
- const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
+ const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
});
it("can delete a tag", async () => {
- const originalAnnotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
- const annotationObject = await openmct.annotation.addAnnotationTag(originalAnnotationObject, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'anotherTagToRemove');
+ const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
- openmct.annotation.removeAnnotationTag(annotationObject, 'anotherTagToRemove');
- expect(annotationObject.tags).toEqual(['aWonderfulTag']);
- openmct.annotation.removeAnnotationTag(annotationObject, 'aWonderfulTag');
- expect(annotationObject.tags).toEqual([]);
+ openmct.annotation.deleteAnnotations([annotationObject]);
+ expect(annotationObject._deleted).toBeTrue();
});
it("throws an error if deleting non-existent tag", async () => {
- const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
+ const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(() => {
openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist');
}).toThrow();
});
it("can remove all tags", async () => {
- const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
+ const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(() => {
- openmct.annotation.removeAnnotationTags(annotationObject);
+ openmct.annotation.deleteAnnotations([annotationObject]);
}).not.toThrow();
- expect(annotationObject.tags).toEqual([]);
+ expect(annotationObject._deleted).toBeTrue();
+ });
+ it("can add/delete/add a tag", async () => {
+ let annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
+ expect(annotationObject).toBeDefined();
+ expect(annotationObject.type).toEqual('annotation');
+ expect(annotationObject.tags).toContain('aWonderfulTag');
+ openmct.annotation.deleteAnnotations([annotationObject]);
+ expect(annotationObject._deleted).toBeTrue();
+ annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
+ expect(annotationObject).toBeDefined();
+ expect(annotationObject.type).toEqual('annotation');
+ expect(annotationObject.tags).toContain('aWonderfulTag');
+ expect(annotationObject._deleted).toBeFalse();
});
});
@@ -175,16 +185,5 @@ describe("The Annotation API", () => {
expect(results).toBeDefined();
expect(results.length).toEqual(1);
});
- it("can get notebook annotations", async () => {
- const targetKeyString = openmct.objects.makeKeyString(mockDomainObject.identifier);
- const query = {
- targetKeyString,
- entryId: 'fooBarEntry'
- };
-
- const results = await openmct.annotation.getAnnotation(query, openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS);
- expect(results).toBeDefined();
- expect(results.tags.length).toEqual(2);
- });
});
});
diff --git a/src/api/composition/CompositionCollection.js b/src/api/composition/CompositionCollection.js
index e04bbe888..78a0a0c8a 100644
--- a/src/api/composition/CompositionCollection.js
+++ b/src/api/composition/CompositionCollection.js
@@ -199,7 +199,7 @@ define([
if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) {
let keyString = this.publicAPI.objects.makeKeyString(child.identifier);
- child = this.publicAPI.objects._toMutable(child);
+ child = this.publicAPI.objects.toMutable(child);
this.mutables[keyString] = child;
}
diff --git a/src/api/objects/InMemorySearchProvider.js b/src/api/objects/InMemorySearchProvider.js
index 6feadaf44..2510b248b 100644
--- a/src/api/objects/InMemorySearchProvider.js
+++ b/src/api/objects/InMemorySearchProvider.js
@@ -42,7 +42,6 @@ class InMemorySearchProvider {
this.openmct = openmct;
this.indexedIds = {};
this.indexedCompositions = {};
- this.indexedTags = {};
this.idsToIndex = [];
this.pendingIndex = {};
this.pendingRequests = 0;
@@ -61,7 +60,6 @@ class InMemorySearchProvider {
this.localSearchForObjects = this.localSearchForObjects.bind(this);
this.localSearchForAnnotations = this.localSearchForAnnotations.bind(this);
this.localSearchForTags = this.localSearchForTags.bind(this);
- this.localSearchForNotebookAnnotations = this.localSearchForNotebookAnnotations.bind(this);
this.onAnnotationCreation = this.onAnnotationCreation.bind(this);
this.onCompositionAdded = this.onCompositionAdded.bind(this);
this.onCompositionRemoved = this.onCompositionRemoved.bind(this);
@@ -93,7 +91,7 @@ class InMemorySearchProvider {
this.searchTypes = this.openmct.objects.SEARCH_TYPES;
- this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.NOTEBOOK_ANNOTATIONS, this.searchTypes.TAGS];
+ this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.TAGS];
this.scheduleForIndexing(rootObject.identifier);
@@ -163,8 +161,6 @@ class InMemorySearchProvider {
return this.localSearchForObjects(queryId, query, maxResults);
} else if (searchType === this.searchTypes.ANNOTATIONS) {
return this.localSearchForAnnotations(queryId, query, maxResults);
- } else if (searchType === this.searchTypes.NOTEBOOK_ANNOTATIONS) {
- return this.localSearchForNotebookAnnotations(queryId, query, maxResults);
} else if (searchType === this.searchTypes.TAGS) {
return this.localSearchForTags(queryId, query, maxResults);
} else {
@@ -281,13 +277,6 @@ class InMemorySearchProvider {
provider.index(domainObject);
}
- onTagMutation(domainObject, newTags) {
- domainObject.tags = newTags;
- const provider = this;
-
- provider.index(domainObject);
- }
-
onCompositionAdded(newDomainObjectToIndex) {
const provider = this;
// The object comes in as a mutable domain object, which has functions,
@@ -342,14 +331,6 @@ class InMemorySearchProvider {
composition.on('remove', this.onCompositionRemoved);
this.indexedCompositions[keyString] = composition;
}
-
- if (domainObject.type === 'annotation') {
- this.indexedTags[keyString] = this.openmct.objects.observe(
- domainObject,
- 'tags',
- this.onTagMutation.bind(this, domainObject)
- );
- }
}
if ((keyString !== 'ROOT')) {
@@ -581,43 +562,6 @@ class InMemorySearchProvider {
this.onWorkerMessage(eventToReturn);
}
- /**
- * A local version of the same SharedWorker function
- * if we don't have SharedWorkers available (e.g., iOS)
- */
- localSearchForNotebookAnnotations(queryId, {entryId, targetKeyString}, maxResults) {
- // This results dictionary will have domain object ID keys which
- // point to the value the domain object's score.
- let results = [];
- const message = {
- request: 'searchForNotebookAnnotations',
- results: [],
- total: 0,
- queryId
- };
-
- const matchingAnnotations = this.localIndexedAnnotationsByDomainObject[targetKeyString];
- if (matchingAnnotations) {
- results = matchingAnnotations.filter(matchingAnnotation => {
- if (!matchingAnnotation.targets) {
- return false;
- }
-
- const target = matchingAnnotation.targets[targetKeyString];
-
- return (target && target.entryId && (target.entryId === entryId));
- });
- }
-
- message.total = results.length;
- message.results = results
- .slice(0, maxResults);
- const eventToReturn = {
- data: message
- };
- this.onWorkerMessage(eventToReturn);
- }
-
destroyObservers(observers) {
Object.entries(observers).forEach(([keyString, unobserve]) => {
if (typeof unobserve === 'function') {
diff --git a/src/api/objects/InMemorySearchWorker.js b/src/api/objects/InMemorySearchWorker.js
index a2bb53a02..71d8fe86b 100644
--- a/src/api/objects/InMemorySearchWorker.js
+++ b/src/api/objects/InMemorySearchWorker.js
@@ -43,8 +43,6 @@
port.postMessage(searchForAnnotations(event.data));
} else if (requestType === 'TAGS') {
port.postMessage(searchForTags(event.data));
- } else if (requestType === 'NOTEBOOK_ANNOTATIONS') {
- port.postMessage(searchForNotebookAnnotations(event.data));
} else {
throw new Error(`Unknown request ${event.data.request}`);
}
@@ -204,33 +202,4 @@
return message;
}
-
- function searchForNotebookAnnotations(data) {
- let results = [];
- const message = {
- request: 'searchForNotebookAnnotations',
- results: {},
- total: 0,
- queryId: data.queryId
- };
-
- const matchingAnnotations = indexedAnnotationsByDomainObject[data.input.targetKeyString];
- if (matchingAnnotations) {
- results = matchingAnnotations.filter(matchingAnnotation => {
- if (!matchingAnnotation.targets) {
- return false;
- }
-
- const target = matchingAnnotation.targets[data.input.targetKeyString];
-
- return (target && target.entryId && (target.entryId === data.input.entryId));
- });
- }
-
- message.total = results.length;
- message.results = results
- .slice(0, data.maxResults);
-
- return message;
- }
}());
diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js
index 64167f3c7..1d0ccfe25 100644
--- a/src/api/objects/ObjectAPI.js
+++ b/src/api/objects/ObjectAPI.js
@@ -64,6 +64,15 @@ import InMemorySearchProvider from './InMemorySearchProvider';
* to load domain objects
* @memberof module:openmct
*/
+
+/**
+ * @readonly
+ * @enum {String} SEARCH_TYPES
+ * @property {String} OBJECTS Search for objects
+ * @property {String} ANNOTATIONS Search for annotations
+ * @property {String} TAGS Search for tags
+*/
+
/**
* Utilities for loading, saving, and manipulating domain objects.
* @interface ObjectAPI
@@ -76,7 +85,6 @@ export default class ObjectAPI {
this.SEARCH_TYPES = Object.freeze({
OBJECTS: 'OBJECTS',
ANNOTATIONS: 'ANNOTATIONS',
- NOTEBOOK_ANNOTATIONS: 'NOTEBOOK_ANNOTATIONS',
TAGS: 'TAGS'
});
this.eventEmitter = new EventEmitter();
@@ -188,7 +196,6 @@ export default class ObjectAPI {
* @returns {Promise} a promise which will resolve when the domain object
* has been saved, or be rejected if it cannot be saved
*/
-
get(identifier, abortSignal) {
let keystring = this.makeKeyString(identifier);
@@ -223,7 +230,7 @@ export default class ObjectAPI {
if (result.isMutable) {
result.$refresh(result);
} else {
- let mutableDomainObject = this._toMutable(result);
+ let mutableDomainObject = this.toMutable(result);
mutableDomainObject.$refresh(result);
}
@@ -300,7 +307,7 @@ export default class ObjectAPI {
}
return this.get(identifier).then((object) => {
- return this._toMutable(object);
+ return this.toMutable(object);
});
}
@@ -490,7 +497,7 @@ export default class ObjectAPI {
} else {
//Creating a temporary mutable domain object allows other mutable instances of the
//object to be kept in sync.
- let mutableDomainObject = this._toMutable(domainObject);
+ let mutableDomainObject = this.toMutable(domainObject);
//Mutate original object
MutableDomainObject.mutateObject(domainObject, path, value);
@@ -510,15 +517,19 @@ export default class ObjectAPI {
}
/**
- * @private
+ * Create a mutable domain object from an existing domain object
+ * @param {module:openmct.DomainObject} domainObject the object to make mutable
+ * @returns {MutableDomainObject} a mutable domain object that will automatically sync
+ * @method toMutable
+ * @memberof module:openmct.ObjectAPI#
*/
- _toMutable(object) {
+ toMutable(domainObject) {
let mutableObject;
- if (object.isMutable) {
- mutableObject = object;
+ if (domainObject.isMutable) {
+ mutableObject = domainObject;
} else {
- mutableObject = MutableDomainObject.createMutable(object, this.eventEmitter);
+ mutableObject = MutableDomainObject.createMutable(domainObject, this.eventEmitter);
// Check if provider supports realtime updates
let identifier = utils.parseKeyString(mutableObject.identifier);
@@ -526,9 +537,11 @@ export default class ObjectAPI {
if (provider !== undefined
&& provider.observe !== undefined
- && this.SYNCHRONIZED_OBJECT_TYPES.includes(object.type)) {
+ && this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) {
let unobserve = provider.observe(identifier, (updatedModel) => {
- if (updatedModel.persisted > mutableObject.modified) {
+ // modified can sometimes be undefined, so make it 0 in this case
+ const mutableObjectModification = mutableObject.modified ?? Number.MIN_SAFE_INTEGER;
+ if (updatedModel.persisted > mutableObjectModification) {
//Don't replace with a stale model. This can happen on slow connections when multiple mutations happen
//in rapid succession and intermediate persistence states are returned by the observe function.
updatedModel = this.applyGetInterceptors(identifier, updatedModel);
@@ -582,7 +595,7 @@ export default class ObjectAPI {
if (domainObject.isMutable) {
return domainObject.$observe(path, callback);
} else {
- let mutable = this._toMutable(domainObject);
+ let mutable = this.toMutable(domainObject);
mutable.$observe(path, callback);
return () => mutable.$destroy();
@@ -675,8 +688,10 @@ export default class ObjectAPI {
}
#hasAlreadyBeenPersisted(domainObject) {
+ // modified can sometimes be undefined, so make it 0 in this case
+ const modified = domainObject.modified ?? Number.MIN_SAFE_INTEGER;
const result = domainObject.persisted !== undefined
- && domainObject.persisted >= domainObject.modified;
+ && domainObject.persisted >= modified;
return result;
}
diff --git a/src/api/objects/ObjectAPISpec.js b/src/api/objects/ObjectAPISpec.js
index e473fc572..950b356be 100644
--- a/src/api/objects/ObjectAPISpec.js
+++ b/src/api/objects/ObjectAPISpec.js
@@ -320,7 +320,7 @@ describe("The Object API", () => {
beforeEach(function () {
// Duplicate object to guarantee we are not sharing object instance, which would invalidate test
testObjectDuplicate = JSON.parse(JSON.stringify(testObject));
- mutableSecondInstance = objectAPI._toMutable(testObjectDuplicate);
+ mutableSecondInstance = objectAPI.toMutable(testObjectDuplicate);
});
afterEach(() => {
diff --git a/src/plugins/notebook/components/Notebook.vue b/src/plugins/notebook/components/Notebook.vue
index 9719de33e..327205a92 100644
--- a/src/plugins/notebook/components/Notebook.vue
+++ b/src/plugins/notebook/components/Notebook.vue
@@ -151,6 +151,7 @@
:key="entry.id"
:entry="entry"
:domain-object="domainObject"
+ :notebook-annotations="notebookAnnotations[entry.id]"
:selected-page="selectedPage"
:selected-section="selectedSection"
:read-only="false"
@@ -219,10 +220,12 @@ export default {
isRestricted: this.domainObject.type === RESTRICTED_NOTEBOOK_TYPE,
search: '',
searchResults: [],
+ lastLocalAnnotationCreation: 0,
showTime: this.domainObject.configuration.showTime || 0,
showNav: false,
sidebarCoversEntries: false,
- filteredAndSortedEntries: []
+ filteredAndSortedEntries: [],
+ notebookAnnotations: {}
};
},
computed: {
@@ -289,7 +292,8 @@ export default {
this.getSearchResults = debounce(this.getSearchResults, 500);
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
},
- mounted() {
+ async mounted() {
+ await this.loadAnnotations();
this.formatSidebar();
this.setSectionAndPageFromUrl();
@@ -307,6 +311,13 @@ export default {
this.unobserveEntries();
}
+ Object.keys(this.notebookAnnotations).forEach(entryID => {
+ const notebookAnnotationsForEntry = this.notebookAnnotations[entryID];
+ notebookAnnotationsForEntry.forEach(notebookAnnotation => {
+ this.openmct.objects.destroyMutable(notebookAnnotation);
+ });
+ });
+
window.removeEventListener('orientationchange', this.formatSidebar);
window.removeEventListener('hashchange', this.setSectionAndPageFromUrl);
},
@@ -338,6 +349,32 @@ export default {
}
});
},
+ async loadAnnotations() {
+ if (!this.openmct.annotation.getAvailableTags().length) {
+ return;
+ }
+
+ this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
+
+ const query = this.openmct.objects.makeKeyString(this.domainObject.identifier);
+ const foundAnnotations = await this.openmct.annotation.getAnnotations(query);
+ foundAnnotations.forEach((foundAnnotation) => {
+ const targetId = Object.keys(foundAnnotation.targets)[0];
+ const entryId = foundAnnotation.targets[targetId].entryId;
+ if (!this.notebookAnnotations[entryId]) {
+ this.$set(this.notebookAnnotations, entryId, []);
+ }
+
+ const annotationExtant = this.notebookAnnotations[entryId].some((existingAnnotation) => {
+ return this.openmct.objects.areIdsEqual(existingAnnotation.identifier, foundAnnotation.identifier);
+ });
+ if (!annotationExtant) {
+ const annotationArray = this.notebookAnnotations[entryId];
+ const mutableAnnotation = this.openmct.objects.toMutable(foundAnnotation);
+ annotationArray.push(mutableAnnotation);
+ }
+ });
+ },
filterAndSortEntries() {
const filterTime = Date.now();
const pageEntries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage) || [];
@@ -350,6 +387,10 @@ export default {
this.filteredAndSortedEntries = this.defaultSort === 'oldest'
? filteredPageEntriesByTime
: [...filteredPageEntriesByTime].reverse();
+
+ if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) {
+ this.loadAnnotations();
+ }
},
changeSelectedSection({ sectionId, pageId }) {
const sections = this.sections.map(s => {
@@ -473,14 +514,8 @@ export default {
]
});
},
- async removeAnnotations(entryId) {
- const targetKeyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
- const query = {
- targetKeyString,
- entryId
- };
- const existingAnnotation = await this.openmct.annotation.getAnnotation(query, this.openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS);
- this.openmct.annotation.removeAnnotationTags(existingAnnotation);
+ removeAnnotations(entryId) {
+ this.openmct.annotation.deleteAnnotations(this.notebookAnnotations[entryId]);
},
checkEntryPos(entry) {
const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage);
diff --git a/src/plugins/notebook/components/NotebookEntry.vue b/src/plugins/notebook/components/NotebookEntry.vue
index 947d1b4ff..58677621d 100644
--- a/src/plugins/notebook/components/NotebookEntry.vue
+++ b/src/plugins/notebook/components/NotebookEntry.vue
@@ -84,9 +84,8 @@
<TagEditor
:domain-object="domainObject"
- :annotation-query="annotationQuery"
+ :annotations="notebookAnnotations"
:annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK"
- :annotation-search-type="openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS"
:target-specific-details="{entryId: entry.id}"
@tags-updated="timestampAndUpdate"
/>
@@ -163,6 +162,12 @@ export default {
return {};
}
},
+ notebookAnnotations: {
+ type: Array,
+ default() {
+ return [];
+ }
+ },
entry: {
type: Object,
default() {
@@ -204,15 +209,6 @@ export default {
createdOnDate() {
return this.formatTime(this.entry.createdOn, 'YYYY-MM-DD');
},
- annotationQuery() {
- const targetKeyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
-
- return {
- targetKeyString,
- entryId: this.entry.id,
- modified: this.entry.modified
- };
- },
createdOnTime() {
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
},
diff --git a/src/plugins/notebook/components/Sidebar.vue b/src/plugins/notebook/components/Sidebar.vue
index c7476d035..5e1022231 100644
--- a/src/plugins/notebook/components/Sidebar.vue
+++ b/src/plugins/notebook/components/Sidebar.vue
@@ -6,6 +6,7 @@
<span class="c-sidebar__header-label">{{ sectionTitle }}</span>
<button
class="c-icon-button c-icon-button--major icon-plus"
+ aria-label="Add Section"
@click="addSection"
>
<span class="c-list-button__label">Add</span>
@@ -33,6 +34,7 @@
<button
class="c-icon-button c-icon-button--major icon-plus"
+ aria-label="Add Page"
@click="addPage"
>
<span class="c-icon-button__label">Add</span>
diff --git a/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js b/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js
index 8222b5eff..dd337c81e 100644
--- a/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js
+++ b/src/plugins/notebook/monkeyPatchObjectAPIForNotebooks.js
@@ -1,22 +1,23 @@
-import { isNotebookType } from './notebook-constants';
+import { isAnnotationType, isNotebookType, isNotebookOrAnnotationType } from './notebook-constants';
+import _ from 'lodash';
export default function (openmct) {
const apiSave = openmct.objects.save.bind(openmct.objects);
openmct.objects.save = async (domainObject) => {
- if (!isNotebookType(domainObject)) {
+ if (!isNotebookOrAnnotationType(domainObject)) {
return apiSave(domainObject);
}
const isNewMutable = !domainObject.isMutable;
- const localMutable = openmct.objects._toMutable(domainObject);
+ const localMutable = openmct.objects.toMutable(domainObject);
let result;
try {
result = await apiSave(localMutable);
} catch (error) {
if (error instanceof openmct.objects.errors.Conflict) {
- result = resolveConflicts(localMutable, openmct);
+ result = await resolveConflicts(domainObject, localMutable, openmct);
} else {
result = Promise.reject(error);
}
@@ -30,16 +31,56 @@ export default function (openmct) {
};
}
-function resolveConflicts(localMutable, openmct) {
- const localEntries = JSON.parse(JSON.stringify(localMutable.configuration.entries));
+function resolveConflicts(domainObject, localMutable, openmct) {
+ if (isNotebookType(domainObject)) {
+ return resolveNotebookEntryConflicts(localMutable, openmct);
+ } else if (isAnnotationType(domainObject)) {
+ return resolveNotebookTagConflicts(localMutable, openmct);
+ }
+}
- return openmct.objects.getMutable(localMutable.identifier).then((remoteMutable) => {
- applyLocalEntries(remoteMutable, localEntries, openmct);
+async function resolveNotebookTagConflicts(localAnnotation, openmct) {
+ const localClonedAnnotation = structuredClone(localAnnotation);
+ const remoteMutable = await openmct.objects.getMutable(localClonedAnnotation.identifier);
- openmct.objects.destroyMutable(remoteMutable);
+ // should only be one annotation per targetID, entryID, and tag; so for sanity, ensure we have the
+ // same targetID, entryID, and tags for this conflict
+ if (!(_.isEqual(remoteMutable.tags, localClonedAnnotation.tags))) {
+ throw new Error('Conflict on annotation\'s tag has different tags than remote');
+ }
+
+ Object.keys(localClonedAnnotation.targets).forEach(targetKey => {
+ if (!remoteMutable.targets[targetKey]) {
+ throw new Error(`Conflict on annotation's target is missing ${targetKey}`);
+ }
+
+ const remoteMutableTarget = remoteMutable.targets[targetKey];
+ const localMutableTarget = localClonedAnnotation.targets[targetKey];
- return true;
+ if (remoteMutableTarget.entryId !== localMutableTarget.entryId) {
+ throw new Error(`Conflict on annotation's entryID ${remoteMutableTarget.entryId} has a different entry Id ${localMutableTarget.entryId}`);
+ }
});
+
+ if (remoteMutable._deleted && (remoteMutable._deleted !== localClonedAnnotation._deleted)) {
+ // not deleting wins 😘
+ openmct.objects.mutate(remoteMutable, '_deleted', false);
+ }
+
+ openmct.objects.destroyMutable(remoteMutable);
+
+ return true;
+}
+
+async function resolveNotebookEntryConflicts(localMutable, openmct) {
+ if (localMutable.configuration.entries) {
+ const localEntries = structuredClone(localMutable.configuration.entries);
+ const remoteMutable = await openmct.objects.getMutable(localMutable.identifier);
+ applyLocalEntries(remoteMutable, localEntries, openmct);
+ openmct.objects.destroyMutable(remoteMutable);
+ }
+
+ return true;
}
function applyLocalEntries(mutable, entries, openmct) {
diff --git a/src/plugins/notebook/notebook-constants.js b/src/plugins/notebook/notebook-constants.js
index 6f2e5af3e..653914651 100644
--- a/src/plugins/notebook/notebook-constants.js
+++ b/src/plugins/notebook/notebook-constants.js
@@ -1,5 +1,6 @@
export const NOTEBOOK_TYPE = 'notebook';
export const RESTRICTED_NOTEBOOK_TYPE = 'restricted-notebook';
+export const ANNOTATION_TYPE = 'annotation';
export const EVENT_SNAPSHOTS_UPDATED = 'SNAPSHOTS_UPDATED';
export const NOTEBOOK_DEFAULT = 'DEFAULT';
export const NOTEBOOK_SNAPSHOT = 'SNAPSHOT';
@@ -9,10 +10,18 @@ export const NOTEBOOK_INSTALLED_KEY = '_NOTEBOOK_PLUGIN_INSTALLED';
export const RESTRICTED_NOTEBOOK_INSTALLED_KEY = '_RESTRICTED_NOTEBOOK_PLUGIN_INSTALLED';
// these only deals with constants, figured this could skip going into a utils file
+export function isNotebookOrAnnotationType(domainObject) {
+ return (isNotebookType(domainObject) || isAnnotationType(domainObject));
+}
+
export function isNotebookType(domainObject) {
return [NOTEBOOK_TYPE, RESTRICTED_NOTEBOOK_TYPE].includes(domainObject.type);
}
+export function isAnnotationType(domainObject) {
+ return [ANNOTATION_TYPE].includes(domainObject.type);
+}
+
export function isNotebookViewType(view) {
return [NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_VIEW_TYPE].includes(view);
}
diff --git a/src/plugins/operatorStatus/AbstractStatusIndicator.js b/src/plugins/operatorStatus/AbstractStatusIndicator.js
index 7d2a01293..a88d2fce7 100644
--- a/src/plugins/operatorStatus/AbstractStatusIndicator.js
+++ b/src/plugins/operatorStatus/AbstractStatusIndicator.js
@@ -27,7 +27,7 @@ export default class AbstractStatusIndicator {
#configuration;
/**
- * @param {*} openmct the Open MCT API (proper jsdoc to come)
+ * @param {*} openmct the Open MCT API (proper typescript doc to come)
* @param {import('@/api/user/UserAPI').UserAPIConfiguration} configuration Per-deployment status styling. See the type definition in UserAPI
*/
constructor(openmct, configuration) {
diff --git a/src/plugins/persistence/couch/CouchObjectProvider.js b/src/plugins/persistence/couch/CouchObjectProvider.js
index 8c3fa5fa1..e43dfefb0 100644
--- a/src/plugins/persistence/couch/CouchObjectProvider.js
+++ b/src/plugins/persistence/couch/CouchObjectProvider.js
@@ -23,7 +23,7 @@
import CouchDocument from "./CouchDocument";
import CouchObjectQueue from "./CouchObjectQueue";
import { PENDING, CONNECTED, DISCONNECTED, UNKNOWN } from "./CouchStatusIndicator";
-import { isNotebookType } from '../../notebook/notebook-constants.js';
+import { isNotebookOrAnnotationType } from '../../notebook/notebook-constants.js';
const REV = "_rev";
const ID = "_id";
@@ -71,7 +71,7 @@ class CouchObjectProvider {
}
onSharedWorkerMessageError(event) {
- console.log('Error', event);
+ console.error('Error', event);
}
isSynchronizedObject(object) {
@@ -290,7 +290,7 @@ class CouchObjectProvider {
this.objectQueue[key] = new CouchObjectQueue(undefined, response[REV]);
}
- if (isNotebookType(object) || object.type === 'annotation') {
+ if (isNotebookOrAnnotationType(object)) {
//Temporary measure until object sync is supported for all object types
//Always update notebook revision number because we have realtime sync, so always assume it's the latest.
this.objectQueue[key].updateRevision(response[REV]);
diff --git a/src/plugins/persistence/couch/CouchSearchProvider.js b/src/plugins/persistence/couch/CouchSearchProvider.js
index 3b2295e99..5d9ef5e4f 100644
--- a/src/plugins/persistence/couch/CouchSearchProvider.js
+++ b/src/plugins/persistence/couch/CouchSearchProvider.js
@@ -31,7 +31,7 @@ class CouchSearchProvider {
constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
- this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.NOTEBOOK_ANNOTATIONS, this.searchTypes.TAGS];
+ this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.TAGS];
}
supportsSearchType(searchType) {
@@ -43,8 +43,6 @@ class CouchSearchProvider {
return this.searchForObjects(query, abortSignal);
} else if (searchType === this.searchTypes.ANNOTATIONS) {
return this.searchForAnnotations(query, abortSignal);
- } else if (searchType === this.searchTypes.NOTEBOOK_ANNOTATIONS) {
- return this.searchForNotebookAnnotations(query, abortSignal);
} else if (searchType === this.searchTypes.TAGS) {
return this.searchForTags(query, abortSignal);
} else {
@@ -91,38 +89,6 @@ class CouchSearchProvider {
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
}
- searchForNotebookAnnotations({targetKeyString, entryId}, abortSignal) {
- const filter = {
- "selector": {
- "$and": [
- {
- "model.type": {
- "$eq": "annotation"
- }
- },
- {
- "model.annotationType": {
- "$eq": "NOTEBOOK"
- }
- },
- {
- "model": {
- "targets": {
- }
- }
- }
- ]
- }
- };
- filter.selector.$and[2].model.targets[targetKeyString] = {
- "entryId": {
- "$eq": entryId
- }
- };
-
- return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
- }
-
searchForTags(tagsArray, abortSignal) {
const filter = {
"selector": {
@@ -130,7 +96,8 @@ class CouchSearchProvider {
{
"model.tags": {
"$elemMatch": {
- "$eq": `${tagsArray[0]}`
+ "$or": [
+ ]
}
}
},
@@ -142,6 +109,11 @@ class CouchSearchProvider {
]
}
};
+ tagsArray.forEach(tag => {
+ filter.selector.$and[0]["model.tags"].$elemMatch.$or.push({
+ "$eq": `${tag}`
+ });
+ });
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
}
diff --git a/src/selection/Selection.js b/src/selection/Selection.js
index 30e1f8d3c..d9afa03d5 100644
--- a/src/selection/Selection.js
+++ b/src/selection/Selection.js
@@ -244,7 +244,7 @@ define(
if (context.item && context.item.isMutable !== true) {
removeMutable = true;
- context.item = this.openmct.objects._toMutable(context.item);
+ context.item = this.openmct.objects.toMutable(context.item);
}
if (select) {
diff --git a/src/ui/components/tags/TagEditor.vue b/src/ui/components/tags/TagEditor.vue
index 2a4068009..e1353d834 100644
--- a/src/ui/components/tags/TagEditor.vue
+++ b/src/ui/components/tags/TagEditor.vue
@@ -51,18 +51,14 @@ export default {
},
inject: ['openmct'],
props: {
- annotationQuery: {
- type: Object,
+ annotations: {
+ type: Array,
required: true
},
annotationType: {
type: String,
required: true
},
- annotationSearchType: {
- type: String,
- required: true
- },
targetSpecificDetails: {
type: Object,
required: true
@@ -76,7 +72,6 @@ export default {
},
data() {
return {
- annontation: null,
addedTags: [],
userAddingTag: false
};
@@ -92,57 +87,50 @@ export default {
}
},
watch: {
- annotation: {
- handler() {
- this.tagsChanged(this.annotation.tags);
- },
- deep: true
- },
- annotationQuery: {
+ annotations: {
handler() {
- this.unloadAnnotation();
- this.loadAnnotation();
+ this.annotationsChanged();
},
deep: true
}
},
mounted() {
- this.loadAnnotation();
- },
- destroyed() {
- if (this.removeTagsListener) {
- this.removeTagsListener();
- }
+ this.annotationsChanged();
},
methods: {
- addAnnotationListener(annotation) {
- if (annotation && !this.removeTagsListener) {
- this.removeTagsListener = this.openmct.objects.observe(annotation, '*', (newAnnotation) => {
- this.tagsChanged(newAnnotation.tags);
- this.annotation = newAnnotation;
- });
- }
- },
- async loadAnnotation() {
- this.annotation = await this.openmct.annotation.getAnnotation(this.annotationQuery, this.annotationSearchType);
- this.addAnnotationListener(this.annotation);
- if (this.annotation && this.annotation.tags) {
- this.tagsChanged(this.annotation.tags);
+ annotationsChanged() {
+ if (this.annotations && this.annotations.length) {
+ this.tagsChanged();
}
},
- unloadAnnotation() {
- if (this.removeTagsListener) {
- this.removeTagsListener();
- this.removeTagsListener = undefined;
+ annotationDeletionListener(changedAnnotation) {
+ const matchingAnnotation = this.annotations.find((possibleMatchingAnnotation) => {
+ return this.openmct.objects.areIdsEqual(possibleMatchingAnnotation.identifier, changedAnnotation.identifier);
+ });
+ if (matchingAnnotation) {
+ matchingAnnotation._deleted = changedAnnotation._deleted;
+ this.userAddingTag = false;
+ this.tagsChanged();
}
},
- tagsChanged(newTags) {
- if (newTags.length < this.addedTags.length) {
- this.addedTags = this.addedTags.slice(0, newTags.length);
+ tagsChanged() {
+ // gather tags from annotations
+ const tagsFromAnnotations = this.annotations.flatMap((annotation) => {
+ if (annotation._deleted) {
+ return [];
+ } else {
+ return annotation.tags;
+ }
+ }).filter((tag, index, array) => {
+ return array.indexOf(tag) === index;
+ });
+
+ if (tagsFromAnnotations.length !== this.addedTags.length) {
+ this.addedTags = this.addedTags.slice(0, tagsFromAnnotations.length);
}
- for (let index = 0; index < newTags.length; index += 1) {
- this.$set(this.addedTags, index, newTags[index]);
+ for (let index = 0; index < tagsFromAnnotations.length; index += 1) {
+ this.$set(this.addedTags, index, tagsFromAnnotations[index]);
}
},
addTag() {
@@ -153,23 +141,27 @@ export default {
this.userAddingTag = true;
},
async tagRemoved(tagToRemove) {
- const result = await this.openmct.annotation.removeAnnotationTag(this.annotation, tagToRemove);
- this.$emit('tags-updated');
+ // Soft delete annotations that match tag instead
+ const annotationsToDelete = this.annotations.filter((annotation) => {
+ return annotation.tags.includes(tagToRemove);
+ });
+ const result = await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
+ this.$emit('tags-updated', annotationsToDelete);
return result;
},
async tagAdded(newTag) {
- const annotationWasCreated = this.annotation === null || this.annotation === undefined;
- this.annotation = await this.openmct.annotation.addAnnotationTag(this.annotation,
+ // Either undelete an annotation, or create one (1) new annotation
+ const existingAnnotation = this.annotations.find((annotation) => {
+ return annotation.tags.includes(newTag);
+ });
+
+ const createdAnnotation = await this.openmct.annotation.addSingleAnnotationTag(existingAnnotation,
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag);
- if (annotationWasCreated) {
- this.addAnnotationListener(this.annotation);
- }
- this.tagsChanged(this.annotation.tags);
this.userAddingTag = false;
- this.$emit('tags-updated');
+ this.$emit('tags-updated', createdAnnotation);
}
}
};
diff --git a/src/ui/components/tags/TagSelection.vue b/src/ui/components/tags/TagSelection.vue
index 3163ae045..a1db2767e 100644
--- a/src/ui/components/tags/TagSelection.vue
+++ b/src/ui/components/tags/TagSelection.vue
@@ -37,7 +37,10 @@
class="c-tag"
:style="{ background: selectedBackgroundColor, color: selectedForegroundColor }"
>
- <div class="c-tag__label">{{ selectedTagLabel }} </div>
+ <div
+ class="c-tag__label"
+ aria-label="Tag"
+ >{{ selectedTagLabel }} </div>
<button
class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle"
@click="removeTag"
diff --git a/webpack.dev.js b/webpack.dev.js
index d2459c9ce..b8e580f77 100644
--- a/webpack.dev.js
+++ b/webpack.dev.js
@@ -19,7 +19,7 @@ module.exports = merge(common, {
// See: https://webpack.js.org/configuration/watch/#watchoptions-exclude
ignored: [
'**/{node_modules,dist,docs,e2e}', // All files in node_modules, dist, docs, e2e,
- '**/{*.yml,Procfile,webpack*.js,babel*.js,package*.json,tsconfig.json,jsdoc.json}', // Config files
+ '**/{*.yml,Procfile,webpack*.js,babel*.js,package*.json,tsconfig.json}', // Config files
'**/*.{sh,md,png,ttf,woff,svg}', // Non source files
'**/.*' // dotfiles and dotfolders
]