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:
Scott Bell 2022-06-03 22:12:42 +02:00 committed by GitHub
parent 2aec1ee854
commit 3c70cf1767
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 3220 additions and 929 deletions

View File

@ -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: [

View File

@ -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([

View File

@ -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(),

View File

@ -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([

View File

@ -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();
});
});

View File

@ -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

View 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);
});
};
}

View 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"
}
}
}

View File

@ -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());

View File

@ -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());

View 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;
}
}

View 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);
});
});
});

View File

@ -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
};
});

View File

@ -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>

View File

@ -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() {

View File

@ -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 };
}
}
}
};

View File

@ -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

View File

@ -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

View File

@ -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 () => {

View File

@ -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',

View File

@ -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;

View File

@ -41,6 +41,10 @@ export default class LocalStorageObjectProvider {
}
}
getAllObjects() {
return this.getSpaceAsObject();
}
create(object) {
return this.persistObject(object);
}

View File

@ -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();

View File

@ -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');
},

View File

@ -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(() => {

View File

@ -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) {

View File

@ -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;

View File

@ -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! 🏆

View File

@ -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;

View File

@ -69,7 +69,8 @@
}
&.selected {
background: #1ac6ff; // this should be a variable... CHARLESSSSSS
background: $colorKey;
color: $colorKeyFg;
}
}

View File

@ -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%);

View File

@ -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%);

View File

@ -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%);

View File

@ -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();

View File

@ -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

View File

@ -256,7 +256,7 @@ body.desktop .has-local-controls {
}
::placeholder {
opacity: 0.5;
opacity: 0.7;
font-style: italic;
}

View File

@ -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 {

View File

@ -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;

View File

@ -283,10 +283,12 @@
}
&__content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
> [class*="__"] + [class*="__"] {
margin-top: $interiorMarginSm;
> * + * {
margin-top: $interiorMargin;
}
}

View File

@ -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";

View File

@ -42,6 +42,13 @@ export default {
navigateToPath: {
type: String,
default: undefined
},
readOnly: {
type: Boolean,
required: false,
default() {
return false;
}
}
},
data() {

View 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>

View 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>

View 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>

View 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;
}
}
}

View File

@ -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,

View File

@ -1,13 +0,0 @@
<template>
<div class="c-search c-search--major">
<input
type="search"
placeholder="Search"
>
</div>
</template>
<script>
export default {
};
</script>

View File

@ -1,10 +0,0 @@
/******************************* SEARCH */
.c-search {
input[type=search] {
width: 100%;
}
&--major {
display: flex;
}
}

View File

@ -12,6 +12,7 @@
class="c-tree-and-search__search"
>
<search
v-show="isSelectorTree"
ref="shell-search"
class="c-search"
:value="searchValue"

View 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>

View 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>

View 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>

View 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>

View 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;
}
}

View File

@ -33,6 +33,10 @@ export default {
},
methods: {
showContextMenu(event) {
if (this.readOnly) {
return;
}
event.preventDefault();
event.stopPropagation();

View File

@ -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}`);
}