mirror of
https://github.com/nasa/openmct.git
synced 2024-12-19 21:27:52 +00:00
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 <john.c.hill@nasa.gov> Co-authored-by: unlikelyzero <jchill2@gmail.com> Co-authored-by: Andrew Henry <akhenry@gmail.com>
This commit is contained in:
parent
2aec1ee854
commit
3c70cf1767
@ -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: [
|
||||
|
@ -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([
|
||||
|
@ -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(),
|
||||
|
@ -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([
|
||||
|
@ -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();
|
||||
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
33
example/exampleTags/plugin.js
Normal file
33
example/exampleTags/plugin.js
Normal file
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
19
example/exampleTags/tags.json
Normal file
19
example/exampleTags/tags.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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());
|
||||
|
@ -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());
|
||||
|
275
src/api/annotation/AnnotationAPI.js
Normal file
275
src/api/annotation/AnnotationAPI.js
Normal file
@ -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<import('../objects/ObjectAPI').DomainObject>} 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;
|
||||
}
|
||||
}
|
176
src/api/annotation/AnnotationAPISpec.js
Normal file
176
src/api/annotation/AnnotationAPISpec.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
};
|
||||
});
|
||||
|
@ -44,18 +44,14 @@
|
||||
>
|
||||
{{ section.name }}
|
||||
</h2>
|
||||
<div
|
||||
<FormRow
|
||||
v-for="(row, index) in section.rows"
|
||||
:key="row.id"
|
||||
class="u-contents"
|
||||
>
|
||||
<FormRow
|
||||
:css-class="section.cssClass"
|
||||
:first="index < 1"
|
||||
:row="row"
|
||||
@onChange="onChange"
|
||||
/>
|
||||
</div>
|
||||
:css-class="row.cssClass"
|
||||
:first="index < 1"
|
||||
:row="row"
|
||||
@onChange="onChange"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
@ -23,7 +23,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="form-row c-form__row"
|
||||
:class="[{ 'first': first }]"
|
||||
:class="[
|
||||
{ 'first': first },
|
||||
cssClass
|
||||
]"
|
||||
@onChange="onChange"
|
||||
>
|
||||
<div
|
||||
@ -34,7 +37,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="c-form-row__state-indicator"
|
||||
:class="rowClass"
|
||||
:class="reqClass"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
@ -76,24 +79,22 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
rowClass() {
|
||||
let cssClass = this.cssClass;
|
||||
reqClass() {
|
||||
let reqClass = 'req';
|
||||
|
||||
if (!this.row.required) {
|
||||
return;
|
||||
}
|
||||
|
||||
cssClass = `${cssClass} req`;
|
||||
|
||||
if (this.visited && this.valid !== undefined) {
|
||||
if (this.valid === true) {
|
||||
cssClass = `${cssClass} valid`;
|
||||
reqClass = 'valid';
|
||||
} else {
|
||||
cssClass = `${cssClass} invalid`;
|
||||
reqClass = 'invalid';
|
||||
}
|
||||
}
|
||||
|
||||
return cssClass;
|
||||
return reqClass;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
@ -19,35 +19,46 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div class="form-control autocomplete">
|
||||
<span class="autocompleteInputAndArrow">
|
||||
<div
|
||||
ref="autoCompleteForm"
|
||||
class="form-control c-input--autocomplete js-autocomplete"
|
||||
>
|
||||
<div
|
||||
class="c-input--autocomplete__wrapper"
|
||||
>
|
||||
<input
|
||||
ref="autoCompleteInput"
|
||||
v-model="field"
|
||||
class="autocompleteInput"
|
||||
class="c-input--autocomplete__input js-autocomplete__input"
|
||||
type="text"
|
||||
:placeholder="placeHolderText"
|
||||
@click="inputClicked()"
|
||||
@keydown="keyDown($event)"
|
||||
>
|
||||
<span
|
||||
class="icon-arrow-down"
|
||||
<div
|
||||
class="icon-arrow-down c-icon-button c-input--autocomplete__afford-arrow js-autocomplete__afford-arrow"
|
||||
@click="arrowClicked()"
|
||||
></span>
|
||||
</span>
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="autocompleteOptions"
|
||||
v-if="!hideOptions"
|
||||
class="c-menu c-input--autocomplete__options"
|
||||
@blur="hideOptions = true"
|
||||
>
|
||||
<ul v-if="!hideOptions">
|
||||
<ul>
|
||||
<li
|
||||
v-for="opt in filteredOptions"
|
||||
:key="opt.optionId"
|
||||
:class="{'optionPreSelected': optionIndex === opt.optionId}"
|
||||
:class="[
|
||||
{'optionPreSelected': optionIndex === opt.optionId},
|
||||
itemCssClass
|
||||
]"
|
||||
:style="itemStyle(opt)"
|
||||
@click="fillInputWithString(opt.name)"
|
||||
@mouseover="optionMouseover(opt.optionId)"
|
||||
>
|
||||
<span class="optionText">{{ opt.name }}</span>
|
||||
{{ opt.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -65,7 +76,23 @@ export default {
|
||||
props: {
|
||||
model: {
|
||||
type: Object,
|
||||
required: true
|
||||
required: true,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
placeHolderText: {
|
||||
type: String,
|
||||
default() {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
itemCssClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -78,31 +105,40 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
filteredOptions() {
|
||||
const options = this.optionNames || [];
|
||||
const fullOptions = this.options || [];
|
||||
if (this.showFilteredOptions) {
|
||||
return options
|
||||
const optionsFiltered = fullOptions
|
||||
.filter(option => {
|
||||
return option.toLowerCase().indexOf(this.field.toLowerCase()) >= 0;
|
||||
if (option.name && this.field) {
|
||||
return option.name.toLowerCase().indexOf(this.field.toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}).map((option, index) => {
|
||||
return {
|
||||
optionId: index,
|
||||
name: option
|
||||
name: option.name,
|
||||
color: option.color
|
||||
};
|
||||
});
|
||||
|
||||
return optionsFiltered;
|
||||
}
|
||||
|
||||
return options.map((option, index) => {
|
||||
const optionsFiltered = fullOptions.map((option, index) => {
|
||||
return {
|
||||
optionId: index,
|
||||
name: option
|
||||
name: option.name,
|
||||
color: option.color
|
||||
};
|
||||
});
|
||||
|
||||
return optionsFiltered;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
field(newValue, oldValue) {
|
||||
if (newValue !== oldValue) {
|
||||
|
||||
const data = {
|
||||
model: this.model,
|
||||
value: newValue
|
||||
@ -123,17 +159,17 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.options = this.model.options;
|
||||
this.autocompleteInputAndArrow = this.$el.getElementsByClassName('autocompleteInputAndArrow')[0];
|
||||
this.autocompleteInputElement = this.$el.getElementsByClassName('autocompleteInput')[0];
|
||||
if (this.options[0].name) {
|
||||
// If "options" include name, value pair
|
||||
this.optionNames = this.options.map((opt) => {
|
||||
return opt.name;
|
||||
this.autocompleteInputAndArrow = this.$refs.autoCompleteForm;
|
||||
this.autocompleteInputElement = this.$refs.autoCompleteInput;
|
||||
if (this.model.options && this.model.options.length && !this.model.options[0].name) {
|
||||
// If options is only an array of string.
|
||||
this.options = this.model.options.map((option) => {
|
||||
return {
|
||||
name: option
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// If options is only an array of string.
|
||||
this.optionNames = this.options;
|
||||
this.options = this.model.options;
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
@ -222,6 +258,12 @@ export default {
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
itemStyle(option) {
|
||||
if (option.color) {
|
||||
|
||||
return { '--optionIconColor': option.color };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -39,11 +39,10 @@ class InMemorySearchProvider {
|
||||
* If max results is not specified in query, use this as default.
|
||||
*/
|
||||
this.DEFAULT_MAX_RESULTS = 100;
|
||||
|
||||
this.openmct = openmct;
|
||||
|
||||
this.indexedIds = {};
|
||||
this.indexedCompositions = {};
|
||||
this.indexedTags = {};
|
||||
this.idsToIndex = [];
|
||||
this.pendingIndex = {};
|
||||
this.pendingRequests = 0;
|
||||
@ -52,11 +51,18 @@ class InMemorySearchProvider {
|
||||
/**
|
||||
* If we don't have SharedWorkers available (e.g., iOS)
|
||||
*/
|
||||
this.localIndexedItems = {};
|
||||
this.localIndexedDomainObjects = {};
|
||||
this.localIndexedAnnotationsByDomainObject = {};
|
||||
this.localIndexedAnnotationsByTag = {};
|
||||
|
||||
this.pendingQueries = {};
|
||||
this.onWorkerMessage = this.onWorkerMessage.bind(this);
|
||||
this.onWorkerMessageError = this.onWorkerMessageError.bind(this);
|
||||
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.onerror = this.onWorkerError.bind(this);
|
||||
this.startIndexing = this.startIndexing.bind(this);
|
||||
|
||||
@ -76,13 +82,39 @@ class InMemorySearchProvider {
|
||||
|
||||
startIndexing() {
|
||||
const rootObject = this.openmct.objects.rootProvider.rootObject;
|
||||
|
||||
this.searchTypes = this.openmct.objects.SEARCH_TYPES;
|
||||
|
||||
this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.NOTEBOOK_ANNOTATIONS, this.searchTypes.TAGS];
|
||||
|
||||
this.scheduleForIndexing(rootObject.identifier);
|
||||
|
||||
this.indexAnnotations();
|
||||
|
||||
if (typeof SharedWorker !== 'undefined') {
|
||||
this.worker = this.startSharedWorker();
|
||||
} else {
|
||||
// we must be on iOS
|
||||
}
|
||||
|
||||
this.openmct.annotation.on('annotationCreated', this.onAnnotationCreation);
|
||||
|
||||
}
|
||||
|
||||
indexAnnotations() {
|
||||
const theInMemorySearchProvider = this;
|
||||
Object.values(this.openmct.objects.providers).forEach(objectProvider => {
|
||||
if (objectProvider.getAllObjects) {
|
||||
const allObjects = objectProvider.getAllObjects();
|
||||
if (allObjects) {
|
||||
Object.values(allObjects).forEach(domainObject => {
|
||||
if (domainObject.type === 'annotation') {
|
||||
theInMemorySearchProvider.scheduleForIndexing(domainObject.identifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -98,51 +130,60 @@ class InMemorySearchProvider {
|
||||
return intermediateResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the search provider for results.
|
||||
*
|
||||
* @param {String} input the string to search by.
|
||||
* @param {Number} maxResults max number of results to return.
|
||||
* @returns {Promise} a promise for a modelResults object.
|
||||
*/
|
||||
query(input, maxResults) {
|
||||
if (!maxResults) {
|
||||
maxResults = this.DEFAULT_MAX_RESULTS;
|
||||
}
|
||||
|
||||
search(query, searchType) {
|
||||
const queryId = uuid();
|
||||
const pendingQuery = this.getIntermediateResponse();
|
||||
this.pendingQueries[queryId] = pendingQuery;
|
||||
const searchOptions = {
|
||||
queryId,
|
||||
searchType,
|
||||
query,
|
||||
maxResults: this.DEFAULT_MAX_RESULTS
|
||||
};
|
||||
|
||||
if (this.worker) {
|
||||
this.dispatchSearch(queryId, input, maxResults);
|
||||
this.#dispatchSearchToWorker(searchOptions);
|
||||
} else {
|
||||
this.localSearch(queryId, input, maxResults);
|
||||
this.#localQueryFallBack(searchOptions);
|
||||
}
|
||||
|
||||
return pendingQuery.promise;
|
||||
}
|
||||
|
||||
#localQueryFallBack({queryId, searchType, query, maxResults}) {
|
||||
if (searchType === this.searchTypes.OBJECTS) {
|
||||
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 {
|
||||
throw new Error(`🤷♂️ Unknown search type passed: ${searchType}`);
|
||||
}
|
||||
}
|
||||
|
||||
supportsSearchType(searchType) {
|
||||
return this.supportedSearchTypes.includes(searchType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from the worker. Only really knows how to handle search
|
||||
* results, which are parsed, transformed into a modelResult object, which
|
||||
* is used to resolve the corresponding promise.
|
||||
* Handle messages from the worker.
|
||||
* @private
|
||||
*/
|
||||
async onWorkerMessage(event) {
|
||||
if (event.data.request !== 'search') {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingQuery = this.pendingQueries[event.data.queryId];
|
||||
const modelResults = {
|
||||
total: event.data.total
|
||||
};
|
||||
modelResults.hits = await Promise.all(event.data.results.map(async (hit) => {
|
||||
const identifier = this.openmct.objects.parseKeyString(hit.keyString);
|
||||
const domainObject = await this.openmct.objects.get(identifier);
|
||||
if (hit && hit.keyString) {
|
||||
const identifier = this.openmct.objects.parseKeyString(hit.keyString);
|
||||
const domainObject = await this.openmct.objects.get(identifier);
|
||||
|
||||
return domainObject;
|
||||
return domainObject;
|
||||
}
|
||||
}));
|
||||
|
||||
pendingQuery.resolve(modelResults);
|
||||
@ -216,6 +257,11 @@ class InMemorySearchProvider {
|
||||
}
|
||||
}
|
||||
|
||||
onAnnotationCreation(annotationObject) {
|
||||
const provider = this;
|
||||
provider.index(annotationObject);
|
||||
}
|
||||
|
||||
onNameMutation(domainObject, name) {
|
||||
const provider = this;
|
||||
|
||||
@ -223,6 +269,14 @@ class InMemorySearchProvider {
|
||||
provider.index(domainObject);
|
||||
}
|
||||
|
||||
onTagMutation(domainObject, newTags) {
|
||||
domainObject.oldTags = domainObject.tags;
|
||||
domainObject.tags = newTags;
|
||||
const provider = this;
|
||||
|
||||
provider.index(domainObject);
|
||||
}
|
||||
|
||||
onCompositionMutation(domainObject, composition) {
|
||||
const provider = this;
|
||||
const indexedComposition = domainObject.composition;
|
||||
@ -259,6 +313,13 @@ class InMemorySearchProvider {
|
||||
'composition',
|
||||
this.onCompositionMutation.bind(this, domainObject)
|
||||
);
|
||||
if (domainObject.type === 'annotation') {
|
||||
this.indexedTags[keyString] = this.openmct.objects.observe(
|
||||
domainObject,
|
||||
'tags',
|
||||
this.onTagMutation.bind(this, domainObject)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((keyString !== 'ROOT')) {
|
||||
@ -317,26 +378,87 @@ class InMemorySearchProvider {
|
||||
* @private
|
||||
* @returns {String} a unique query Id for the query.
|
||||
*/
|
||||
dispatchSearch(queryId, searchInput, maxResults) {
|
||||
#dispatchSearchToWorker({queryId, searchType, query, maxResults}) {
|
||||
const message = {
|
||||
request: 'search',
|
||||
input: searchInput,
|
||||
request: searchType.toString(),
|
||||
input: query,
|
||||
maxResults,
|
||||
queryId
|
||||
};
|
||||
this.worker.port.postMessage(message);
|
||||
}
|
||||
|
||||
localIndexTags(keyString, objectToIndex, model) {
|
||||
// add new tags
|
||||
model.tags.forEach(tagID => {
|
||||
if (!this.localIndexedAnnotationsByTag[tagID]) {
|
||||
this.localIndexedAnnotationsByTag[tagID] = [];
|
||||
}
|
||||
|
||||
const existsInIndex = this.localIndexedAnnotationsByTag[tagID].some(indexedObject => {
|
||||
return indexedObject.keyString === objectToIndex.keyString;
|
||||
});
|
||||
|
||||
if (!existsInIndex) {
|
||||
this.localIndexedAnnotationsByTag[tagID].push(objectToIndex);
|
||||
}
|
||||
|
||||
});
|
||||
// remove old tags
|
||||
if (model.oldTags) {
|
||||
model.oldTags.forEach(tagIDToRemove => {
|
||||
const existsInNewModel = model.tags.includes(tagIDToRemove);
|
||||
if (!existsInNewModel && this.localIndexedAnnotationsByTag[tagIDToRemove]) {
|
||||
this.localIndexedAnnotationsByTag[tagIDToRemove] = this.localIndexedAnnotationsByTag[tagIDToRemove].
|
||||
filter(annotationToRemove => {
|
||||
const shouldKeep = annotationToRemove.keyString !== keyString;
|
||||
|
||||
return shouldKeep;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
localIndexAnnotation(objectToIndex, model) {
|
||||
Object.keys(model.targets).forEach(targetID => {
|
||||
if (!this.localIndexedAnnotationsByDomainObject[targetID]) {
|
||||
this.localIndexedAnnotationsByDomainObject[targetID] = [];
|
||||
}
|
||||
|
||||
objectToIndex.targets = model.targets;
|
||||
objectToIndex.tags = model.tags;
|
||||
const existsInIndex = this.localIndexedAnnotationsByDomainObject[targetID].some(indexedObject => {
|
||||
return indexedObject.keyString === objectToIndex.keyString;
|
||||
});
|
||||
|
||||
if (!existsInIndex) {
|
||||
this.localIndexedAnnotationsByDomainObject[targetID].push(objectToIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A local version of the same SharedWorker function
|
||||
* if we don't have SharedWorkers available (e.g., iOS)
|
||||
*/
|
||||
localIndexItem(keyString, model) {
|
||||
this.localIndexedItems[keyString] = {
|
||||
const objectToIndex = {
|
||||
type: model.type,
|
||||
name: model.name,
|
||||
keyString
|
||||
};
|
||||
if (model && (model.type === 'annotation')) {
|
||||
if (model.targets && model.targets) {
|
||||
this.localIndexAnnotation(objectToIndex, model);
|
||||
}
|
||||
|
||||
if (model.tags) {
|
||||
this.localIndexTags(keyString, objectToIndex, model);
|
||||
}
|
||||
} else {
|
||||
this.localIndexedDomainObjects[keyString] = objectToIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -346,21 +468,122 @@ class InMemorySearchProvider {
|
||||
* Gets search results from the indexedItems based on provided search
|
||||
* input. Returns matching results from indexedItems
|
||||
*/
|
||||
localSearch(queryId, searchInput, maxResults) {
|
||||
localSearchForObjects(queryId, searchInput, maxResults) {
|
||||
// This results dictionary will have domain object ID keys which
|
||||
// point to the value the domain object's score.
|
||||
let results;
|
||||
let results = [];
|
||||
const input = searchInput.trim().toLowerCase();
|
||||
const message = {
|
||||
request: 'search',
|
||||
results: {},
|
||||
request: 'searchForObjects',
|
||||
results: [],
|
||||
total: 0,
|
||||
queryId
|
||||
};
|
||||
|
||||
results = Object.values(this.localIndexedItems).filter((indexedItem) => {
|
||||
results = Object.values(this.localIndexedDomainObjects).filter((indexedItem) => {
|
||||
return indexedItem.name.toLowerCase().includes(input);
|
||||
});
|
||||
}) || [];
|
||||
|
||||
message.total = results.length;
|
||||
message.results = results
|
||||
.slice(0, maxResults);
|
||||
const eventToReturn = {
|
||||
data: message
|
||||
};
|
||||
this.onWorkerMessage(eventToReturn);
|
||||
}
|
||||
|
||||
/**
|
||||
* A local version of the same SharedWorker function
|
||||
* if we don't have SharedWorkers available (e.g., iOS)
|
||||
*/
|
||||
localSearchForAnnotations(queryId, searchInput, 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: 'searchForAnnotations',
|
||||
results: [],
|
||||
total: 0,
|
||||
queryId
|
||||
};
|
||||
|
||||
results = this.localIndexedAnnotationsByDomainObject[searchInput] || [];
|
||||
|
||||
message.total = results.length;
|
||||
message.results = results
|
||||
.slice(0, maxResults);
|
||||
const eventToReturn = {
|
||||
data: message
|
||||
};
|
||||
this.onWorkerMessage(eventToReturn);
|
||||
}
|
||||
|
||||
/**
|
||||
* A local version of the same SharedWorker function
|
||||
* if we don't have SharedWorkers available (e.g., iOS)
|
||||
*/
|
||||
localSearchForTags(queryId, matchingTagKeys, maxResults) {
|
||||
let results = [];
|
||||
const message = {
|
||||
request: 'searchForTags',
|
||||
results: [],
|
||||
total: 0,
|
||||
queryId
|
||||
};
|
||||
|
||||
if (matchingTagKeys) {
|
||||
matchingTagKeys.forEach(matchingTag => {
|
||||
const matchingAnnotations = this.localIndexedAnnotationsByTag[matchingTag];
|
||||
if (matchingAnnotations) {
|
||||
matchingAnnotations.forEach(matchingAnnotation => {
|
||||
const existsInResults = results.some(indexedObject => {
|
||||
return matchingAnnotation.keyString === indexedObject.keyString;
|
||||
});
|
||||
if (!existsInResults) {
|
||||
results.push(matchingAnnotation);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
message.total = results.length;
|
||||
message.results = results
|
||||
.slice(0, maxResults);
|
||||
const eventToReturn = {
|
||||
data: message
|
||||
};
|
||||
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
|
||||
|
@ -26,16 +26,27 @@
|
||||
(function () {
|
||||
// An object composed of domain object IDs and models
|
||||
// {id: domainObject's ID, name: domainObject's name}
|
||||
const indexedItems = {};
|
||||
const indexedDomainObjects = {};
|
||||
const indexedAnnotationsByDomainObject = {};
|
||||
const indexedAnnotationsByTag = {};
|
||||
|
||||
self.onconnect = function (e) {
|
||||
const port = e.ports[0];
|
||||
|
||||
port.onmessage = function (event) {
|
||||
if (event.data.request === 'index') {
|
||||
const requestType = event.data.request;
|
||||
if (requestType === 'index') {
|
||||
indexItem(event.data.keyString, event.data.model);
|
||||
} else if (event.data.request === 'search') {
|
||||
port.postMessage(search(event.data));
|
||||
} else if (requestType === 'OBJECTS') {
|
||||
port.postMessage(searchForObjects(event.data));
|
||||
} else if (requestType === 'ANNOTATIONS') {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -48,12 +59,73 @@
|
||||
console.error('Error on feed', error);
|
||||
};
|
||||
|
||||
function indexAnnotation(objectToIndex, model) {
|
||||
Object.keys(model.targets).forEach(targetID => {
|
||||
if (!indexedAnnotationsByDomainObject[targetID]) {
|
||||
indexedAnnotationsByDomainObject[targetID] = [];
|
||||
}
|
||||
|
||||
objectToIndex.targets = model.targets;
|
||||
objectToIndex.tags = model.tags;
|
||||
const existsInIndex = indexedAnnotationsByDomainObject[targetID].some(indexedObject => {
|
||||
return indexedObject.keyString === objectToIndex.keyString;
|
||||
});
|
||||
|
||||
if (!existsInIndex) {
|
||||
indexedAnnotationsByDomainObject[targetID].push(objectToIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function indexTags(keyString, objectToIndex, model) {
|
||||
// add new tags
|
||||
model.tags.forEach(tagID => {
|
||||
if (!indexedAnnotationsByTag[tagID]) {
|
||||
indexedAnnotationsByTag[tagID] = [];
|
||||
}
|
||||
|
||||
const existsInIndex = indexedAnnotationsByTag[tagID].some(indexedObject => {
|
||||
return indexedObject.keyString === objectToIndex.keyString;
|
||||
});
|
||||
|
||||
if (!existsInIndex) {
|
||||
indexedAnnotationsByTag[tagID].push(objectToIndex);
|
||||
}
|
||||
|
||||
});
|
||||
// remove old tags
|
||||
if (model.oldTags) {
|
||||
model.oldTags.forEach(tagIDToRemove => {
|
||||
const existsInNewModel = model.tags.includes(tagIDToRemove);
|
||||
if (!existsInNewModel && indexedAnnotationsByTag[tagIDToRemove]) {
|
||||
indexedAnnotationsByTag[tagIDToRemove] = indexedAnnotationsByTag[tagIDToRemove].
|
||||
filter(annotationToRemove => {
|
||||
const shouldKeep = annotationToRemove.keyString !== keyString;
|
||||
|
||||
return shouldKeep;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function indexItem(keyString, model) {
|
||||
indexedItems[keyString] = {
|
||||
const objectToIndex = {
|
||||
type: model.type,
|
||||
name: model.name,
|
||||
keyString
|
||||
};
|
||||
if (model && (model.type === 'annotation')) {
|
||||
if (model.targets && model.targets) {
|
||||
indexAnnotation(objectToIndex, model);
|
||||
}
|
||||
|
||||
if (model.tags) {
|
||||
indexTags(keyString, objectToIndex, model);
|
||||
}
|
||||
} else {
|
||||
indexedDomainObjects[keyString] = objectToIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -65,21 +137,98 @@
|
||||
* * maxResults: The maximum number of search results desired
|
||||
* * queryId: an id identifying this query, will be returned.
|
||||
*/
|
||||
function search(data) {
|
||||
// This results dictionary will have domain object ID keys which
|
||||
// point to the value the domain object's score.
|
||||
let results;
|
||||
function searchForObjects(data) {
|
||||
let results = [];
|
||||
const input = data.input.trim().toLowerCase();
|
||||
const message = {
|
||||
request: 'search',
|
||||
request: 'searchForObjects',
|
||||
results: [],
|
||||
total: 0,
|
||||
queryId: data.queryId
|
||||
};
|
||||
|
||||
results = Object.values(indexedDomainObjects).filter((indexedItem) => {
|
||||
return indexedItem.name.toLowerCase().includes(input);
|
||||
}) || [];
|
||||
|
||||
message.total = results.length;
|
||||
message.results = results
|
||||
.slice(0, data.maxResults);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function searchForAnnotations(data) {
|
||||
let results = [];
|
||||
const message = {
|
||||
request: 'searchForAnnotations',
|
||||
results: [],
|
||||
total: 0,
|
||||
queryId: data.queryId
|
||||
};
|
||||
|
||||
results = indexedAnnotationsByDomainObject[data.input] || [];
|
||||
|
||||
message.total = results.length;
|
||||
message.results = results
|
||||
.slice(0, data.maxResults);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function searchForTags(data) {
|
||||
let results = [];
|
||||
const message = {
|
||||
request: 'searchForTags',
|
||||
results: [],
|
||||
total: 0,
|
||||
queryId: data.queryId
|
||||
};
|
||||
|
||||
if (data.input) {
|
||||
data.input.forEach(matchingTag => {
|
||||
const matchingAnnotations = indexedAnnotationsByTag[matchingTag];
|
||||
if (matchingAnnotations) {
|
||||
matchingAnnotations.forEach(matchingAnnotation => {
|
||||
const existsInResults = results.some(indexedObject => {
|
||||
return matchingAnnotation.keyString === indexedObject.keyString;
|
||||
});
|
||||
if (!existsInResults) {
|
||||
results.push(matchingAnnotation);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
message.total = results.length;
|
||||
message.results = results
|
||||
.slice(0, data.maxResults);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function searchForNotebookAnnotations(data) {
|
||||
let results = [];
|
||||
const message = {
|
||||
request: 'searchForNotebookAnnotations',
|
||||
results: {},
|
||||
total: 0,
|
||||
queryId: data.queryId
|
||||
};
|
||||
|
||||
results = Object.values(indexedItems).filter((indexedItem) => {
|
||||
return indexedItem.name.toLowerCase().includes(input);
|
||||
});
|
||||
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
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -17,13 +17,16 @@ describe("The Object API Search Function", () => {
|
||||
openmct = createOpenMct();
|
||||
|
||||
mockObjectProvider = jasmine.createSpyObj("mock object provider", [
|
||||
"search"
|
||||
"search", "supportsSearchType"
|
||||
]);
|
||||
anotherMockObjectProvider = jasmine.createSpyObj("another mock object provider", [
|
||||
"search"
|
||||
"search", "supportsSearchType"
|
||||
]);
|
||||
openmct.objects.addProvider('objects', mockObjectProvider);
|
||||
openmct.objects.addProvider('other-objects', anotherMockObjectProvider);
|
||||
mockObjectProvider.supportsSearchType.and.callFake(() => {
|
||||
return true;
|
||||
});
|
||||
mockObjectProvider.search.and.callFake(() => {
|
||||
return new Promise(resolve => {
|
||||
const mockProviderSearch = {
|
||||
@ -38,6 +41,9 @@ describe("The Object API Search Function", () => {
|
||||
}, MOCK_PROVIDER_SEARCH_DELAY);
|
||||
});
|
||||
});
|
||||
anotherMockObjectProvider.supportsSearchType.and.callFake(() => {
|
||||
return true;
|
||||
});
|
||||
anotherMockObjectProvider.search.and.callFake(() => {
|
||||
return new Promise(resolve => {
|
||||
const anotherMockProviderSearch = {
|
||||
@ -110,8 +116,8 @@ describe("The Object API Search Function", () => {
|
||||
namespace: ''
|
||||
});
|
||||
openmct.objects.addProvider('foo', defaultObjectProvider);
|
||||
spyOn(openmct.objects.inMemorySearchProvider, "query").and.callThrough();
|
||||
spyOn(openmct.objects.inMemorySearchProvider, "localSearch").and.callThrough();
|
||||
spyOn(openmct.objects.inMemorySearchProvider, "search").and.callThrough();
|
||||
spyOn(openmct.objects.inMemorySearchProvider, "localSearchForObjects").and.callThrough();
|
||||
|
||||
openmct.on('start', async () => {
|
||||
mockIdentifier1 = {
|
||||
@ -155,7 +161,7 @@ describe("The Object API Search Function", () => {
|
||||
|
||||
it("can provide indexing without a provider", () => {
|
||||
openmct.objects.search('foo');
|
||||
expect(openmct.objects.inMemorySearchProvider.query).toHaveBeenCalled();
|
||||
expect(openmct.objects.inMemorySearchProvider.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can do partial search", async () => {
|
||||
@ -177,16 +183,22 @@ describe("The Object API Search Function", () => {
|
||||
});
|
||||
|
||||
describe("Without Shared Workers", () => {
|
||||
let sharedWorkerToRestore;
|
||||
beforeEach(async () => {
|
||||
// use local worker
|
||||
sharedWorkerToRestore = openmct.objects.inMemorySearchProvider.worker;
|
||||
openmct.objects.inMemorySearchProvider.worker = null;
|
||||
// reindex locally
|
||||
await openmct.objects.inMemorySearchProvider.index(mockDomainObject1);
|
||||
await openmct.objects.inMemorySearchProvider.index(mockDomainObject2);
|
||||
await openmct.objects.inMemorySearchProvider.index(mockDomainObject3);
|
||||
});
|
||||
afterEach(() => {
|
||||
openmct.objects.inMemorySearchProvider.worker = sharedWorkerToRestore;
|
||||
});
|
||||
it("calls local search", () => {
|
||||
openmct.objects.search('foo');
|
||||
expect(openmct.objects.inMemorySearchProvider.localSearch).toHaveBeenCalled();
|
||||
expect(openmct.objects.inMemorySearchProvider.localSearchForObjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can do partial search", async () => {
|
||||
|
@ -89,6 +89,7 @@ export default function ClockPlugin(options) {
|
||||
"key": "timezone",
|
||||
"name": "Timezone",
|
||||
"control": "autocomplete",
|
||||
"cssClass": "c-clock__timezone-selection c-menu--no-icon",
|
||||
"options": momentTimezone.tz.names(),
|
||||
property: [
|
||||
'configuration',
|
||||
|
@ -88,6 +88,35 @@ describe('the plugin', function () {
|
||||
expect(displayLayoutViewProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders a display layout view without errors', () => {
|
||||
const testViewObject = {
|
||||
identifier: {
|
||||
namespace: 'test-namespace',
|
||||
key: 'test-key'
|
||||
},
|
||||
type: 'layout',
|
||||
configuration: {
|
||||
items: [],
|
||||
layoutGrid: [10, 10]
|
||||
},
|
||||
composition: []
|
||||
};
|
||||
|
||||
const applicableViews = openmct.objectViews.get(testViewObject, []);
|
||||
let displayLayoutViewProvider = applicableViews.find((viewProvider) => viewProvider.key === 'layout.view');
|
||||
let view = displayLayoutViewProvider.view(testViewObject);
|
||||
let error;
|
||||
|
||||
try {
|
||||
view.show(child, false);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
|
||||
});
|
||||
|
||||
describe('the alpha numeric format view', () => {
|
||||
let displayLayoutItem;
|
||||
let telemetryItem;
|
||||
|
@ -41,6 +41,10 @@ export default class LocalStorageObjectProvider {
|
||||
}
|
||||
}
|
||||
|
||||
getAllObjects() {
|
||||
return this.getSpaceAsObject();
|
||||
}
|
||||
|
||||
create(object) {
|
||||
return this.persistObject(object);
|
||||
}
|
||||
|
@ -196,23 +196,11 @@ export default {
|
||||
searchResults: [],
|
||||
showTime: this.domainObject.configuration.showTime || 0,
|
||||
showNav: false,
|
||||
sidebarCoversEntries: false
|
||||
sidebarCoversEntries: false,
|
||||
filteredAndSortedEntries: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredAndSortedEntries() {
|
||||
const filterTime = Date.now();
|
||||
const pageEntries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage) || [];
|
||||
|
||||
const hours = parseInt(this.showTime, 10);
|
||||
const filteredPageEntriesByTime = hours
|
||||
? pageEntries.filter(entry => (filterTime - entry.createdOn) <= hours * 60 * 60 * 1000)
|
||||
: pageEntries;
|
||||
|
||||
return this.defaultSort === 'oldest'
|
||||
? filteredPageEntriesByTime
|
||||
: [...filteredPageEntriesByTime].reverse();
|
||||
},
|
||||
pages() {
|
||||
return this.getPages() || [];
|
||||
},
|
||||
@ -261,6 +249,7 @@ export default {
|
||||
},
|
||||
defaultSort() {
|
||||
mutateObject(this.openmct, this.domainObject, 'configuration.defaultSort', this.defaultSort);
|
||||
this.filterAndSortEntries();
|
||||
},
|
||||
showTime() {
|
||||
mutateObject(this.openmct, this.domainObject, 'configuration.showTime', this.showTime);
|
||||
@ -276,6 +265,7 @@ export default {
|
||||
|
||||
window.addEventListener('orientationchange', this.formatSidebar);
|
||||
window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
|
||||
this.filterAndSortEntries();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.unlisten) {
|
||||
@ -313,6 +303,19 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
filterAndSortEntries() {
|
||||
const filterTime = Date.now();
|
||||
const pageEntries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage) || [];
|
||||
|
||||
const hours = parseInt(this.showTime, 10);
|
||||
const filteredPageEntriesByTime = hours
|
||||
? pageEntries.filter(entry => (filterTime - entry.createdOn) <= hours * 60 * 60 * 1000)
|
||||
: pageEntries;
|
||||
|
||||
this.filteredAndSortedEntries = this.defaultSort === 'oldest'
|
||||
? filteredPageEntriesByTime
|
||||
: [...filteredPageEntriesByTime].reverse();
|
||||
},
|
||||
changeSelectedSection({ sectionId, pageId }) {
|
||||
const sections = this.sections.map(s => {
|
||||
s.isSelected = false;
|
||||
@ -384,16 +387,40 @@ export default {
|
||||
const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
|
||||
entries.splice(entryPos, 1);
|
||||
this.updateEntries(entries);
|
||||
this.filterAndSortEntries();
|
||||
this.removeAnnotations(entryId);
|
||||
dialog.dismiss();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Cancel",
|
||||
callback: () => dialog.dismiss()
|
||||
callback: () => {
|
||||
dialog.dismiss();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
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);
|
||||
},
|
||||
checkEntryPos(entry) {
|
||||
const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage);
|
||||
if (entryPos === -1) {
|
||||
this.openmct.notifications.alert('Warning: unable to tag entry');
|
||||
console.error(`unable to tag entry ${entry} from section ${this.selectedSection}, page ${this.selectedPage}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
dragOver(event) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
@ -611,13 +638,13 @@ export default {
|
||||
|
||||
return section.id;
|
||||
},
|
||||
newEntry(embed = null) {
|
||||
async newEntry(embed = null) {
|
||||
this.resetSearch();
|
||||
const notebookStorage = this.createNotebookStorageObject();
|
||||
this.updateDefaultNotebook(notebookStorage);
|
||||
addNotebookEntry(this.openmct, this.domainObject, notebookStorage, embed).then(id => {
|
||||
this.focusEntryId = id;
|
||||
});
|
||||
const id = await addNotebookEntry(this.openmct, this.domainObject, notebookStorage, embed);
|
||||
this.focusEntryId = id;
|
||||
this.filterAndSortEntries();
|
||||
},
|
||||
orientationChange() {
|
||||
this.formatSidebar();
|
||||
@ -737,6 +764,7 @@ export default {
|
||||
|
||||
this.selectedPageId = pageId;
|
||||
this.syncUrlWithPageAndSection();
|
||||
this.filterAndSortEntries();
|
||||
},
|
||||
selectSection(sectionId) {
|
||||
if (!sectionId) {
|
||||
@ -749,6 +777,7 @@ export default {
|
||||
this.selectPage(pageId);
|
||||
|
||||
this.syncUrlWithPageAndSection();
|
||||
this.filterAndSortEntries();
|
||||
},
|
||||
activeTransaction() {
|
||||
return this.openmct.objects.getActiveTransaction();
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="c-notebook__entry c-ne has-local-controls"
|
||||
class="c-notebook__entry c-ne has-local-controls has-tag-applier"
|
||||
@dragover="changeCursor"
|
||||
@drop.capture="cancelEditMode"
|
||||
@drop.prevent="dropOnEntry"
|
||||
@ -67,6 +67,13 @@
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<TagEditor
|
||||
:domain-object="domainObject"
|
||||
:annotation-query="annotationQuery"
|
||||
:annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK"
|
||||
:annotation-search-type="openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS"
|
||||
:target-specific-details="{entryId: entry.id}"
|
||||
/>
|
||||
<div class="c-snapshots c-ne__embeds">
|
||||
<NotebookEmbed
|
||||
v-for="embed in entry.embeds"
|
||||
@ -115,6 +122,7 @@
|
||||
|
||||
<script>
|
||||
import NotebookEmbed from './NotebookEmbed.vue';
|
||||
import TagEditor from '../../../ui/components/tags/TagEditor.vue';
|
||||
import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue';
|
||||
import { createNewEmbed } from '../utils/notebook-entries';
|
||||
import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image';
|
||||
@ -124,7 +132,8 @@ import Moment from 'moment';
|
||||
export default {
|
||||
components: {
|
||||
NotebookEmbed,
|
||||
TextHighlight
|
||||
TextHighlight,
|
||||
TagEditor
|
||||
},
|
||||
inject: ['openmct', 'snapshotContainer'],
|
||||
props: {
|
||||
@ -169,6 +178,14 @@ 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
|
||||
};
|
||||
},
|
||||
createdOnTime() {
|
||||
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
|
||||
},
|
||||
|
@ -33,6 +33,7 @@ describe("Notebook plugin:", () => {
|
||||
let objectProviderObserver;
|
||||
|
||||
let notebookDomainObject;
|
||||
let originalAnnotations;
|
||||
|
||||
beforeEach((done) => {
|
||||
notebookDomainObject = {
|
||||
@ -55,6 +56,11 @@ describe("Notebook plugin:", () => {
|
||||
element.appendChild(child);
|
||||
|
||||
openmct.install(notebookPlugin());
|
||||
originalAnnotations = openmct.annotation.getNotebookAnnotation;
|
||||
// eslint-disable-next-line require-await
|
||||
openmct.annotation.getNotebookAnnotation = async function () {
|
||||
return null;
|
||||
};
|
||||
|
||||
notebookDefinition = openmct.types.get('notebook').definition;
|
||||
notebookDefinition.initialize(notebookDomainObject);
|
||||
@ -65,6 +71,7 @@ describe("Notebook plugin:", () => {
|
||||
|
||||
afterEach(() => {
|
||||
appHolder.remove();
|
||||
openmct.annotation.getNotebookAnnotation = originalAnnotations;
|
||||
|
||||
return resetApplicationState(openmct);
|
||||
});
|
||||
@ -83,7 +90,7 @@ describe("Notebook plugin:", () => {
|
||||
let notebookViewObject;
|
||||
let mutableNotebookObject;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
notebookViewObject = {
|
||||
...notebookDomainObject,
|
||||
id: "test-object",
|
||||
@ -161,16 +168,14 @@ describe("Notebook plugin:", () => {
|
||||
testObjectProvider.create.and.returnValue(Promise.resolve(true));
|
||||
testObjectProvider.update.and.returnValue(Promise.resolve(true));
|
||||
|
||||
return openmct.objects.getMutable(notebookViewObject.identifier).then((mutableObject) => {
|
||||
mutableNotebookObject = mutableObject;
|
||||
objectProviderObserver = testObjectProvider.observe.calls.mostRecent().args[1];
|
||||
const mutableObject = await openmct.objects.getMutable(notebookViewObject.identifier);
|
||||
mutableNotebookObject = mutableObject;
|
||||
objectProviderObserver = testObjectProvider.observe.calls.mostRecent().args[1];
|
||||
|
||||
notebookView = notebookViewProvider.view(mutableNotebookObject);
|
||||
notebookView.show(child);
|
||||
|
||||
return Vue.nextTick();
|
||||
});
|
||||
notebookView = notebookViewProvider.view(mutableNotebookObject);
|
||||
notebookView.show(child);
|
||||
|
||||
await Vue.nextTick();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import objectLink from '../../../ui/mixins/object-link';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
async function getUsername(openmct) {
|
||||
let username = '';
|
||||
@ -123,8 +124,8 @@ export async function addNotebookEntry(openmct, domainObject, notebookStorage, e
|
||||
? [embed]
|
||||
: [];
|
||||
|
||||
const id = `entry-${uuid()}`;
|
||||
const createdBy = await getUsername(openmct);
|
||||
const id = `entry-${date}`;
|
||||
const entry = {
|
||||
id,
|
||||
createdOn: date,
|
||||
@ -142,7 +143,7 @@ export async function addNotebookEntry(openmct, domainObject, notebookStorage, e
|
||||
}
|
||||
|
||||
export function getNotebookEntries(domainObject, selectedSection, selectedPage) {
|
||||
if (!domainObject || !selectedSection || !selectedPage) {
|
||||
if (!domainObject || !selectedSection || !selectedPage || !domainObject.configuration) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -159,7 +160,9 @@ export function getNotebookEntries(domainObject, selectedSection, selectedPage)
|
||||
return;
|
||||
}
|
||||
|
||||
return entries[selectedSection.id][selectedPage.id];
|
||||
const specificEntries = entries[selectedSection.id][selectedPage.id];
|
||||
|
||||
return specificEntries;
|
||||
}
|
||||
|
||||
export function getEntryPosById(entryId, domainObject, selectedSection, selectedPage) {
|
||||
|
@ -30,9 +30,29 @@
|
||||
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];
|
||||
}
|
||||
|
||||
search(query, abortSignal) {
|
||||
supportsSearchType(searchType) {
|
||||
return this.supportedSearchTypes.includes(searchType);
|
||||
}
|
||||
|
||||
search(query, abortSignal, searchType) {
|
||||
if (searchType === this.searchTypes.OBJECTS) {
|
||||
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 {
|
||||
throw new Error(`🤷♂️ Unknown search type passed: ${searchType}`);
|
||||
}
|
||||
}
|
||||
|
||||
searchForObjects(query, abortSignal) {
|
||||
const filter = {
|
||||
"selector": {
|
||||
"model": {
|
||||
@ -45,5 +65,86 @@ class CouchSearchProvider {
|
||||
|
||||
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
|
||||
}
|
||||
|
||||
searchForAnnotations(keyString, abortSignal) {
|
||||
const filter = {
|
||||
"selector": {
|
||||
"$and": [
|
||||
{
|
||||
"model": {
|
||||
"targets": {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"model.type": {
|
||||
"$eq": "annotation"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
filter.selector.$and[0].model.targets[keyString] = {
|
||||
"$exists": true
|
||||
};
|
||||
|
||||
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": {
|
||||
"$and": [
|
||||
{
|
||||
"model.tags": {
|
||||
"$elemMatch": {
|
||||
"$eq": `${tagsArray[0]}`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"model.type": {
|
||||
"$eq": "annotation"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
|
||||
}
|
||||
|
||||
}
|
||||
export default CouchSearchProvider;
|
||||
|
@ -1,11 +1,12 @@
|
||||
# Introduction
|
||||
These instructions are for setting up CouchDB for a **development** environment. For a production environment, we recommend running OpenMCT behind a proxy server (e.g., Nginx or Apache), and securing the CouchDB server properly:
|
||||
These instructions are for setting up CouchDB for a **development** environment. For a production environment, we recommend running Open MCT behind a proxy server (e.g., Nginx or Apache), and securing the CouchDB server properly:
|
||||
https://docs.couchdb.org/en/main/intro/security.html
|
||||
|
||||
# Installing CouchDB
|
||||
## OSX
|
||||
1. Install CouchDB using: `brew install couchdb`
|
||||
2. Edit `/usr/local/etc/local.ini` and add and admin password:
|
||||
## macOS
|
||||
### Installing with admin privileges to your computer
|
||||
1. Install CouchDB using: `brew install couchdb`.
|
||||
2. Edit `/usr/local/etc/local.ini` and add the following settings:
|
||||
```
|
||||
[admins]
|
||||
admin = youradminpassword
|
||||
@ -15,34 +16,37 @@ https://docs.couchdb.org/en/main/intro/security.html
|
||||
[couchdb]
|
||||
single_node=true
|
||||
```
|
||||
|
||||
3. Start CouchDB by running: `couchdb`
|
||||
4. Add the `_global_changes` database using `curl` (note the `youradminpassword` should be changed to what you set above 👆): `curl -X PUT http://admin:youradminpassword@127.0.0.1:5984/_global_changes`
|
||||
Enable CORS
|
||||
```
|
||||
[chttpd]
|
||||
enable_cors = true
|
||||
[cors]
|
||||
origins = http://localhost:8080
|
||||
```
|
||||
### Installing without admin privileges to your computer
|
||||
1. Install CouchDB following these instructions: https://docs.brew.sh/Installation#untar-anywhere.
|
||||
1. Edit `local.ini` in Homebrew's `/etc/` directory as directed above in the 'Installing with admin privileges to your computer' section.
|
||||
## Other Operating Systems
|
||||
Follow the installation instructions from the CouchDB installation guide: https://docs.couchdb.org/en/stable/install/index.html
|
||||
# Configuring CouchDB
|
||||
1. Start CouchDB by running: `couchdb`.
|
||||
2. Add the `_global_changes` database using `curl` (note the `youradminpassword` should be changed to what you set above 👆): `curl -X PUT http://admin:youradminpassword@127.0.0.1:5984/_global_changes`
|
||||
3. Navigate to http://localhost:5984/_utils
|
||||
4. Create a database called `openmct`
|
||||
5. Navigate to http://127.0.0.1:5984/_utils/#/database/openmct/permissions
|
||||
6. Remove permission restrictions in CouchDB from Open MCT by deleting `_admin` roles for both `Admin` and `Member`.
|
||||
|
||||
# Configuring OpenMCT
|
||||
1. Navigate to http://localhost:5984/_utils
|
||||
2. Create a database called `openmct`
|
||||
3. In your OpenMCT directory, edit `openmct/index.html`, and comment out:
|
||||
# Configuring Open MCT
|
||||
1. Edit `openmct/index.html` comment out the following line:
|
||||
```
|
||||
openmct.install(openmct.plugins.LocalStorage());
|
||||
```
|
||||
Add a line to install the CouchDB plugin for OpenMCT:
|
||||
Add a line to install the CouchDB plugin for Open MCT:
|
||||
```
|
||||
openmct.install(openmct.plugins.CouchDB("http://localhost:5984/openmct"));
|
||||
```
|
||||
6. Enable cors in CouchDB by editing `~/homebrew/etc/local.ini` and add: `
|
||||
```
|
||||
[chttpd]
|
||||
enable_cors = true
|
||||
|
||||
[cors]
|
||||
origins = http://localhost:8080
|
||||
```
|
||||
7. Remove permission restrictions in CouchDB from OpenMCT by navigating to http://127.0.0.1:5984/_utils/#/database/openmct/permissions and deleting `_admin` roles for both `Admin` and `Member`.
|
||||
8. Start openmct by running `npm start` in the OpenMCT directory.
|
||||
9. Navigate to http://localhost:8080/ and create a random object in OpenMCT (e.g., a `Clock`) and save. You may get an error saying that the objects failed to persist. This is a known error that you can ignore, and will only happen the first time you save.
|
||||
10. Navigate to: http://127.0.0.1:5984/_utils/#database/openmct/_all_docs
|
||||
11. Look at the `JSON` tab and ensure you can see the `Clock` object you created above.
|
||||
12. All done! 🏆
|
||||
2. Start Open MCT by running `npm start` in the `openmct` path.
|
||||
3. Navigate to http://localhost:8080/ and create a random object in Open MCT (e.g., a 'Clock') and save. You may get an error saying that the object failed to persist - this is a known error that you can ignore, and will only happen the first time you save - just try again.
|
||||
4. Navigate to: http://127.0.0.1:5984/_utils/#database/openmct/_all_docs
|
||||
5. Look at the 'JSON' tab and ensure you can see the specific object you created above.
|
||||
6. All done! 🏆
|
||||
|
@ -80,7 +80,8 @@ define([
|
||||
'./localStorage/plugin',
|
||||
'./operatorStatus/plugin',
|
||||
'./gauge/GaugePlugin',
|
||||
'./timelist/plugin'
|
||||
'./timelist/plugin',
|
||||
'../../example/exampleTags/plugin'
|
||||
], function (
|
||||
_,
|
||||
UTCTimeSystem,
|
||||
@ -141,7 +142,8 @@ define([
|
||||
LocalStorage,
|
||||
OperatorStatus,
|
||||
GaugePlugin,
|
||||
TimeList
|
||||
TimeList,
|
||||
ExampleTags
|
||||
) {
|
||||
const plugins = {};
|
||||
|
||||
@ -149,6 +151,7 @@ define([
|
||||
plugins.example.ExampleUser = ExampleUser.default;
|
||||
plugins.example.ExampleImagery = ExampleImagery.default;
|
||||
plugins.example.EventGeneratorPlugin = EventGeneratorPlugin.default;
|
||||
plugins.example.ExampleTags = ExampleTags.default;
|
||||
plugins.example.Generator = () => GeneratorPlugin;
|
||||
|
||||
plugins.UTCTimeSystem = UTCTimeSystem.default;
|
||||
|
@ -69,7 +69,8 @@
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #1ac6ff; // this should be a variable... CHARLESSSSSS
|
||||
background: $colorKey;
|
||||
color: $colorKeyFg;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -245,7 +245,8 @@ $colorMenuHovBg: rgba($colorKey, 0.5);
|
||||
$colorMenuHovFg: $colorBodyFgEm;
|
||||
$colorMenuHovIc: $colorMenuHovFg;
|
||||
$colorMenuElementHilite: pullForward($colorMenuBg, 10%);
|
||||
$shdwMenu: rgba(black, 0.5) 0 1px 5px;
|
||||
$shdwMenu: rgba(black, 0.8) 0 2px 10px;
|
||||
$shdwMenuInner: inset 0 0 0 1px rgba(white, 0.2);
|
||||
$shdwMenuText: none;
|
||||
$menuItemPad: $interiorMargin, floor($interiorMargin * 1.25);
|
||||
|
||||
@ -269,7 +270,6 @@ $colorFormSectionHeaderBg: rgba(#000, 0.1);
|
||||
$colorFormSectionHeaderFg: rgba($colorBodyFg, 0.8);
|
||||
$colorInputBg: rgba(black, 0.2);
|
||||
$colorInputFg: $colorBodyFg;
|
||||
$colorInputPlaceholder: pushBack($colorBodyFg, 20%);
|
||||
$colorFormText: pushBack($colorBodyFg, 10%);
|
||||
$colorInputIcon: pushBack($colorBodyFg, 25%);
|
||||
$colorFieldHint: pullForward($colorBodyFg, 40%);
|
||||
|
@ -249,7 +249,8 @@ $colorMenuHovBg: rgba($colorKey, 0.5);
|
||||
$colorMenuHovFg: $colorBodyFgEm;
|
||||
$colorMenuHovIc: $colorMenuHovFg;
|
||||
$colorMenuElementHilite: pullForward($colorMenuBg, 10%);
|
||||
$shdwMenu: rgba(black, 0.5) 0 1px 5px;
|
||||
$shdwMenu: rgba(black, 0.8) 0 2px 10px;
|
||||
$shdwMenuInner: inset 0 0 0 1px rgba(white, 0.2);
|
||||
$shdwMenuText: none;
|
||||
$menuItemPad: $interiorMargin, floor($interiorMargin * 1.25);
|
||||
|
||||
@ -273,7 +274,6 @@ $colorFormSectionHeaderBg: rgba(#000, 0.1);
|
||||
$colorFormSectionHeaderFg: rgba($colorBodyFg, 0.8);
|
||||
$colorInputBg: rgba(black, 0.2);
|
||||
$colorInputFg: $colorBodyFg;
|
||||
$colorInputPlaceholder: pushBack($colorBodyFg, 20%);
|
||||
$colorFormText: pushBack($colorBodyFg, 10%);
|
||||
$colorInputIcon: pushBack($colorBodyFg, 25%);
|
||||
$colorFieldHint: pullForward($colorBodyFg, 40%);
|
||||
|
@ -245,7 +245,8 @@ $colorMenuHovBg: $colorMenuIc;
|
||||
$colorMenuHovFg: $colorMenuBg;
|
||||
$colorMenuHovIc: $colorMenuBg;
|
||||
$colorMenuElementHilite: darken($colorMenuBg, 10%);
|
||||
$shdwMenu: rgba(black, 0.5) 0 1px 5px;
|
||||
$shdwMenu: rgba(black, 0.8) 0 2px 10px;
|
||||
$shdwMenuInner: none;
|
||||
$shdwMenuText: none;
|
||||
$menuItemPad: $interiorMargin, floor($interiorMargin * 1.25);
|
||||
|
||||
@ -269,7 +270,6 @@ $colorFormSectionHeaderBg: rgba(#000, 0.05);
|
||||
$colorFormSectionHeaderFg: rgba($colorBodyFg, 0.5);
|
||||
$colorInputBg: $colorGenBg;
|
||||
$colorInputFg: $colorBodyFg;
|
||||
$colorInputPlaceholder: pushBack($colorBodyFg, 20%);
|
||||
$colorFormText: pushBack($colorBodyFg, 10%);
|
||||
$colorInputIcon: pushBack($colorBodyFg, 25%);
|
||||
$colorFieldHint: pullForward($colorBodyFg, 40%);
|
||||
|
@ -22,6 +22,60 @@
|
||||
|
||||
@use 'sass:math';
|
||||
|
||||
/******************************************************** CONTROL-SPECIFIC MIXINS */
|
||||
@mixin menuOuter() {
|
||||
border-radius: $basicCr;
|
||||
box-shadow: $shdwMenuInner, $shdwMenu;
|
||||
background: $colorMenuBg;
|
||||
color: $colorMenuFg;
|
||||
//filter: $filterMenu; // 2022: causing all kinds of weird visual bugs in Chrome
|
||||
text-shadow: $shdwMenuText;
|
||||
padding: $interiorMarginSm;
|
||||
//box-shadow: $shdwMenu;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menuInner() {
|
||||
li {
|
||||
@include cControl();
|
||||
justify-content: start;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: nth($menuItemPad, 1) nth($menuItemPad, 2);
|
||||
transition: $transIn;
|
||||
white-space: nowrap;
|
||||
|
||||
@include hover {
|
||||
background: $colorMenuHovBg;
|
||||
color: $colorMenuHovFg;
|
||||
&:before {
|
||||
color: $colorMenuHovIc;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.c-menu--no-icon &) {
|
||||
&:before {
|
||||
color: $colorMenuIc;
|
||||
font-size: 1em;
|
||||
margin-right: $interiorMargin;
|
||||
min-width: 1em;
|
||||
}
|
||||
|
||||
&:not([class*='icon']):before {
|
||||
content: ''; // Enable :before so that menu items without an icon still indent properly
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/******************************************************** BUTTONS */
|
||||
// Optionally can include icon in :before via markup
|
||||
button {
|
||||
@ -333,6 +387,47 @@ input[type=number]::-webkit-outer-spin-button {
|
||||
// Small inputs, like small numerics
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
&--autocomplete {
|
||||
&__wrapper {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__input {
|
||||
min-width: 100px;
|
||||
|
||||
// Fend off from afford-arrow
|
||||
min-height: 2em;
|
||||
padding-right: 2.5em !important;
|
||||
}
|
||||
|
||||
&__options {
|
||||
@include menuOuter();
|
||||
@include menuInner();
|
||||
display: flex;
|
||||
|
||||
ul {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
li {
|
||||
&:before {
|
||||
color: var(--optionIconColor) !important;
|
||||
font-size: 0.8em !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__afford-arrow {
|
||||
font-size: 0.8em;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type=number].c-input-number--no-spinners {
|
||||
@ -470,59 +565,6 @@ select {
|
||||
}
|
||||
|
||||
/******************************************************** MENUS */
|
||||
@mixin menuOuter() {
|
||||
border-radius: $basicCr;
|
||||
background: $colorMenuBg;
|
||||
filter: $filterMenu;
|
||||
text-shadow: $shdwMenuText;
|
||||
padding: $interiorMarginSm;
|
||||
box-shadow: $shdwMenu;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menuInner() {
|
||||
li {
|
||||
@include cControl();
|
||||
justify-content: start;
|
||||
color: $colorMenuFg;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: nth($menuItemPad, 1) nth($menuItemPad, 2);
|
||||
transition: $transIn;
|
||||
white-space: nowrap;
|
||||
|
||||
@include hover {
|
||||
background: $colorMenuHovBg;
|
||||
color: $colorMenuHovFg;
|
||||
&:before {
|
||||
color: $colorMenuHovIc;
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
color: $colorMenuIc;
|
||||
font-size: 1em;
|
||||
margin-right: $interiorMargin;
|
||||
min-width: 1em;
|
||||
}
|
||||
|
||||
&:not([class*='icon']):before {
|
||||
content: ''; // Enable :before so that menu items without an icon still indent properly
|
||||
}
|
||||
|
||||
.menus-no-icon & {
|
||||
&:before { display: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.c-menu {
|
||||
@include menuOuter();
|
||||
@include menuInner();
|
||||
|
@ -322,39 +322,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
input {
|
||||
width: 226px;
|
||||
padding: 5px 0px 5px 7px;
|
||||
}
|
||||
.icon-arrow-down {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 210px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.autocompleteOptions {
|
||||
border: 1px solid $colorFormLines;
|
||||
border-radius: 5px;
|
||||
width: 224px;
|
||||
max-height: 170px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
li {
|
||||
border: 1px solid $colorFormLines;
|
||||
padding: 8px 0px 8px 5px;
|
||||
.optionText {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.optionPreSelected {
|
||||
background-color: $colorInspectorSectionHeaderBg;
|
||||
color: $colorInspectorSectionHeaderFg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/********* COMPACT FORM */
|
||||
// ul > li > label, control
|
||||
// Make a new UL for each form section
|
||||
|
@ -256,7 +256,7 @@ body.desktop .has-local-controls {
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
opacity: 0.5;
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
@ -45,6 +45,11 @@
|
||||
|
||||
.c-clock {
|
||||
> * + * { margin-left: $interiorMargin; }
|
||||
|
||||
&__timezone-selection .c-menu {
|
||||
// Menu for selecting timezones in properties dialog
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.c-timer {
|
||||
|
@ -250,6 +250,12 @@
|
||||
width: $plotSwatchD;
|
||||
}
|
||||
|
||||
@mixin dropDownArrowBg() {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3e%3cpath fill='%23#{svgColorFromHex($colorSelectArw)}' d='M5 5l5-5H0z'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-position: right .4em top 80%, 0 0;
|
||||
}
|
||||
|
||||
@mixin noColor() {
|
||||
// A "no fill/stroke" selection option. Used in palettes.
|
||||
$c: red;
|
||||
|
@ -283,10 +283,12 @@
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
|
||||
> [class*="__"] + [class*="__"] {
|
||||
margin-top: $interiorMarginSm;
|
||||
> * + * {
|
||||
margin-top: $interiorMargin;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,6 +35,7 @@
|
||||
@import "../ui/components/progress-bar.scss";
|
||||
@import "../ui/components/search.scss";
|
||||
@import "../ui/components/swim-lane/swimlane.scss";
|
||||
@import "../ui/components/tags/tags.scss";
|
||||
@import "../ui/components/toggle-switch.scss";
|
||||
@import "../ui/components/timesystem-axis.scss";
|
||||
@import "../ui/components/List/list-view.scss";
|
||||
@ -45,7 +46,7 @@
|
||||
@import "../ui/layout/create-button.scss";
|
||||
@import "../ui/layout/layout.scss";
|
||||
@import "../ui/layout/mct-tree.scss";
|
||||
@import "../ui/layout/mct-search.scss";
|
||||
@import "../ui/layout/search/search.scss";
|
||||
@import "../ui/layout/pane.scss";
|
||||
@import "../ui/layout/status-bar/indicators.scss";
|
||||
@import "../ui/layout/status-bar/notification-banner.scss";
|
||||
|
@ -42,6 +42,13 @@ export default {
|
||||
navigateToPath: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
139
src/ui/components/ObjectPath.vue
Normal file
139
src/ui/components/ObjectPath.vue
Normal file
@ -0,0 +1,139 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<ul
|
||||
v-if="originalPath.length"
|
||||
class="c-location"
|
||||
>
|
||||
<li
|
||||
v-for="pathObject in orderedOriginalPath"
|
||||
:key="pathObject.key"
|
||||
class="c-location__item"
|
||||
>
|
||||
<object-label
|
||||
:domain-object="pathObject.domainObject"
|
||||
:object-path="pathObject.objectPath"
|
||||
:read-only="readOnly"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ObjectLabel from './ObjectLabel.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ObjectLabel
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
enableSelectionListening: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default() {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
domainObject: {},
|
||||
originalPath: [],
|
||||
keyString: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
orderedOriginalPath() {
|
||||
return this.originalPath.slice().reverse();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.enableSelectionListening) {
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
this.updateSelection(this.openmct.selection.get());
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
},
|
||||
methods: {
|
||||
setOriginalPath(path, skipSlice) {
|
||||
let originalPath = path;
|
||||
|
||||
if (!skipSlice) {
|
||||
originalPath = path.slice(1, -1);
|
||||
}
|
||||
|
||||
this.originalPath = originalPath.map((domainObject, index, pathArray) => {
|
||||
let key = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
|
||||
return {
|
||||
domainObject,
|
||||
key,
|
||||
objectPath: pathArray.slice(index)
|
||||
};
|
||||
});
|
||||
},
|
||||
clearData() {
|
||||
this.domainObject = {};
|
||||
this.originalPath = [];
|
||||
this.keyString = '';
|
||||
},
|
||||
updateSelection(selection) {
|
||||
if (!selection.length || !selection[0].length) {
|
||||
this.clearData();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.domainObject = selection[0][0].context.item;
|
||||
let parentObject = selection[0][1];
|
||||
|
||||
if (!this.domainObject && parentObject && parentObject.context.item) {
|
||||
this.setOriginalPath([parentObject.context.item], true);
|
||||
this.keyString = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
|
||||
if (keyString && this.keyString !== keyString) {
|
||||
this.keyString = keyString;
|
||||
this.originalPath = [];
|
||||
|
||||
this.openmct.objects.getOriginalPath(this.keyString)
|
||||
.then(this.setOriginalPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
155
src/ui/components/tags/TagEditor.vue
Normal file
155
src/ui/components/tags/TagEditor.vue
Normal file
@ -0,0 +1,155 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div class="c-tag-applier">
|
||||
<TagSelection
|
||||
v-for="(addedTag, index) in addedTags"
|
||||
:key="index"
|
||||
:selected-tag="addedTag.newTag ? null : addedTag"
|
||||
:new-tag="addedTag.newTag"
|
||||
:added-tags="addedTags"
|
||||
@tagRemoved="tagRemoved"
|
||||
@tagAdded="tagAdded"
|
||||
/>
|
||||
<button
|
||||
v-show="!userAddingTag && !maxTagsAdded"
|
||||
class="c-tag-applier__add-btn c-icon-button c-icon-button--major icon-plus"
|
||||
title="Add new tag"
|
||||
@click="addTag"
|
||||
>
|
||||
<div class="c-icon-button__label">Add Tag</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TagSelection from './TagSelection.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TagSelection
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
annotationQuery: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
annotationType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
annotationSearchType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
targetSpecificDetails: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
domainObject: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
annontation: null,
|
||||
addedTags: [],
|
||||
userAddingTag: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
availableTags() {
|
||||
return this.openmct.annotation.getAvailableTags();
|
||||
},
|
||||
maxTagsAdded() {
|
||||
const availableTags = this.openmct.annotation.getAvailableTags();
|
||||
|
||||
return !(availableTags && availableTags.length && (this.addedTags.length < availableTags.length));
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
annotation: {
|
||||
handler() {
|
||||
this.tagsChanged(this.annotation.tags);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.annontation = await this.openmct.annotation.getAnnotation(this.annotationQuery, this.annotationSearchType);
|
||||
this.addAnnotationListener(this.annotation);
|
||||
if (this.annotation && this.annotation.tags) {
|
||||
this.tagsChanged(this.annotation.tags);
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
if (this.removeTagsListener) {
|
||||
this.removeTagsListener();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addAnnotationListener(annotation) {
|
||||
if (annotation && !this.removeTagsListener) {
|
||||
this.removeTagsListener = this.openmct.objects.observe(annotation, 'tags', this.tagsChanged);
|
||||
}
|
||||
},
|
||||
tagsChanged(newTags) {
|
||||
if (newTags.length < this.addedTags.length) {
|
||||
this.addedTags = this.addedTags.slice(0, newTags.length);
|
||||
}
|
||||
|
||||
for (let index = 0; index < newTags.length; index += 1) {
|
||||
this.$set(this.addedTags, index, newTags[index]);
|
||||
}
|
||||
},
|
||||
addTag() {
|
||||
const newTagValue = {
|
||||
newTag: true
|
||||
};
|
||||
this.addedTags.push(newTagValue);
|
||||
this.userAddingTag = true;
|
||||
},
|
||||
async tagRemoved(tagToRemove) {
|
||||
const existingAnnotation = await this.openmct.annotation.getAnnotation(this.annotationQuery, this.annotationSearchType);
|
||||
|
||||
return this.openmct.annotation.removeAnnotationTag(existingAnnotation, tagToRemove);
|
||||
},
|
||||
async tagAdded(newTag) {
|
||||
const existingAnnotation = await this.openmct.annotation.getAnnotation(this.annotationQuery, this.annotationSearchType);
|
||||
|
||||
const newAnnotation = await this.openmct.annotation.addAnnotationTag(existingAnnotation,
|
||||
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag);
|
||||
if (!this.annotation) {
|
||||
this.addAnnotationListener(newAnnotation);
|
||||
}
|
||||
|
||||
this.tagsChanged(newAnnotation.tags);
|
||||
this.userAddingTag = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
152
src/ui/components/tags/TagSelection.vue
Normal file
152
src/ui/components/tags/TagSelection.vue
Normal file
@ -0,0 +1,152 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div class="c-tag__parent">
|
||||
<div class="c-tag_selection">
|
||||
<AutoCompleteField
|
||||
v-if="newTag"
|
||||
ref="tagSelection"
|
||||
:model="availableTagModel"
|
||||
:place-holder-text="'Type to select tag'"
|
||||
class="c-tag-selection"
|
||||
:item-css-class="'icon-circle'"
|
||||
@onChange="tagSelected"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="c-tag"
|
||||
:style="{ background: selectedBackgroundColor, color: selectedForegroundColor }"
|
||||
>
|
||||
<div class="c-tag__label">{{ selectedTagLabel }} </div>
|
||||
<button
|
||||
class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle"
|
||||
@click="removeTag"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import AutoCompleteField from '../../../api/forms/components/controls/AutoCompleteField.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AutoCompleteField
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
addedTags: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
selectedTag: {
|
||||
type: String,
|
||||
default() {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
newTag: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
availableTagModel() {
|
||||
const availableTags = this.openmct.annotation.getAvailableTags().filter(tag => {
|
||||
return (!this.addedTags.includes(tag.id));
|
||||
}).map(tag => {
|
||||
return {
|
||||
name: tag.label,
|
||||
color: tag.backgroundColor,
|
||||
id: tag.id
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
options: availableTags
|
||||
};
|
||||
},
|
||||
selectedBackgroundColor() {
|
||||
const selectedTag = this.getAvailableTagByID(this.selectedTag);
|
||||
if (selectedTag) {
|
||||
return selectedTag.backgroundColor;
|
||||
} else {
|
||||
// missing available tag color, use default
|
||||
return '#00000';
|
||||
}
|
||||
},
|
||||
selectedForegroundColor() {
|
||||
const selectedTag = this.getAvailableTagByID(this.selectedTag);
|
||||
if (selectedTag) {
|
||||
return selectedTag.foregroundColor;
|
||||
} else {
|
||||
// missing available tag color, use default
|
||||
return '#FFFFF';
|
||||
}
|
||||
},
|
||||
selectedTagLabel() {
|
||||
const selectedTag = this.getAvailableTagByID(this.selectedTag);
|
||||
if (selectedTag) {
|
||||
return selectedTag.label;
|
||||
} else {
|
||||
// missing available tag color, use default
|
||||
return '¡UNKNOWN!';
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
methods: {
|
||||
getAvailableTagByID(tagID) {
|
||||
return this.openmct.annotation.getAvailableTags().find(tag => {
|
||||
return tag.id === tagID;
|
||||
});
|
||||
},
|
||||
removeTag() {
|
||||
this.$emit('tagRemoved', this.selectedTag);
|
||||
},
|
||||
tagSelected(autoField) {
|
||||
const tagAdded = autoField.model.options.find(option => {
|
||||
if (option.name === autoField.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
if (tagAdded) {
|
||||
this.$emit('tagAdded', tagAdded.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
67
src/ui/components/tags/tags.scss
Normal file
67
src/ui/components/tags/tags.scss
Normal file
@ -0,0 +1,67 @@
|
||||
/******************************* TAGS */
|
||||
.c-tag {
|
||||
border-radius: 10px; //TODO: convert to theme constant
|
||||
display: inline-flex;
|
||||
padding: 1px 10px; //TODO: convert to theme constant
|
||||
|
||||
> * + * {
|
||||
margin-left: $interiorMargin;
|
||||
}
|
||||
|
||||
&__remove-btn {
|
||||
color: inherit !important;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
padding: 1px !important;
|
||||
transition: $transIn;
|
||||
width: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* SEARCH RESULTS */
|
||||
&.--is-not-search-match {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/******************************* TAG EDITOR */
|
||||
.c-tag-applier {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
> * + * {
|
||||
margin-left: $interiorMargin;
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
&:before { font-size: 0.9em; }
|
||||
}
|
||||
|
||||
.c-tag {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-right: 3px !important;
|
||||
|
||||
&__remove-btn {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/******************************* HOVERS */
|
||||
.has-tag-applier {
|
||||
// Apply this class to all components that should trigger tag removal btn on hover
|
||||
&:hover {
|
||||
.c-tag__remove-btn {
|
||||
width: 1.1em;
|
||||
opacity: 0.7;
|
||||
transition: $transOut;
|
||||
}
|
||||
}
|
||||
}
|
@ -18,6 +18,9 @@
|
||||
}"
|
||||
>
|
||||
<CreateButton class="l-shell__create-button" />
|
||||
<GrandSearch
|
||||
ref="grand-search"
|
||||
/>
|
||||
<indicators class="l-shell__head-section l-shell__indicators" />
|
||||
<button
|
||||
class="l-shell__head__collapse-button c-icon-button"
|
||||
@ -122,6 +125,7 @@ import Inspector from '../inspector/Inspector.vue';
|
||||
import MctTree from './mct-tree.vue';
|
||||
import ObjectView from '../components/ObjectView.vue';
|
||||
import CreateButton from './CreateButton.vue';
|
||||
import GrandSearch from './search/GrandSearch.vue';
|
||||
import multipane from './multipane.vue';
|
||||
import pane from './pane.vue';
|
||||
import BrowseBar from './BrowseBar.vue';
|
||||
@ -136,6 +140,7 @@ export default {
|
||||
MctTree,
|
||||
ObjectView,
|
||||
CreateButton,
|
||||
GrandSearch,
|
||||
multipane,
|
||||
pane,
|
||||
BrowseBar,
|
||||
|
@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div class="c-search c-search--major">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
};
|
||||
</script>
|
@ -1,10 +0,0 @@
|
||||
/******************************* SEARCH */
|
||||
.c-search {
|
||||
input[type=search] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--major {
|
||||
display: flex;
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@
|
||||
class="c-tree-and-search__search"
|
||||
>
|
||||
<search
|
||||
v-show="isSelectorTree"
|
||||
ref="shell-search"
|
||||
class="c-search"
|
||||
:value="searchValue"
|
||||
|
148
src/ui/layout/search/AnnotationSearchResult.vue
Normal file
148
src/ui/layout/search/AnnotationSearchResult.vue
Normal file
@ -0,0 +1,148 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="c-gsearch-result c-gsearch-result--annotation"
|
||||
>
|
||||
<div
|
||||
class="c-gsearch-result__type-icon"
|
||||
:class="resultTypeIcon"
|
||||
></div>
|
||||
<div
|
||||
class="c-gsearch-result__body"
|
||||
aria-label="Annotation Search Result"
|
||||
>
|
||||
<div
|
||||
class="c-gsearch-result__title"
|
||||
@click="clickedResult"
|
||||
>
|
||||
{{ getResultName }}
|
||||
</div>
|
||||
|
||||
<ObjectPath
|
||||
ref="location"
|
||||
:read-only="false"
|
||||
/>
|
||||
|
||||
<div class="c-gsearch-result__tags">
|
||||
<div
|
||||
v-for="(tag, index) in result.fullTagModels"
|
||||
:key="index"
|
||||
class="c-tag"
|
||||
:class="{ '--is-not-search-match': !isSearchMatched(tag) }"
|
||||
:style="{ backgroundColor: tag.backgroundColor, color: tag.foregroundColor }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c-gsearch-result__more-options-button">
|
||||
<button class="c-icon-button icon-3-dots"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ObjectPath from '../../components/ObjectPath.vue';
|
||||
import objectPathToUrl from '../../../tools/url';
|
||||
|
||||
export default {
|
||||
name: 'AnnotationSearchResult',
|
||||
components: {
|
||||
ObjectPath
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
result: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
domainObject() {
|
||||
return this.result.targetModels[0];
|
||||
},
|
||||
getResultName() {
|
||||
if (this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.NOTEBOOK) {
|
||||
const targetID = Object.keys(this.result.targets)[0];
|
||||
const entryIdToFind = this.result.targets[targetID].entryId;
|
||||
const notebookModel = this.result.targetModels[0].configuration.entries;
|
||||
|
||||
const sections = Object.values(notebookModel);
|
||||
for (const section of sections) {
|
||||
const pages = Object.values(section);
|
||||
for (const entries of pages) {
|
||||
for (const entry of entries) {
|
||||
if (entry.id === entryIdToFind) {
|
||||
return entry.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Could not find any matching Notebook entries";
|
||||
} else {
|
||||
return this.result.targetModels[0].name;
|
||||
}
|
||||
},
|
||||
resultTypeIcon() {
|
||||
return this.openmct.types.get(this.result.type).definition.cssClass;
|
||||
},
|
||||
tagBackgroundColor() {
|
||||
return this.result.fullTagModels[0].backgroundColor;
|
||||
},
|
||||
tagForegroundColor() {
|
||||
return this.result.fullTagModels[0].foregroundColor;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const selectionObject = {
|
||||
context: {
|
||||
item: this.domainObject
|
||||
}
|
||||
};
|
||||
this.$refs.location.updateSelection([[selectionObject]]);
|
||||
},
|
||||
methods: {
|
||||
clickedResult() {
|
||||
const objectPath = this.domainObject.originalPath;
|
||||
const resultUrl = objectPathToUrl(this.openmct, objectPath);
|
||||
this.openmct.router.navigate(resultUrl);
|
||||
},
|
||||
isSearchMatched(tag) {
|
||||
if (this.result.matchingTagKeys) {
|
||||
return this.result.matchingTagKeys.includes(tag.tagID);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
145
src/ui/layout/search/GrandSearch.vue
Normal file
145
src/ui/layout/search/GrandSearch.vue
Normal file
@ -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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="GrandSearch"
|
||||
aria-label="OpenMCT Search"
|
||||
class="c-gsearch"
|
||||
role="searchbox"
|
||||
>
|
||||
<search
|
||||
ref="shell-search"
|
||||
class="c-gsearch__input"
|
||||
tabindex="0"
|
||||
:value="searchValue"
|
||||
@input="searchEverything"
|
||||
@clear="searchEverything"
|
||||
@click="showSearchResults"
|
||||
/>
|
||||
<SearchResultsDropDown
|
||||
ref="searchResultsDropDown"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import search from '../../components/search.vue';
|
||||
import SearchResultsDropDown from './SearchResultsDropDown.vue';
|
||||
|
||||
export default {
|
||||
name: 'GrandSearch',
|
||||
components: {
|
||||
search,
|
||||
SearchResultsDropDown
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchValue: '',
|
||||
searchLoading: false,
|
||||
annotationSearchResults: [],
|
||||
objectSearchResults: []
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
destroyed() {
|
||||
document.body.removeEventListener('click', this.handleOutsideClick);
|
||||
},
|
||||
methods: {
|
||||
async searchEverything(value) {
|
||||
// if an abort controller exists, regardless of the value passed in,
|
||||
// there is an active search that should be canceled
|
||||
if (this.abortSearchController) {
|
||||
this.abortSearchController.abort();
|
||||
delete this.abortSearchController;
|
||||
}
|
||||
|
||||
this.searchValue = value;
|
||||
this.searchLoading = true;
|
||||
// clear any previous search results
|
||||
this.annotationSearchResults = [];
|
||||
this.objectSearchResults = [];
|
||||
|
||||
if (this.searchValue) {
|
||||
await this.getSearchResults();
|
||||
} else {
|
||||
this.searchLoading = false;
|
||||
this.$refs.searchResultsDropDown.showResults(this.annotationSearchResults, this.objectSearchResults);
|
||||
}
|
||||
},
|
||||
getPathsForObjects(objectsNeedingPaths) {
|
||||
return Promise.all(objectsNeedingPaths.map(async (domainObject) => {
|
||||
const keyStringForObject = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
const originalPathObjects = await this.openmct.objects.getOriginalPath(keyStringForObject);
|
||||
|
||||
return {
|
||||
originalPath: originalPathObjects,
|
||||
...domainObject
|
||||
};
|
||||
}));
|
||||
},
|
||||
async getSearchResults() {
|
||||
// an abort controller will be passed in that will be used
|
||||
// to cancel an active searches if necessary
|
||||
this.abortSearchController = new AbortController();
|
||||
const abortSignal = this.abortSearchController.signal;
|
||||
try {
|
||||
this.annotationSearchResults = await this.openmct.annotation.searchForTags(this.searchValue, abortSignal);
|
||||
const fullObjectSearchResults = await Promise.all(this.openmct.objects.search(this.searchValue, abortSignal));
|
||||
const aggregatedObjectSearchResults = fullObjectSearchResults.flat();
|
||||
const aggregatedObjectSearchResultsWithPaths = await this.getPathsForObjects(aggregatedObjectSearchResults);
|
||||
const filterAnnotations = aggregatedObjectSearchResultsWithPaths.filter(result => {
|
||||
return result.type !== 'annotation';
|
||||
});
|
||||
this.objectSearchResults = filterAnnotations;
|
||||
this.showSearchResults();
|
||||
} catch (error) {
|
||||
console.error(`😞 Error searching`, error);
|
||||
this.searchLoading = false;
|
||||
|
||||
if (this.abortSearchController) {
|
||||
delete this.abortSearchController;
|
||||
}
|
||||
}
|
||||
},
|
||||
showSearchResults() {
|
||||
this.$refs.searchResultsDropDown.showResults(this.annotationSearchResults, this.objectSearchResults);
|
||||
document.body.addEventListener('click', this.handleOutsideClick);
|
||||
},
|
||||
handleOutsideClick(event) {
|
||||
// if click event is detected outside the dropdown while the
|
||||
// dropdown is visible, this will collapse the dropdown.
|
||||
if (this.$refs.GrandSearch) {
|
||||
const clickedInsideDropdown = this.$refs.GrandSearch.contains(event.target);
|
||||
if (!clickedInsideDropdown && this.$refs.searchResultsDropDown._data.resultsShown) {
|
||||
this.$refs.searchResultsDropDown._data.resultsShown = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
102
src/ui/layout/search/ObjectSearchResult.vue
Normal file
102
src/ui/layout/search/ObjectSearchResult.vue
Normal file
@ -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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="c-gsearch-result c-gsearch-result--object"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
class="c-gsearch-result__type-icon"
|
||||
:class="resultTypeIcon"
|
||||
></div>
|
||||
<div
|
||||
class="c-gsearch-result__body"
|
||||
role="option"
|
||||
:aria-label="`${resultName} ${resultType} result`"
|
||||
>
|
||||
<div
|
||||
class="c-gsearch-result__title"
|
||||
:name="resultName"
|
||||
@click="clickedResult"
|
||||
>
|
||||
{{ resultName }}
|
||||
</div>
|
||||
|
||||
<ObjectPath
|
||||
ref="objectpath"
|
||||
:read-only="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="c-gsearch-result__more-options-button">
|
||||
<button class="c-icon-button icon-3-dots"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ObjectPath from '../../components/ObjectPath.vue';
|
||||
import objectPathToUrl from '../../../tools/url';
|
||||
|
||||
export default {
|
||||
name: 'ObjectSearchResult',
|
||||
components: {
|
||||
ObjectPath
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
result: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resultName() {
|
||||
return this.result.name;
|
||||
},
|
||||
resultTypeIcon() {
|
||||
return this.openmct.types.get(this.result.type).definition.cssClass;
|
||||
},
|
||||
resultType() {
|
||||
return this.result.type;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const selectionObject = {
|
||||
context: {
|
||||
item: this.result
|
||||
}
|
||||
};
|
||||
this.$refs.objectpath.updateSelection([[selectionObject]]);
|
||||
},
|
||||
methods: {
|
||||
clickedResult() {
|
||||
const objectPath = this.result.originalPath;
|
||||
const resultUrl = objectPathToUrl(this.openmct, objectPath);
|
||||
this.openmct.router.navigate(resultUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
99
src/ui/layout/search/SearchResultsDropDown.vue
Normal file
99
src/ui/layout/search/SearchResultsDropDown.vue
Normal file
@ -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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="(annotationResults && annotationResults.length) ||
|
||||
(objectResults && objectResults.length)"
|
||||
class="c-gsearch__dropdown"
|
||||
>
|
||||
<div
|
||||
v-show="resultsShown"
|
||||
class="c-gsearch__results-wrapper"
|
||||
>
|
||||
<div class="c-gsearch__results">
|
||||
<div
|
||||
v-if="objectResults && objectResults.length"
|
||||
ref="objectResults"
|
||||
class="c-gsearch__results-section"
|
||||
role="listbox"
|
||||
>
|
||||
<div class="c-gsearch__results-section-title">Object Results</div>
|
||||
<object-search-result
|
||||
v-for="(objectResult, index) in objectResults"
|
||||
:key="index"
|
||||
:result="objectResult"
|
||||
@click.native="selectedResult"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="annotationResults && annotationResults.length"
|
||||
ref="annotationResults"
|
||||
>
|
||||
<div class="c-gsearch__results-section-title">Annotation Results</div>
|
||||
<annotation-search-result
|
||||
v-for="(annotationResult, index) in annotationResults"
|
||||
:key="index"
|
||||
:result="annotationResult"
|
||||
@click.native="selectedResult"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AnnotationSearchResult from './AnnotationSearchResult.vue';
|
||||
import ObjectSearchResult from './ObjectSearchResult.vue';
|
||||
|
||||
export default {
|
||||
name: 'SearchResultsDropDown',
|
||||
components: {
|
||||
AnnotationSearchResult,
|
||||
ObjectSearchResult
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resultsShown: false,
|
||||
annotationResults: [],
|
||||
objectResults: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
selectedResult() {
|
||||
this.resultsShown = false;
|
||||
},
|
||||
showResults(passedAnnotationResults, passedObjectResults) {
|
||||
if ((passedAnnotationResults && passedAnnotationResults.length)
|
||||
|| (passedObjectResults && passedObjectResults.length)) {
|
||||
this.resultsShown = true;
|
||||
this.annotationResults = passedAnnotationResults;
|
||||
this.objectResults = passedObjectResults;
|
||||
} else {
|
||||
this.resultsShown = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
template: 'Dropdown'
|
||||
};
|
||||
</script>
|
137
src/ui/layout/search/search.scss
Normal file
137
src/ui/layout/search/search.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -33,6 +33,10 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
showContextMenu(event) {
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user