From 3c70cf1767a35a6c72c939611e986d6b1f20db8f Mon Sep 17 00:00:00 2001 From: Scott Bell Date: Fri, 3 Jun 2022 22:12:42 +0200 Subject: [PATCH] Search & Notebook Tagging - Mct4820 (#5203) * implement new search and tagging for notebooks * add example tags, remove inspector reference * include annotations in mct * fix performance tests Co-authored-by: John Hill Co-authored-by: unlikelyzero Co-authored-by: Andrew Henry --- e2e/playwright-performance.config.js | 6 +- e2e/tests/performance/imagery.perf.spec.js | 4 +- .../performance/memleak-imagery.perf.spec.js | 4 +- e2e/tests/performance/notebook.perf.spec.js | 4 +- e2e/tests/plugins/clock/Clock.e2e.spec.js | 10 +- .../plugins/condition/condition.e2e.spec.js | 92 +- example/exampleTags/plugin.js | 33 + example/exampleTags/tags.json | 19 + index.html | 1 + src/MCT.js | 9 + src/api/annotation/AnnotationAPI.js | 275 ++++ src/api/annotation/AnnotationAPISpec.js | 176 +++ src/api/api.js | 9 +- src/api/forms/components/FormProperties.vue | 16 +- src/api/forms/components/FormRow.vue | 19 +- .../components/controls/AutoCompleteField.vue | 100 +- src/api/objects/InMemorySearchProvider.js | 297 ++++- src/api/objects/InMemorySearchWorker.js | 175 ++- src/api/objects/ObjectAPI.js | 1150 +++++++++-------- src/api/objects/ObjectAPISearchSpec.js | 24 +- src/plugins/clock/plugin.js | 1 + src/plugins/displayLayout/pluginSpec.js | 29 + .../LocalStorageObjectProvider.js | 4 + src/plugins/notebook/components/Notebook.vue | 67 +- .../notebook/components/NotebookEntry.vue | 21 +- src/plugins/notebook/pluginSpec.js | 23 +- .../notebook/utils/notebook-entries.js | 9 +- .../persistence/couch/CouchSearchProvider.js | 103 +- src/plugins/persistence/couch/README.md | 56 +- src/plugins/plugins.js | 7 +- src/plugins/timeConductor/date-picker.scss | 3 +- src/styles/_constants-espresso.scss | 4 +- src/styles/_constants-maelstrom.scss | 4 +- src/styles/_constants-snow.scss | 4 +- src/styles/_controls.scss | 148 ++- src/styles/_forms.scss | 33 - src/styles/_global.scss | 2 +- src/styles/_legacy.scss | 5 + src/styles/_mixins.scss | 6 + src/styles/notebook.scss | 6 +- src/styles/vue-styles.scss | 3 +- src/ui/components/ObjectLabel.vue | 7 + src/ui/components/ObjectPath.vue | 139 ++ src/ui/components/tags/TagEditor.vue | 155 +++ src/ui/components/tags/TagSelection.vue | 152 +++ src/ui/components/tags/tags.scss | 67 + src/ui/layout/Layout.vue | 5 + src/ui/layout/MCTSearch.vue | 13 - src/ui/layout/mct-search.scss | 10 - src/ui/layout/mct-tree.vue | 1 + .../layout/search/AnnotationSearchResult.vue | 148 +++ src/ui/layout/search/GrandSearch.vue | 145 +++ src/ui/layout/search/ObjectSearchResult.vue | 102 ++ .../layout/search/SearchResultsDropDown.vue | 99 ++ src/ui/layout/search/search.scss | 137 ++ src/ui/mixins/context-menu-gesture.js | 4 + src/ui/router/Browse.js | 4 +- 57 files changed, 3220 insertions(+), 929 deletions(-) create mode 100644 example/exampleTags/plugin.js create mode 100644 example/exampleTags/tags.json create mode 100644 src/api/annotation/AnnotationAPI.js create mode 100644 src/api/annotation/AnnotationAPISpec.js create mode 100644 src/ui/components/ObjectPath.vue create mode 100644 src/ui/components/tags/TagEditor.vue create mode 100644 src/ui/components/tags/TagSelection.vue create mode 100644 src/ui/components/tags/tags.scss delete mode 100644 src/ui/layout/MCTSearch.vue delete mode 100644 src/ui/layout/mct-search.scss create mode 100644 src/ui/layout/search/AnnotationSearchResult.vue create mode 100644 src/ui/layout/search/GrandSearch.vue create mode 100644 src/ui/layout/search/ObjectSearchResult.vue create mode 100644 src/ui/layout/search/SearchResultsDropDown.vue create mode 100644 src/ui/layout/search/search.scss diff --git a/e2e/playwright-performance.config.js b/e2e/playwright-performance.config.js index a718f1df03..1d3b849ac4 100644 --- a/e2e/playwright-performance.config.js +++ b/e2e/playwright-performance.config.js @@ -4,9 +4,9 @@ /** @type {import('@playwright/test').PlaywrightTestConfig} */ const config = { - retries: 0, + retries: 1, //Only for debugging purposes testDir: 'tests/performance/', - timeout: 30 * 1000, + timeout: 60 * 1000, workers: 1, //Only run in serial with 1 worker webServer: { command: 'npm run start', @@ -20,7 +20,7 @@ const config = { headless: Boolean(process.env.CI), //Only if running locally ignoreHTTPSErrors: true, screenshot: 'off', - trace: 'off', + trace: 'on-first-retry', video: 'off' }, projects: [ diff --git a/e2e/tests/performance/imagery.perf.spec.js b/e2e/tests/performance/imagery.perf.spec.js index 0e75e52af1..e7033ef10d 100644 --- a/e2e/tests/performance/imagery.perf.spec.js +++ b/e2e/tests/performance/imagery.perf.spec.js @@ -103,10 +103,10 @@ test.describe('Performance tests', () => { await page.goto('/'); // Search Available after Launch - await page.locator('input[type="search"]').click(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); await page.evaluate(() => window.performance.mark("search-available")); // Fill Search input - await page.locator('input[type="search"]').fill('Performance Display Layout'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Performance Display Layout'); await page.evaluate(() => window.performance.mark("search-entered")); //Search Result Appears and is clicked await Promise.all([ diff --git a/e2e/tests/performance/memleak-imagery.perf.spec.js b/e2e/tests/performance/memleak-imagery.perf.spec.js index a611a7c6a1..6ce14f5533 100644 --- a/e2e/tests/performance/memleak-imagery.perf.spec.js +++ b/e2e/tests/performance/memleak-imagery.perf.spec.js @@ -64,9 +64,9 @@ test.describe.skip('Memory Performance tests', () => { await page.goto('/', {waitUntil: 'networkidle'}); // To to Search Available after Launch - await page.locator('input[type="search"]').click(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); // Fill Search input - await page.locator('input[type="search"]').fill('Performance Display Layout'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Performance Display Layout'); //Search Result Appears and is clicked await Promise.all([ page.waitForNavigation(), diff --git a/e2e/tests/performance/notebook.perf.spec.js b/e2e/tests/performance/notebook.perf.spec.js index 14c74bd32d..1c10ad6ba5 100644 --- a/e2e/tests/performance/notebook.perf.spec.js +++ b/e2e/tests/performance/notebook.perf.spec.js @@ -98,10 +98,10 @@ test.describe('Performance tests', () => { await page.goto('/'); // To to Search Available after Launch - await page.locator('input[type="search"]').click(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); await page.evaluate(() => window.performance.mark("search-available")); // Fill Search input - await page.locator('input[type="search"]').fill('Performance Notebook'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Performance Notebook'); await page.evaluate(() => window.performance.mark("search-entered")); //Search Result Appears and is clicked await Promise.all([ diff --git a/e2e/tests/plugins/clock/Clock.e2e.spec.js b/e2e/tests/plugins/clock/Clock.e2e.spec.js index bdf8843bc2..645770a55d 100644 --- a/e2e/tests/plugins/clock/Clock.e2e.spec.js +++ b/e2e/tests/plugins/clock/Clock.e2e.spec.js @@ -46,22 +46,22 @@ test.describe('Clock Generator', () => { // Click .icon-arrow-down await page.locator('.icon-arrow-down').click(); //verify if the autocomplete dropdown is visible - await expect(page.locator(".optionPreSelected")).toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).toBeVisible(); // Click .icon-arrow-down await page.locator('.icon-arrow-down').click(); // Verify clicking on the autocomplete arrow collapses the dropdown - await expect(page.locator(".optionPreSelected")).not.toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).not.toBeVisible(); // Click timezone input to open dropdown - await page.locator('.autocompleteInput').click(); + await page.locator('.c-input--autocomplete__input').click(); //verify if the autocomplete dropdown is visible - await expect(page.locator(".optionPreSelected")).toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).toBeVisible(); // Verify clicking outside the autocomplete dropdown collapses it await page.locator('text=Timezone').click(); // Verify clicking on the autocomplete arrow collapses the dropdown - await expect(page.locator(".optionPreSelected")).not.toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).not.toBeVisible(); }); }); diff --git a/e2e/tests/plugins/condition/condition.e2e.spec.js b/e2e/tests/plugins/condition/condition.e2e.spec.js index 68f31eed01..a2ace18f75 100644 --- a/e2e/tests/plugins/condition/condition.e2e.spec.js +++ b/e2e/tests/plugins/condition/condition.e2e.spec.js @@ -32,42 +32,40 @@ const { expect } = require('@playwright/test'); let conditionSetUrl; let getConditionSetIdentifierFromUrl; -test('Create new Condition Set object and store @localStorage', async ({ page, context }) => { - //Go to baseURL - await page.goto('/', { waitUntil: 'networkidle' }); - - //Click the Create button - await page.click('button:has-text("Create")'); - - // Click text=Condition Set - await page.click('text=Condition Set'); - - // Click on My Items in Tree. Workaround for https://github.com/nasa/openmct/issues/5184 - await page.click('form[name="mctForm"] a:has-text("My Items")'); - - // Click text=OK - await Promise.all([ - page.waitForNavigation(), - page.click('text=OK') - ]); - - await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set'); - //Save localStorage for future test execution - await context.storageState({ path: './e2e/tests/recycled_storage.json' }); - - //Set object identifier from url - conditionSetUrl = await page.url(); - console.log('conditionSetUrl ' + conditionSetUrl); - - getConditionSetIdentifierFromUrl = await conditionSetUrl.split('/').pop().split('?')[0]; - console.log('getConditionSetIdentifierFromUrl ' + getConditionSetIdentifierFromUrl); - -}); - test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + //Go to baseURL + await page.goto('/', { waitUntil: 'networkidle' }); + + //Click the Create button + await page.click('button:has-text("Create")'); + + // Click text=Condition Set + await page.locator('li:has-text("Condition Set")').click(); + + // Click text=OK + await Promise.all([ + page.waitForNavigation(), + page.click('text=OK') + ]); + + //Save localStorage for future test execution + await context.storageState({ path: './e2e/tests/recycled_storage.json' }); + + //Set object identifier from url + conditionSetUrl = await page.url(); + console.log('conditionSetUrl ' + conditionSetUrl); + + getConditionSetIdentifierFromUrl = await conditionSetUrl.split('/').pop().split('?')[0]; + console.debug('getConditionSetIdentifierFromUrl ' + getConditionSetIdentifierFromUrl); + }); + test.afterAll(async ({ browser }) => { + await browser.close(); + }); //Load localStorage for subsequent tests test.use({ storageState: './e2e/tests/recycled_storage.json' }); - //Begin suite of tests again localStorage test('Condition set object properties persist in main view and inspector', async ({ page }) => { //Navigate to baseURL with injected localStorage @@ -124,7 +122,7 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { // Verify Condition Set Object is renamed in Tree await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); // Verify Search Tree reflects renamed Name property - await page.locator('input[type="search"]').fill('Renamed'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Renamed'); await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); //Reload Page @@ -148,35 +146,31 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { // Verify Condition Set Object is renamed in Tree await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); // Verify Search Tree reflects renamed Name property - await page.locator('input[type="search"]').fill('Renamed'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Renamed'); await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); }); test('condition set object can be deleted by Search Tree Actions menu on @localStorage', async ({ page }) => { //Navigate to baseURL await page.goto('/', { waitUntil: 'networkidle' }); + const numberOfConditionSetsToStart = await page.locator('a:has-text("Unnamed Condition Set Condition Set")').count(); //Expect Unnamed Condition Set to be visible in Main View - await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set")')).toBeVisible(); + await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set") >> nth=0')).toBeVisible(); // Search for Unnamed Condition Set - await page.locator('input[type="search"]').fill('Unnamed Condition Set'); - // Right Click to Open Actions Menu - await page.locator('a:has-text("Unnamed Condition Set")').click({ - button: 'right' - }); - // Click Remove Action + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Unnamed Condition Set'); + // Click Search Result + await page.locator('[aria-label="OpenMCT Search"] >> text=Unnamed Condition Set').first().click(); + // Click hamburger button + await page.locator('[title="More options"]').click(); + // Click text=Remove await page.locator('text=Remove').click(); await page.locator('text=OK').click(); //Expect Unnamed Condition Set to be removed in Main View - await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set")')).not.toBeVisible(); - - await page.locator('.c-search__clear-input').click(); - // Search for Unnamed Condition Set - await page.locator('input[type="search"]').fill('Unnamed Condition Set'); - // Expect Unnamed Condition Set to be removed - await expect(page.locator('a:has-text("Unnamed Condition Set")')).not.toBeVisible(); + const numberOfConditionSetsAtEnd = await page.locator('a:has-text("Unnamed Condition Set Condition Set")').count(); + expect(numberOfConditionSetsAtEnd).toEqual(numberOfConditionSetsToStart - 1); //Feature? //Domain Object is still available by direct URL after delete diff --git a/example/exampleTags/plugin.js b/example/exampleTags/plugin.js new file mode 100644 index 0000000000..b78ad89eb1 --- /dev/null +++ b/example/exampleTags/plugin.js @@ -0,0 +1,33 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ +import availableTags from './tags.json'; +/** + * @returns {function} The plugin install function + */ +export default function exampleTagsPlugin() { + return function install(openmct) { + Object.keys(availableTags.tags).forEach(tagKey => { + const tagDefinition = availableTags.tags[tagKey]; + openmct.annotation.defineTag(tagKey, tagDefinition); + }); + }; +} diff --git a/example/exampleTags/tags.json b/example/exampleTags/tags.json new file mode 100644 index 0000000000..31a1b823a9 --- /dev/null +++ b/example/exampleTags/tags.json @@ -0,0 +1,19 @@ +{ + "tags": { + "46a62ad1-bb86-4f88-9a17-2a029e12669d": { + "label": "Science", + "backgroundColor": "#cc0000", + "foregroundColor": "#ffffff" + }, + "65f150ef-73b7-409a-b2e8-258cbd8b7323": { + "label": "Driving", + "backgroundColor": "#ffad32", + "foregroundColor": "#333333" + }, + "f156b038-c605-46db-88a6-67cf2489a371": { + "label": "Drilling", + "backgroundColor": "#b0ac4e", + "foregroundColor": "#FFFFFF" + } + } +} diff --git a/index.html b/index.html index 4cfcacb001..9afe2acb34 100644 --- a/index.html +++ b/index.html @@ -81,6 +81,7 @@ openmct.install(openmct.plugins.example.Generator()); openmct.install(openmct.plugins.example.EventGeneratorPlugin()); openmct.install(openmct.plugins.example.ExampleImagery()); + openmct.install(openmct.plugins.example.ExampleTags()); openmct.install(openmct.plugins.Espresso()); openmct.install(openmct.plugins.MyItems()); diff --git a/src/MCT.js b/src/MCT.js index ad01016a6b..1330201488 100644 --- a/src/MCT.js +++ b/src/MCT.js @@ -242,6 +242,15 @@ define([ this.branding = BrandingAPI.default; + /** + * MCT's annotation API that enables + * human-created comments and categorization linked to data products + * @type {module:openmct.AnnotationAPI} + * @memberof module:openmct.MCT# + * @name annotation + */ + this.annotation = new api.AnnotationAPI(this); + // Plugins that are installed by default this.install(this.plugins.Plot()); this.install(this.plugins.TelemetryTable.default()); diff --git a/src/api/annotation/AnnotationAPI.js b/src/api/annotation/AnnotationAPI.js new file mode 100644 index 0000000000..a856114957 --- /dev/null +++ b/src/api/annotation/AnnotationAPI.js @@ -0,0 +1,275 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +import { v4 as uuid } from 'uuid'; +import EventEmitter from 'EventEmitter'; + +/** + * @readonly + * @enum {String} AnnotationType + * @property {String} NOTEBOOK The notebook annotation type + * @property {String} GEOSPATIAL The geospatial annotation type + * @property {String} PIXEL_SPATIAL The pixel-spatial annotation type + * @property {String} TEMPORAL The temporal annotation type + * @property {String} PLOT_SPATIAL The plot-spatial annotation type + */ +const ANNOTATION_TYPES = Object.freeze({ + NOTEBOOK: 'NOTEBOOK', + GEOSPATIAL: 'GEOSPATIAL', + PIXEL_SPATIAL: 'PIXEL_SPATIAL', + TEMPORAL: 'TEMPORAL', + PLOT_SPATIAL: 'PLOT_SPATIAL' +}); + +/** + * @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 { + constructor(openmct) { + super(); + this.openmct = openmct; + this.availableTags = {}; + + this.ANNOTATION_TYPES = ANNOTATION_TYPES; + + this.openmct.types.addType('annotation', { + name: 'Annotation', + description: 'A user created note or comment about time ranges, pixel space, and geospatial features.', + creatable: false, + cssClass: 'icon-notebook', + initialize: function (domainObject) { + domainObject.targets = domainObject.targets || {}; + domainObject.originalContextPath = domainObject.originalContextPath || ''; + domainObject.tags = domainObject.tags || []; + domainObject.contentText = domainObject.contentText || ''; + domainObject.annotationType = domainObject.annotationType || 'plotspatial'; + } + }); + } + + /** + * Create the a generic annotation + * @typedef {Object} CreateAnnotationOptions + * @property {String} name a name for the new parameter + * @property {import('../objects/ObjectAPI').DomainObject} domainObject the domain object to create + * @property {ANNOTATION_TYPES} annotationType the type of annotation to create + * @property {Tag[]} tags + * @property {String} contentText + * @property {import('../objects/ObjectAPI').Identifier[]} targets + */ + /** + * @method create + * @param {CreateAnnotationOptions} options + * @returns {Promise} a promise which will resolve when the domain object + * has been created, or be rejected if it cannot be saved + */ + async create({name, domainObject, annotationType, tags, contentText, targets}) { + if (!Object.keys(this.ANNOTATION_TYPES).includes(annotationType)) { + throw new Error(`Unknown annotation type: ${annotationType}`); + } + + if (!Object.keys(targets).length) { + throw new Error(`At least one target is required to create an annotation`); + } + + const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier); + const originalPathObjects = await this.openmct.objects.getOriginalPath(domainObjectKeyString); + const originalContextPath = this.openmct.objects.getRelativePath(originalPathObjects); + const namespace = domainObject.identifier.namespace; + const type = 'annotation'; + const typeDefinition = this.openmct.types.get(type); + const definition = typeDefinition.definition; + + const createdObject = { + name, + type, + identifier: { + key: uuid(), + namespace + }, + tags, + annotationType, + contentText, + originalContextPath + }; + + if (definition.initialize) { + definition.initialize(createdObject); + } + + createdObject.targets = targets; + createdObject.originalContextPath = originalContextPath; + + const success = await this.openmct.objects.save(createdObject); + if (success) { + this.emit('annotationCreated', createdObject); + + return createdObject; + } else { + throw new Error('Failed to create object'); + } + } + + defineTag(tagKey, tagsDefinition) { + this.availableTags[tagKey] = tagsDefinition; + } + + getAvailableTags() { + if (this.availableTags) { + const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => { + return { + id: tagKey, + ...this.availableTags[tagKey] + }; + }); + + return rearrangedToArray; + } else { + return []; + } + } + + 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]; + } + + return foundAnnotation; + } + + async addAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) { + if (!existingAnnotation) { + const targets = {}; + const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier); + targets[targetKeyString] = targetSpecificDetails; + const contentText = `${annotationType} tag`; + const annotationCreationArguments = { + name: contentText, + domainObject: targetDomainObject, + annotationType, + tags: [], + contentText, + targets + }; + existingAnnotation = await this.create(annotationCreationArguments); + } + + const tagArray = [tag, ...existingAnnotation.tags]; + this.openmct.objects.mutate(existingAnnotation, 'tags', tagArray); + + 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); + } + } + + 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', []); + } + } + + #getMatchingTags(query) { + if (!query) { + return []; + } + + const matchingTags = Object.keys(this.availableTags).filter(tagKey => { + if (this.availableTags[tagKey] && this.availableTags[tagKey].label) { + return this.availableTags[tagKey].label.toLowerCase().includes(query.toLowerCase()); + } + + return false; + }); + + return matchingTags; + } + + #addTagMetaInformationToResults(results, matchingTagKeys) { + const tagsAddedToResults = results.map(result => { + const fullTagModels = result.tags.map(tagKey => { + const tagModel = this.availableTags[tagKey]; + tagModel.tagID = tagKey; + + return tagModel; + }); + + return { + fullTagModels, + matchingTagKeys, + ...result + }; + }); + + return tagsAddedToResults; + } + + async #addTargetModelsToResults(results) { + const modelAddedToResults = await Promise.all(results.map(async result => { + const targetModels = await Promise.all(Object.keys(result.targets).map(async (targetID) => { + const targetModel = await this.openmct.objects.get(targetID); + const targetKeyString = this.openmct.objects.makeKeyString(targetModel.identifier); + const originalPathObjects = await this.openmct.objects.getOriginalPath(targetKeyString); + + return { + originalPath: originalPathObjects, + ...targetModel + }; + })); + + return { + targetModels, + ...result + }; + })); + + return modelAddedToResults; + } + + /** + * @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 + * @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 appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults); + + return appliedTargetsModels; + } +} diff --git a/src/api/annotation/AnnotationAPISpec.js b/src/api/annotation/AnnotationAPISpec.js new file mode 100644 index 0000000000..731aead3cc --- /dev/null +++ b/src/api/annotation/AnnotationAPISpec.js @@ -0,0 +1,176 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +import { createOpenMct, resetApplicationState } from '../../utils/testing'; +import ExampleTagsPlugin from "../../../example/exampleTags/plugin"; + +describe("The Annotation API", () => { + let openmct; + let mockObjectProvider; + let mockDomainObject; + let mockAnnotationObject; + + beforeEach((done) => { + openmct = createOpenMct(); + openmct.install(new ExampleTagsPlugin()); + const availableTags = openmct.annotation.getAvailableTags(); + mockDomainObject = { + type: 'notebook', + name: 'fooRabbitNotebook', + identifier: { + key: 'some-object', + namespace: 'fooNameSpace' + } + }; + mockAnnotationObject = { + type: 'annotation', + name: 'Some Notebook Annotation', + annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, + tags: [availableTags[0].id, availableTags[1].id], + identifier: { + key: 'anAnnotationKey', + namespace: 'fooNameSpace' + }, + targets: { + 'fooNameSpace:some-object': { + entryId: 'fooBarEntry' + } + } + }; + + mockObjectProvider = jasmine.createSpyObj("mock provider", [ + "create", + "update", + "get" + ]); + // eslint-disable-next-line require-await + mockObjectProvider.get = async (identifier) => { + if (identifier.key === mockDomainObject.identifier.key) { + return mockDomainObject; + } else if (identifier.key === mockAnnotationObject.identifier.key) { + return mockAnnotationObject; + } else { + return null; + } + }; + + mockObjectProvider.create.and.returnValue(Promise.resolve(true)); + mockObjectProvider.update.and.returnValue(Promise.resolve(true)); + + openmct.objects.addProvider('fooNameSpace', mockObjectProvider); + openmct.on('start', done); + openmct.startHeadless(); + }); + afterEach(async () => { + openmct.objects.providers = {}; + await resetApplicationState(openmct); + }); + it("is defined", () => { + expect(openmct.annotation).toBeDefined(); + }); + + describe("Creation", () => { + it("can create annotations", async () => { + const annotationCreationArguments = { + name: 'Test Annotation', + domainObject: mockDomainObject, + annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, + tags: ['sometag'], + contentText: "fooContext", + targets: {'fooTarget': {}} + }; + const annotationObject = await openmct.annotation.create(annotationCreationArguments); + expect(annotationObject).toBeDefined(); + expect(annotationObject.type).toEqual('annotation'); + }); + it("fails if annotation is an unknown type", async () => { + try { + await openmct.annotation.create('Garbage Annotation', mockDomainObject, 'garbageAnnotation', ['sometag'], "fooContext", {'fooTarget': {}}); + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("Tagging", () => { + it("can create a tag", async () => { + const annotationObject = await openmct.annotation.addAnnotationTag(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'); + expect(annotationObject).toBeDefined(); + openmct.annotation.removeAnnotationTag(annotationObject, 'anotherTagToRemove'); + expect(annotationObject.tags).toEqual(['aWonderfulTag']); + openmct.annotation.removeAnnotationTag(annotationObject, 'aWonderfulTag'); + expect(annotationObject.tags).toEqual([]); + }); + 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'); + 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'); + expect(annotationObject).toBeDefined(); + expect(() => { + openmct.annotation.removeAnnotationTags(annotationObject); + }).not.toThrow(); + expect(annotationObject.tags).toEqual([]); + }); + }); + + describe("Search", () => { + let sharedWorkerToRestore; + beforeEach(async () => { + // use local worker + sharedWorkerToRestore = openmct.objects.inMemorySearchProvider.worker; + openmct.objects.inMemorySearchProvider.worker = null; + await openmct.objects.inMemorySearchProvider.index(mockDomainObject); + await openmct.objects.inMemorySearchProvider.index(mockAnnotationObject); + }); + afterEach(() => { + openmct.objects.inMemorySearchProvider.worker = sharedWorkerToRestore; + }); + it("can search for tags", async () => { + const results = await openmct.annotation.searchForTags('S'); + 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/api.js b/src/api/api.js index 7e31bec7aa..6cb76a0d97 100644 --- a/src/api/api.js +++ b/src/api/api.js @@ -34,7 +34,8 @@ define([ './telemetry/TelemetryAPI', './time/TimeAPI', './types/TypeRegistry', - './user/UserAPI' + './user/UserAPI', + './annotation/AnnotationAPI' ], function ( ActionsAPI, CompositionAPI, @@ -49,7 +50,8 @@ define([ TelemetryAPI, TimeAPI, TypeRegistry, - UserAPI + UserAPI, + AnnotationAPI ) { return { ActionsAPI: ActionsAPI.default, @@ -65,6 +67,7 @@ define([ TelemetryAPI: TelemetryAPI, TimeAPI: TimeAPI.default, TypeRegistry: TypeRegistry, - UserAPI: UserAPI.default + UserAPI: UserAPI.default, + AnnotationAPI: AnnotationAPI.default }; }); diff --git a/src/api/forms/components/FormProperties.vue b/src/api/forms/components/FormProperties.vue index eac28b2f7e..f6c5a62c8b 100644 --- a/src/api/forms/components/FormProperties.vue +++ b/src/api/forms/components/FormProperties.vue @@ -44,18 +44,14 @@ > {{ section.name }} -
- -
+ :css-class="row.cssClass" + :first="index < 1" + :row="row" + @onChange="onChange" + /> diff --git a/src/api/forms/components/FormRow.vue b/src/api/forms/components/FormRow.vue index c6e3aba454..a7e9ac5b4c 100644 --- a/src/api/forms/components/FormRow.vue +++ b/src/api/forms/components/FormRow.vue @@ -23,7 +23,10 @@ - - diff --git a/src/ui/layout/mct-search.scss b/src/ui/layout/mct-search.scss deleted file mode 100644 index a6b1a8f18c..0000000000 --- a/src/ui/layout/mct-search.scss +++ /dev/null @@ -1,10 +0,0 @@ -/******************************* SEARCH */ -.c-search { - input[type=search] { - width: 100%; - } - - &--major { - display: flex; - } -} diff --git a/src/ui/layout/mct-tree.vue b/src/ui/layout/mct-tree.vue index da074f98b2..5cb61a3893 100644 --- a/src/ui/layout/mct-tree.vue +++ b/src/ui/layout/mct-tree.vue @@ -12,6 +12,7 @@ class="c-tree-and-search__search" > +
+
+
+
+ {{ getResultName }} +
+ + + +
+
+ {{ tag.label }} +
+
+
+
+ +
+
+ + + diff --git a/src/ui/layout/search/GrandSearch.vue b/src/ui/layout/search/GrandSearch.vue new file mode 100644 index 0000000000..788ae1e589 --- /dev/null +++ b/src/ui/layout/search/GrandSearch.vue @@ -0,0 +1,145 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + + + + diff --git a/src/ui/layout/search/ObjectSearchResult.vue b/src/ui/layout/search/ObjectSearchResult.vue new file mode 100644 index 0000000000..2e3416a8ee --- /dev/null +++ b/src/ui/layout/search/ObjectSearchResult.vue @@ -0,0 +1,102 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + + + + diff --git a/src/ui/layout/search/SearchResultsDropDown.vue b/src/ui/layout/search/SearchResultsDropDown.vue new file mode 100644 index 0000000000..21e1487cb8 --- /dev/null +++ b/src/ui/layout/search/SearchResultsDropDown.vue @@ -0,0 +1,99 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + + + + diff --git a/src/ui/layout/search/search.scss b/src/ui/layout/search/search.scss new file mode 100644 index 0000000000..fe3e3513db --- /dev/null +++ b/src/ui/layout/search/search.scss @@ -0,0 +1,137 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ + +/******************************* EXPANDED SEARCH 2022 */ +.c-gsearch { + .l-shell__head & { + // Search input in the shell head + width: 20%; + + .c-search { + background: rgba($colorHeadFg, 0.2); + box-shadow: none; + } + } + + &__results-wrapper { + @include menuOuter(); + display: flex; + flex-direction: column; + padding: $interiorMarginLg; + min-width: 500px; + max-height: 500px; + } + + &__results, + &__results-section { + flex: 1 1 auto; + } + + &__results { + // Holds n __results-sections + padding-right: $interiorMargin; // Fend off scrollbar + overflow-y: auto; + + > * + * { + margin-top: $interiorMarginLg; + } + } + + &__results-section { + > * + * { + margin-top: $interiorMarginSm; + } + } + + &__results-section-title { + @include propertiesHeader(); + } +} + +.c-gsearch-result { + display: flex; + padding: $interiorMargin $interiorMarginSm; + + > * + * { + margin-left: $interiorMarginLg; + } + + + .c-gsearch-result { + border-top: 1px solid $colorInteriorBorder; + } + + &__type-icon, + &__more-options-button { + flex: 0 0 auto; + } + + &__type-icon { + color: $colorItemTreeIcon; + font-size: 2.2em; + + // TEMP: uses object-label component, hide label part + .c-object-label__name { + display: none; + } + } + + &__more-options-button { + display: none; // TEMP until enabled + } + + &__body { + flex: 1 1 auto; + + > * + * { + margin-top: $interiorMarginSm; + } + + .c-location { + font-size: 0.9em; + opacity: 0.8; + } + } + + &__tags { + display: flex; + + > * + * { + margin-left: $interiorMargin; + } + } + + &__title { + border-radius: $basicCr; + color: pullForward($colorBodyFg, 30%); + cursor: pointer; + font-size: 1.15em; + padding: 3px $interiorMarginSm; + + &:hover { + background-color: $colorItemTreeHoverBg; + } + } + + .c-tag { + font-size: 0.9em; + } +} diff --git a/src/ui/mixins/context-menu-gesture.js b/src/ui/mixins/context-menu-gesture.js index 404015f383..ad7fa0de31 100644 --- a/src/ui/mixins/context-menu-gesture.js +++ b/src/ui/mixins/context-menu-gesture.js @@ -33,6 +33,10 @@ export default { }, methods: { showContextMenu(event) { + if (this.readOnly) { + return; + } + event.preventDefault(); event.stopPropagation(); diff --git a/src/ui/router/Browse.js b/src/ui/router/Browse.js index 05106815ae..1c8f622457 100644 --- a/src/ui/router/Browse.js +++ b/src/ui/router/Browse.js @@ -133,9 +133,7 @@ define([ composition.load() .then(children => { let lastChild = children[children.length - 1]; - if (!lastChild) { - console.debug('Unable to navigate to anything. No root objects found.'); - } else { + if (lastChild) { let lastChildId = openmct.objects.makeKeyString(lastChild.identifier); openmct.router.setPath(`#/browse/${lastChildId}`); }