Compare commits
8 Commits
bugfix/iss
...
plan-notes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd75bd5d7 | ||
|
|
ceffee9f22 | ||
|
|
a08ccd80dc | ||
|
|
3509eacdec | ||
|
|
d4496cba41 | ||
|
|
64f300d466 | ||
|
|
8de24a109a | ||
|
|
6d62e0e73c |
@@ -1,4 +1,4 @@
|
||||
# Open MCT [](http://www.apache.org/licenses/LICENSE-2.0) [](https://lgtm.com/projects/g/nasa/openmct/context:javascript) [](https://codecov.io/gh/nasa/openmct) [](https://percy.io/b2e34b17/openmct) [](https://www.npmjs.com/package/openmct)
|
||||
# Open MCT [](http://www.apache.org/licenses/LICENSE-2.0) [](https://codecov.io/gh/nasa/openmct) [](https://percy.io/b2e34b17/openmct) [](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
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -149,7 +149,7 @@ class TimeAPI extends GlobalTimeContext {
|
||||
|
||||
return () => {
|
||||
//follow any upstream time context
|
||||
this.emit('refreshContext');
|
||||
this.emit('removeOwnContext', key);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] + ' ';
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user