Compare commits

...

8 Commits

Author SHA1 Message Date
Shefali
cfd75bd5d7 Add notes for clipping text to fit activity rectangle widths 2023-02-27 10:40:19 -08:00
Simon Waldherr
ceffee9f22 docs: Remove LGTM badge from README (#6072)
Update README.md

remove LGTM-Badge - Sadly, LGTM.com is no longer available
2023-02-22 23:09:31 +00:00
Jesse Mazzella
a08ccd80dc chore: bump version to 2.2.0-SNAPSHOT (#6341)
Co-authored-by: John Hill <john.c.hill@nasa.gov>
2023-02-15 21:17:21 +00:00
Scott Bell
3509eacdec Debounce search results (#6259)
* throttle search results to one a second

* changed to use custom debounce due to async

* attempt to fix flakey test

* attempt to fix flakey test

* revert test changes

* reduce debounce time and add e2e test

* allow canceling of timeout

* removed debug

* make search result e2e tests more stable

* make drop down selector a constant

* address pr comments
2023-02-15 21:50:03 +01:00
Jamie V
d4496cba41 [Notebook] Links, Restricted Notebook Links, Search links (#6344)
* big simplification and enhancement to highlight component and added formatting for locked notebooks as well

* fix typo, fix logic
2023-02-15 11:05:28 -08:00
Shefali Joshi
64f300d466 Emit indices of out of order telemetry collection items (#6342)
* Emit the indices of items added by the telemetry collections
* Handle added item indices for imagery
2023-02-14 15:25:23 -08:00
Scott Bell
8de24a109a Have clicking on annotation search result use the preview action if in edit mode (#6331)
* fix preview issue

* reenabled test suite after testing 30x in parallel

* add e2e test and disable unit tests for search

* change to const
2023-02-14 11:29:18 -08:00
Shefali Joshi
6d62e0e73c Decouple removal of independent time context for a view from refreshing context for other views (#6334)
* Decouple removing the context for a view from refreshing the context of views to get their upstream contexts

* Add test for clicking on an item in the elements pool for a preview

* Add test to ensure independent time context for items with no parents works as expected
2023-02-13 21:19:26 +00:00
18 changed files with 330 additions and 159 deletions

View File

@@ -1,4 +1,4 @@
# Open MCT [![license](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/nasa/openmct.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/nasa/openmct/context:javascript) [![codecov](https://codecov.io/gh/nasa/openmct/branch/master/graph/badge.svg?token=7DQIipp3ej)](https://codecov.io/gh/nasa/openmct) [![This project is using Percy.io for visual regression testing.](https://percy.io/static/images/percy-badge.svg)](https://percy.io/b2e34b17/openmct) [![npm version](https://img.shields.io/npm/v/openmct.svg)](https://www.npmjs.com/package/openmct)
# Open MCT [![license](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) [![codecov](https://codecov.io/gh/nasa/openmct/branch/master/graph/badge.svg?token=7DQIipp3ej)](https://codecov.io/gh/nasa/openmct) [![This project is using Percy.io for visual regression testing.](https://percy.io/static/images/percy-badge.svg)](https://percy.io/b2e34b17/openmct) [![npm version](https://img.shields.io/npm/v/openmct.svg)](https://www.npmjs.com/package/openmct)
Open MCT (Open Mission Control Technologies) is a next-generation mission control framework for visualization of data on desktop and mobile devices. It is developed at NASA's Ames Research Center, and is being used by NASA for data analysis of spacecraft missions, as well as planning and operation of experimental rover systems. As a generalizable and open source framework, Open MCT could be used as the basis for building applications for planning, operation, and analysis of any systems producing telemetry data.
@@ -98,7 +98,7 @@ To run the performance tests:
The test suite is configured to all tests localed in `e2e/tests/` ending in `*.e2e.spec.js`. For more about the e2e test suite, please see the [README](./e2e/README.md)
### Security Tests
Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/) and our overall security report is available on [LGTM](https://lgtm.com/projects/g/nasa/openmct/). The list of CWE coverage items is avaiable in the [CodeQL docs](https://codeql.github.com/codeql-query-help/javascript-cwe/). The CodeQL workflow is specified in the [CodeQL analysis file](./.github/workflows/codeql-analysis.yml) and the custom [CodeQL config](./.github/codeql/codeql-config.yml).
Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/). The list of CWE coverage items is avaiable in the [CodeQL docs](https://codeql.github.com/codeql-query-help/javascript-cwe/). The CodeQL workflow is specified in the [CodeQL analysis file](./.github/workflows/codeql-analysis.yml) and the custom [CodeQL config](./.github/codeql/codeql-config.yml).
### Test Reporting and Code Coverage

View File

@@ -111,7 +111,7 @@ test.describe('Tagging in Notebooks @addInit', () => {
await expect(page.locator('[aria-label="Autocomplete Options"]')).not.toContainText("Driving");
await expect(page.locator('[aria-label="Autocomplete Options"]')).toContainText("Drilling");
});
test('Can search for tags', async ({ page }) => {
test('Can search for tags and preview works properly', async ({ page }) => {
await createNotebookEntryAndTags(page);
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
@@ -126,6 +126,19 @@ test.describe('Tagging in Notebooks @addInit', () => {
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq');
await expect(page.locator('text=No results found')).toBeVisible();
await createDomainObjectWithDefaults(page, {
type: 'Display Layout'
});
// Go back into edit mode for the display layout
await page.locator('button[title="Edit"]').click();
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Sc');
await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science");
await page.getByText('Entry 0').click();
await expect(page.locator('.js-preview-window')).toBeVisible();
});
test('Can delete tags', async ({ page }) => {

View File

@@ -140,4 +140,61 @@ test.describe('Overlay Plot', () => {
expect(yAxis3Group.getByRole('listitem', { name: swgB.name })).toBeTruthy();
expect(yAxis3Group.getByRole('listitem').nth(0).getByText(swgB.name)).toBeTruthy();
});
test('Clicking on an item in the elements pool brings up the plot preview with data points', async ({ page }) => {
const overlayPlot = await createDomainObjectWithDefaults(page, {
type: "Overlay Plot"
});
const swgA = await createDomainObjectWithDefaults(page, {
type: "Sine Wave Generator",
parent: overlayPlot.uuid
});
await page.goto(overlayPlot.url);
await page.click('button[title="Edit"]');
await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).click();
await page.locator('.js-overlay canvas').nth(1);
const plotPixelSize = await getCanvasPixelsWithData(page);
expect(plotPixelSize).toBeGreaterThan(0);
});
});
/**
* @param {import('@playwright/test').Page} page
*/
async function getCanvasPixelsWithData(page) {
const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getCanvasValue', resolve));
await page.evaluate(() => {
// The document canvas is where the plot points and lines are drawn.
// The only way to access the canvas is using document (using page.evaluate)
let data;
let canvas;
let ctx;
canvas = document.querySelector('.js-overlay canvas');
ctx = canvas.getContext('2d');
data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const imageDataValues = Object.values(data);
let plotPixels = [];
// Each pixel consists of four values within the ImageData.data array. The for loop iterates by multiples of four.
// The values associated with each pixel are R (red), G (green), B (blue), and A (alpha), in that order.
for (let i = 0; i < imageDataValues.length;) {
if (imageDataValues[i] > 0) {
plotPixels.push({
startIndex: i,
endIndex: i + 3,
value: `rgb(${imageDataValues[i]}, ${imageDataValues[i + 1]}, ${imageDataValues[i + 2]}, ${imageDataValues[i + 3]})`
});
}
i = i + 4;
}
window.getCanvasValue(plotPixels.length);
});
return getTelemValuePromise;
}

View File

@@ -28,6 +28,14 @@ const { createDomainObjectWithDefaults } = require('../../appActions');
const { v4: uuid } = require('uuid');
test.describe('Grand Search', () => {
const searchResultSelector = '.c-gsearch-result__title';
const searchResultDropDownSelector = '.c-gsearch__results';
test.beforeEach(async ({ page }) => {
// Go to baseURL
await page.goto("./", { waitUntil: "networkidle" });
});
test('Can search for objects, and subsequent search dropdown behaves properly', async ({ page, openmctConfig }) => {
const { myItemsFolderName } = openmctConfig;
@@ -89,15 +97,8 @@ test.describe('Grand Search', () => {
await expect(page.locator('[aria-label="Search Result"] >> nth=2')).toContainText(`Clock C ${myItemsFolderName} Red Folder Blue Folder`);
await expect(page.locator('[aria-label="Search Result"] >> nth=3')).toContainText(`Clock D ${myItemsFolderName} Red Folder Blue Folder`);
});
});
test.describe("Search Tests @unstable", () => {
const searchResultSelector = '.c-gsearch-result__title';
test('Validate empty search result', async ({ page }) => {
// Go to baseURL
await page.goto("./", { waitUntil: "networkidle" });
// Invalid search for objects
await page.type("input[type=search]", 'not found');
@@ -105,7 +106,7 @@ test.describe("Search Tests @unstable", () => {
await waitForSearchCompletion(page);
// Get the search results
const searchResults = await page.locator(searchResultSelector);
const searchResults = page.locator(searchResultSelector);
// Verify that no results are found
expect(await searchResults.count()).toBe(0);
@@ -115,9 +116,6 @@ test.describe("Search Tests @unstable", () => {
});
test('Validate single object in search result @couchdb', async ({ page }) => {
//Go to baseURL
await page.goto("./", { waitUntil: "networkidle" });
// Create a folder object
const folderName = uuid();
await createDomainObjectWithDefaults(page, {
@@ -139,21 +137,56 @@ test.describe("Search Tests @unstable", () => {
await expect(searchResults).toHaveText(folderName);
});
test('Search results are debounced @couchdb', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6179'
});
await createObjectsForSearch(page);
let networkRequests = [];
page.on('request', (request) => {
const searchRequest = request.url().endsWith('_find');
const fetchRequest = request.resourceType() === 'fetch';
if (searchRequest && fetchRequest) {
networkRequests.push(request);
}
});
// Full search for object
await page.type("input[type=search]", 'Clock', { delay: 100 });
// Wait for search to finish
await waitForSearchCompletion(page);
// Network requests for the composite telemetry with multiple items should be:
// 1. batched request for latest telemetry using the bulk API
expect(networkRequests.length).toBe(1);
const searchResultDropDown = await page.locator(searchResultDropDownSelector);
await expect(searchResultDropDown).toContainText('Clock A');
});
test("Validate multiple objects in search results return partial matches", async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/4667'
});
// Go to baseURL
await page.goto("/", { waitUntil: "networkidle" });
// Create folder objects
const folderName = "e928a26e-e924-4ea0";
const folderName1 = "e928a26e-e924-4ea0";
const folderName2 = "e928a26e-e924-4001";
await createFolderObject(page, folderName);
await createFolderObject(page, folderName2);
await createDomainObjectWithDefaults(page, {
type: 'Folder',
name: folderName1
});
await createDomainObjectWithDefaults(page, {
type: 'Folder',
name: folderName2
});
// Partial search for objects
await page.type("input[type=search]", 'e928a26e');
@@ -161,36 +194,22 @@ test.describe("Search Tests @unstable", () => {
// Wait for search to finish
await waitForSearchCompletion(page);
// Get the search results
const searchResults = await page.locator(searchResultSelector);
const searchResultDropDown = await page.locator(searchResultDropDownSelector);
// Verify that the search result/s correctly match the search query
await expect(searchResultDropDown).toContainText(folderName1);
await expect(searchResultDropDown).toContainText(folderName2);
// Get the search results
const searchResults = page.locator(searchResultSelector);
// Verify that two results are found
expect(await searchResults.count()).toBe(2);
await expect(await searchResults.first()).toHaveText(folderName);
await expect(await searchResults.last()).toHaveText(folderName2);
});
});
async function createFolderObject(page, folderName) {
// Open Create menu
await page.locator('button:has-text("Create")').click();
// Select Folder object
await page.locator('text=Folder').nth(1).click();
// Click folder title to enter edit mode
await page.locator('text=Properties Title Notes >> input[type="text"]').click();
// Enter folder name
await page.locator('text=Properties Title Notes >> input[type="text"]').fill(folderName);
// Create folder object
await page.locator('button:has-text("OK")').click();
}
async function waitForSearchCompletion(page) {
// Wait loading spinner to disappear
await page.waitForSelector('.c-tree-and-search__loading', { state: 'detached' });
await page.waitForSelector('.search-finished');
}
/**
@@ -198,9 +217,6 @@ async function waitForSearchCompletion(page) {
* @param {import('@playwright/test').Page} page
*/
async function createObjectsForSearch(page) {
//Go to baseURL
await page.goto('./', { waitUntil: 'networkidle' });
const redFolder = await createDomainObjectWithDefaults(page, {
type: 'Folder',
name: 'Red Folder'

View File

@@ -1,6 +1,6 @@
{
"name": "openmct",
"version": "2.1.6-SNAPSHOT",
"version": "2.2.0-SNAPSHOT",
"description": "The Open MCT core platform",
"devDependencies": {
"@babel/eslint-parser": "7.18.9",

View File

@@ -180,6 +180,7 @@ export default class TelemetryCollection extends EventEmitter {
let beforeStartOfBounds;
let afterEndOfBounds;
let added = [];
let addedIndices = [];
// loop through, sort and dedupe
for (let datum of data) {
@@ -212,6 +213,7 @@ export default class TelemetryCollection extends EventEmitter {
let index = endIndex || startIndex;
this.boundedTelemetry.splice(index, 0, datum);
addedIndices.push(index);
added.push(datum);
}
@@ -230,7 +232,7 @@ export default class TelemetryCollection extends EventEmitter {
this.emit('add', this.boundedTelemetry);
}
} else {
this.emit('add', added);
this.emit('add', added, addedIndices);
}
}
}
@@ -330,7 +332,8 @@ export default class TelemetryCollection extends EventEmitter {
this.boundedTelemetry = added;
}
this.emit('add', added);
// Assumption is that added will be of length 1 here, so just send the last index of the boundedTelemetry in the add event
this.emit('add', added, [this.boundedTelemetry.length]);
}
} else {
// user bounds change, reset

View File

@@ -32,14 +32,18 @@ class IndependentTimeContext extends TimeContext {
this.openmct = openmct;
this.unlisteners = [];
this.globalTimeContext = globalTimeContext;
this.upstreamTimeContext = undefined;
// We always start with the global time context.
// This upstream context will be undefined when an independent time context is added later.
this.upstreamTimeContext = this.globalTimeContext;
this.objectPath = objectPath;
this.refreshContext = this.refreshContext.bind(this);
this.resetContext = this.resetContext.bind(this);
this.removeIndependentContext = this.removeIndependentContext.bind(this);
this.refreshContext();
this.globalTimeContext.on('refreshContext', this.refreshContext);
this.globalTimeContext.on('removeOwnContext', this.removeIndependentContext);
}
bounds(newBounds) {
@@ -202,10 +206,16 @@ class IndependentTimeContext extends TimeContext {
}
getUpstreamContext() {
// If a view has an independent context, don't return an upstream context
// Be aware that when a new independent time context is created, we assign the global context as default
if (this.hasOwnContext()) {
return undefined;
}
let timeContext = this.globalTimeContext;
this.objectPath.some((item, index) => {
const key = this.openmct.objects.makeKeyString(item.identifier);
//first index is the view object itself
// we're only interested in parents, not self, so index > 0
const itemContext = this.globalTimeContext.independentContexts.get(key);
if (index > 0 && itemContext && itemContext.hasOwnContext()) {
//upstream time context
@@ -219,6 +229,43 @@ class IndependentTimeContext extends TimeContext {
return timeContext;
}
/**
* Set the time context of a view to follow any upstream time contexts as necessary (defaulting to the global context)
* This needs to be separate from refreshContext
*/
removeIndependentContext(viewKey) {
const key = this.openmct.objects.makeKeyString(this.objectPath[0].identifier);
if (viewKey && key === viewKey) {
//this is necessary as the upstream context gets reassigned after this
this.stopFollowingTimeContext();
let timeContext = this.globalTimeContext;
this.objectPath.some((item, index) => {
const objectKey = this.openmct.objects.makeKeyString(item.identifier);
// we're only interested in any parents, not self, so index > 0
const itemContext = this.globalTimeContext.independentContexts.get(objectKey);
if (index > 0 && itemContext && itemContext.hasOwnContext()) {
//upstream time context
timeContext = itemContext;
return true;
}
return false;
});
this.upstreamTimeContext = timeContext;
this.followTimeContext();
// Emit bounds so that views that are changing context get the upstream bounds
this.emit('bounds', this.bounds());
// now that the view's context is set, tell others to check theirs in case they were following this view's context.
this.globalTimeContext.emit('refreshContext', viewKey);
}
}
}
export default IndependentTimeContext;

View File

@@ -149,7 +149,7 @@ class TimeAPI extends GlobalTimeContext {
return () => {
//follow any upstream time context
this.emit('refreshContext');
this.emit('removeOwnContext', key);
};
}

View File

@@ -117,22 +117,58 @@ describe("The Independent Time API", function () {
});
it("uses an object's independent time context if the parent doesn't have one", () => {
const domainObjectKey2 = `${domainObjectKey}-2`;
const domainObjectKey3 = `${domainObjectKey}-3`;
let timeContext = api.getContextForView([{
identifier: {
namespace: '',
key: domainObjectKey
}
}, {
}]);
let timeContext2 = api.getContextForView([{
identifier: {
namespace: '',
key: 'blah'
key: domainObjectKey2
}
}]);
let timeContext3 = api.getContextForView([{
identifier: {
namespace: '',
key: domainObjectKey3
}
}]);
// all bounds follow global time context
expect(timeContext.bounds()).toEqual(bounds);
expect(timeContext2.bounds()).toEqual(bounds);
expect(timeContext3.bounds()).toEqual(bounds);
// only first item has own context
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
expect(timeContext.bounds()).toEqual(independentBounds);
expect(timeContext2.bounds()).toEqual(bounds);
expect(timeContext3.bounds()).toEqual(bounds);
// first and second item have own context
let destroyTimeContext2 = api.addIndependentContext(domainObjectKey2, independentBounds);
expect(timeContext.bounds()).toEqual(independentBounds);
expect(timeContext2.bounds()).toEqual(independentBounds);
expect(timeContext3.bounds()).toEqual(bounds);
// all items have own time context
let destroyTimeContext3 = api.addIndependentContext(domainObjectKey3, independentBounds);
expect(timeContext.bounds()).toEqual(independentBounds);
expect(timeContext2.bounds()).toEqual(independentBounds);
expect(timeContext3.bounds()).toEqual(independentBounds);
//remove own contexts one at a time - should revert to global time context
destroyTimeContext();
expect(timeContext.bounds()).toEqual(bounds);
expect(timeContext2.bounds()).toEqual(independentBounds);
expect(timeContext3.bounds()).toEqual(independentBounds);
destroyTimeContext2();
expect(timeContext.bounds()).toEqual(bounds);
expect(timeContext2.bounds()).toEqual(bounds);
expect(timeContext3.bounds()).toEqual(independentBounds);
destroyTimeContext3();
expect(timeContext.bounds()).toEqual(bounds);
expect(timeContext2.bounds()).toEqual(bounds);
expect(timeContext3.bounds()).toEqual(bounds);
});
it("Allows setting of valid bounds", function () {

View File

@@ -76,9 +76,14 @@ export default {
this.telemetryCollection.destroy();
},
methods: {
dataAdded(dataToAdd) {
const normalizedDataToAdd = dataToAdd.map(datum => this.normalizeDatum(datum));
this.imageHistory = this.imageHistory.concat(normalizedDataToAdd);
dataAdded(addedItems, addedItemIndices) {
const normalizedDataToAdd = addedItems.map(datum => this.normalizeDatum(datum));
let newImageHistory = this.imageHistory.slice();
normalizedDataToAdd.forEach(((datum, index) => {
newImageHistory.splice(addedItemIndices[index] ?? -1, 0, datum);
}));
//Assign just once so imageHistory watchers don't get called too often
this.imageHistory = newImageHistory;
},
dataCleared() {
this.imageHistory = [];

View File

@@ -64,7 +64,7 @@
tabindex="0"
>
<TextHighlight
:text="entryText"
:text="formatValidUrls(entry.text)"
:highlight="highlightText"
:highlight-class="'search-highlight'"
/>
@@ -94,7 +94,7 @@
class="c-ne__text"
contenteditable="false"
tabindex="0"
v-html="formattedText"
v-bind.prop="formattedText"
>
</div>
</template>
@@ -228,7 +228,9 @@ export default {
},
selectedEntryId: {
type: String,
required: true
default() {
return '';
}
}
},
data() {
@@ -236,7 +238,7 @@ export default {
editMode: false,
canEdit: true,
enableEmbedsWrapperScroll: false,
urlWhitelist: null
urlWhitelist: []
};
},
computed: {
@@ -248,28 +250,15 @@ export default {
},
formattedText() {
// remove ANY tags
let text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
const text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
if (this.editMode || !this.urlWhitelist) {
if (this.editMode || this.urlWhitelist.length === 0) {
return { innerText: text };
}
text = text.replace(URL_REGEX, (match) => {
const url = new URL(match);
const domain = url.hostname;
let result = match;
let isMatch = this.urlWhitelist.find((partialDomain) => {
return domain.endsWith(partialDomain);
});
const html = this.formatValidUrls(text);
if (isMatch) {
result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
}
return result;
});
return { innerHTML: text };
return { innerHTML: html };
},
isSelectedEntry() {
return this.selectedEntryId === this.entry.id;
@@ -355,6 +344,22 @@ export default {
deleteEntry() {
this.$emit('deleteEntry', this.entry.id);
},
formatValidUrls(text) {
return text.replace(URL_REGEX, (match) => {
const url = new URL(match);
const domain = url.hostname;
let result = match;
let isMatch = this.urlWhitelist.find((partialDomain) => {
return domain.endsWith(partialDomain);
});
if (isMatch) {
result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
}
return result;
});
},
manageEmbedLayout() {
if (this.$refs.embeds) {
const embedsWrapperLength = this.$refs.embedsWrapper.clientWidth;

View File

@@ -325,6 +325,7 @@ export default {
let textLines = this.getActivityDisplayText(this.canvasContext, activity.name, activityNameFitsRect);
const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING;
//We shouldn't need the else block here if we clip text to fit the rectangle
if (activityNameFitsRect) {
currentRow = this.getRowForActivity(rectX, rectWidth, activitiesByRow);
} else {
@@ -365,6 +366,8 @@ export default {
};
});
},
//REDO this to receive the rectangle width as the max width of text (minus padding)
// Cut off any text that exceeds this width.
getActivityDisplayText(context, text, activityNameFitsRect) {
//TODO: If the activity start is less than viewBounds.start then the text should be cropped on the left/should be off-screen)
let words = text.split(' ');
@@ -376,6 +379,7 @@ export default {
let testLine = line + words[n] + ' ';
let metrics = context.measureText(testLine);
let testWidth = metrics.width;
//We need to go to a new line if the width of our line is > MAX_TEXT_WIDTH
if (!activityNameFitsRect && (testWidth > MAX_TEXT_WIDTH && n > 0)) {
activityText.push(line);
line = words[n] + ' ';

View File

@@ -67,6 +67,7 @@
<script>
import ObjectPath from '../../components/ObjectPath.vue';
import PreviewAction from '../../preview/PreviewAction';
import { identifierToString } from '../../../../src/tools/url';
export default {
@@ -125,12 +126,25 @@ export default {
return this.result.fullTagModels[0].foregroundColor;
}
},
mounted() {
this.previewAction = new PreviewAction(this.openmct);
this.previewAction.on('isVisible', this.togglePreviewState);
},
destroyed() {
this.previewAction.off('isVisible', this.togglePreviewState);
},
methods: {
clickedResult() {
clickedResult(event) {
const objectPath = this.domainObject.originalPath;
let resultUrl = identifierToString(this.openmct, objectPath);
if (this.openmct.editor.isEditing()) {
event.preventDefault();
this.preview(objectPath);
} else {
const resultUrl = identifierToString(this.openmct, objectPath);
this.openmct.router.navigate(resultUrl);
}
this.openmct.router.navigate(resultUrl);
if (this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL) {
//wait a beat for the navigation
setTimeout(() => {
@@ -138,6 +152,11 @@ export default {
}, 100);
}
},
preview(objectPath) {
if (this.previewAction.appliesTo(objectPath)) {
this.previewAction.invoke(objectPath);
}
},
clickedPlotAnnotation() {
const targetDetails = {};
const targetDomainObjects = {};

View File

@@ -47,6 +47,8 @@
import search from '../../components/search.vue';
import SearchResultsDropDown from './SearchResultsDropDown.vue';
const SEARCH_DEBOUNCE_TIME = 200;
export default {
name: 'GrandSearch',
components: {
@@ -59,11 +61,15 @@ export default {
data() {
return {
searchValue: '',
debouncedSearchTimeoutID: null,
searchLoading: false,
annotationSearchResults: [],
objectSearchResults: []
};
},
mounted() {
this.getSearchResults = this.debounceAsyncFunction(this.getSearchResults, SEARCH_DEBOUNCE_TIME);
},
destroyed() {
document.body.removeEventListener('click', this.handleOutsideClick);
},
@@ -84,6 +90,7 @@ export default {
if (this.searchValue) {
await this.getSearchResults();
} else {
clearTimeout(this.debouncedSearchTimeoutID);
const dropdownOptions = {
searchLoading: this.searchLoading,
searchValue: this.searchValue,
@@ -93,6 +100,19 @@ export default {
this.$refs.searchResultsDropDown.showResults(dropdownOptions);
}
},
debounceAsyncFunction(functionToDebounce, debounceTime) {
return (...args) => {
clearTimeout(this.debouncedSearchTimeoutID);
return new Promise((resolve, reject) => {
this.debouncedSearchTimeoutID = setTimeout(() => {
functionToDebounce(...args)
.then(resolve)
.catch(reject);
}, debounceTime);
});
};
},
getPathsForObjects(objectsNeedingPaths) {
return Promise.all(objectsNeedingPaths.map(async (domainObject) => {
if (!domainObject) {

View File

@@ -291,4 +291,16 @@ xdescribe("GrandSearch", () => {
const previewWindow = document.querySelector('.js-preview-window');
expect(previewWindow.innerText).toContain('Snapshot');
});
it("should preview annotation search results in edit mode if annotation clicked", async () => {
await grandSearchComponent.$children[0].searchEverything('Dri');
grandSearchComponent._provided.openmct.router.path = [mockDisplayLayout];
await Vue.nextTick();
const annotationResults = document.querySelectorAll('[aria-label="Search Result"]');
expect(annotationResults.length).toBe(1);
expect(annotationResults[0].innerText).toContain('Driving');
annotationResults[0].click();
const previewWindow = document.querySelector('.js-preview-window');
expect(previewWindow.innerText).toContain('Snapshot');
});
});

View File

@@ -96,11 +96,11 @@ export default {
},
methods: {
clickedResult(event) {
const { objectPath } = this.result;
if (this.openmct.editor.isEditing()) {
event.preventDefault();
this.preview();
this.preview(objectPath);
} else {
const { objectPath } = this.result;
let resultUrl = identifierToString(this.openmct, objectPath);
// Remove the vestigial 'ROOT' identifier from url if it exists
@@ -114,8 +114,7 @@ export default {
togglePreviewState(previewState) {
this.$emit('preview-changed', previewState);
},
preview() {
const { objectPath } = this.result;
preview(objectPath) {
if (this.previewAction.appliesTo(objectPath)) {
this.previewAction.invoke(objectPath);
}

View File

@@ -28,7 +28,10 @@
v-show="resultsShown"
class="c-gsearch__results-wrapper"
>
<div class="c-gsearch__results">
<div
class="c-gsearch__results"
:class="{ 'search-finished' : !searchLoading }"
>
<div
v-if="objectResults && objectResults.length"
ref="objectResults"

View File

@@ -21,24 +21,13 @@
*****************************************************************************/
<template>
<span>
<span
v-for="segment in segments"
:key="segment.id"
:style="getStyles(segment)"
:class="{ [highlightClass] : segment.type === 'highlight' }"
>
{{ segment.text }}
</span>
</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="highlightedText"></span>
</template>
<script>
import { v4 as uuid } from 'uuid';
export default {
props: {
text: {
@@ -58,68 +47,11 @@ export default {
}
}
},
data() {
return {
segments: []
};
},
watch: {
highlight() {
this.highlightText();
},
text() {
this.highlightText();
}
},
mounted() {
this.highlightText();
},
methods: {
addHighlightSegment(segment) {
this.segments.push({
id: uuid(),
text: segment,
type: 'highlight',
spaceBefore: segment.startsWith(' '),
spaceAfter: segment.endsWith(' ')
});
},
addTextSegment(segment) {
this.segments.push({
id: uuid(),
text: segment,
type: 'text',
spaceBefore: segment.startsWith(' '),
spaceAfter: segment.endsWith(' ')
});
},
getStyles(segment) {
let styles = {
display: 'inline-block'
};
computed: {
highlightedText() {
let regex = new RegExp(`(?<!<[^>]*)(${this.highlight})`, 'gi');
if (segment.spaceBefore) {
styles.paddingLeft = '.33em';
}
if (segment.spaceAfter) {
styles.paddingRight = '.33em';
}
return styles;
},
highlightText() {
this.segments = [];
let regex = new RegExp('(' + this.highlight + ')', 'gi');
let textSegments = this.text.split(regex);
for (let i = 0; i < textSegments.length; i++) {
if (textSegments[i].toLowerCase() === this.highlight.toLowerCase()) {
this.addHighlightSegment(textSegments[i]);
} else {
this.addTextSegment(textSegments[i]);
}
}
return this.text.replace(regex, `<span class="${this.highlightClass}">${this.highlight}</span>`);
}
}
};