diff options
author | Scott Bell <scott@traclabs.com> | 2022-09-30 20:32:11 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-09-30 20:32:11 +0300 |
commit | ce463babfff0f800d4eac6bbbb1ea9d838d0dce9 (patch) | |
tree | e641c223f3d3f0cf437ddaa571b30f528fcedc01 | |
parent | 27c30132d23421de79a271b737f98c53f59bd065 (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>
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 ] |