5391 Add preview and drag support to Grand Search (#5394)

* add preview and drag actions

* added unit test, simplified remove action

* do not hide search results in preview mode when clicking outside search results

* add semantic aria labels to enable e2e tests

* readd preview

* add e2e test

* remove commented out url

* add percy snapshot and add search to ci

* make percy stuff work

* linting

* fix percy again

* move percy snapshots to a visual test

* added separate visual test and changed test to fixtures

* fix fixtures path

* addressing review comments
This commit is contained in:
Scott Bell 2022-06-29 17:12:45 +02:00 committed by GitHub
parent 5a1c329c66
commit 28dbd724d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 483 additions and 13 deletions

View File

@ -0,0 +1,111 @@
/*****************************************************************************
* 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.
*****************************************************************************/
/*
This test suite is dedicated to tests which verify search functionality.
*/
const { expect } = require('@playwright/test');
const { test } = require('../../../../fixtures');
/**
* Creates a notebook object and adds an entry.
* @param {import('@playwright/test').Page} page
*/
async function createClockAndDisplayLayout(page) {
//Go to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
// Click button:has-text("Create")
await page.locator('button:has-text("Create")').click();
// Click li:has-text("Notebook")
await page.locator('li:has-text("Clock")').click();
// Click button:has-text("OK")
await Promise.all([
page.waitForNavigation(),
page.locator('button:has-text("OK")').click()
]);
// Click a:has-text("My Items")
await Promise.all([
page.waitForNavigation(),
page.locator('a:has-text("My Items") >> nth=0').click()
]);
// Click button:has-text("Create")
await page.locator('button:has-text("Create")').click();
// Click li:has-text("Notebook")
await page.locator('li:has-text("Display Layout")').click();
// Click button:has-text("OK")
await Promise.all([
page.waitForNavigation(),
page.locator('button:has-text("OK")').click()
]);
}
test.describe('Grand Search', () => {
test('Can search for objects, and subsequent search dropdown behaves properly', async ({ page }) => {
await createClockAndDisplayLayout(page);
// Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Cl');
await expect(page.locator('[aria-label="Search Result"]')).toContainText('Clock');
// Click text=Elements >> nth=0
await page.locator('text=Elements').first().click();
await expect(page.locator('[aria-label="Search Result"]')).not.toBeVisible();
// Click [aria-label="OpenMCT Search"] [aria-label="Search Input"]
await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').click();
// Click [aria-label="Unnamed Clock clock result"] >> text=Unnamed Clock
await page.locator('[aria-label="Unnamed Clock clock result"] >> text=Unnamed Clock').click();
await expect(page.locator('.js-preview-window')).toBeVisible();
// Click [aria-label="Close"]
await page.locator('[aria-label="Close"]').click();
await expect(page.locator('[aria-label="Search Result"]')).toBeVisible();
await expect(page.locator('[aria-label="Search Result"]')).toContainText('Cloc');
// Click [aria-label="OpenMCT Search"] a >> nth=0
await page.locator('[aria-label="OpenMCT Search"] a').first().click();
await expect(page.locator('[aria-label="Search Result"]')).not.toBeVisible();
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('foo');
await expect(page.locator('[aria-label="Search Result"]')).not.toBeVisible();
// Click text=Snapshot Save and Finish Editing Save and Continue Editing >> button >> nth=1
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
// Click text=Save and Finish Editing
await page.locator('text=Save and Finish Editing').click();
// Click [aria-label="OpenMCT Search"] [aria-label="Search Input"]
await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').click();
// Fill [aria-label="OpenMCT Search"] [aria-label="Search Input"]
await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').fill('Cl');
// Click text=Unnamed Clock
await Promise.all([
page.waitForNavigation(),
page.locator('text=Unnamed Clock').click()
]);
await expect(page.locator('.is-object-type-clock')).toBeVisible();
});
});

View File

@ -0,0 +1,104 @@
/*****************************************************************************
* 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.
*****************************************************************************/
/*
This test suite is dedicated to tests which verify search functionality.
*/
const { test, expect } = require('@playwright/test');
const percySnapshot = require('@percy/playwright');
/**
* Creates a notebook object and adds an entry.
* @param {import('@playwright/test').Page} page
*/
async function createClockAndDisplayLayout(page) {
//Go to baseURL
await page.goto('/', { waitUntil: 'networkidle' });
// Click button:has-text("Create")
await page.locator('button:has-text("Create")').click();
// Click li:has-text("Notebook")
await page.locator('li:has-text("Clock")').click();
// Click button:has-text("OK")
await Promise.all([
page.waitForNavigation(),
page.locator('button:has-text("OK")').click()
]);
// Click a:has-text("My Items")
await Promise.all([
page.waitForNavigation(),
page.locator('a:has-text("My Items") >> nth=0').click()
]);
// Click button:has-text("Create")
await page.locator('button:has-text("Create")').click();
// Click li:has-text("Notebook")
await page.locator('li:has-text("Display Layout")').click();
// Click button:has-text("OK")
await Promise.all([
page.waitForNavigation(),
page.locator('button:has-text("OK")').click()
]);
}
test.describe('Grand Search', () => {
test('Can search for objects, and subsequent search dropdown behaves properly', async ({ page }) => {
await createClockAndDisplayLayout(page);
// Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Cl');
await expect(page.locator('[aria-label="Search Result"]')).toContainText('Clock');
await percySnapshot(page, 'Searching for Clocks');
// Click text=Elements >> nth=0
await page.locator('text=Elements').first().click();
await expect(page.locator('[aria-label="Search Result"]')).not.toBeVisible();
// Click [aria-label="OpenMCT Search"] [aria-label="Search Input"]
await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').click();
// Click [aria-label="Unnamed Clock clock result"] >> text=Unnamed Clock
await page.locator('[aria-label="Unnamed Clock clock result"] >> text=Unnamed Clock').click();
await percySnapshot(page, 'Preview for clock should display when editing enabled and search item clicked');
// Click [aria-label="Close"]
await page.locator('[aria-label="Close"]').click();
await percySnapshot(page, 'Search should still be showing after preview closed');
// Click text=Snapshot Save and Finish Editing Save and Continue Editing >> button >> nth=1
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
// Click text=Save and Finish Editing
await page.locator('text=Save and Finish Editing').click();
// Click [aria-label="OpenMCT Search"] [aria-label="Search Input"]
await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').click();
// Fill [aria-label="OpenMCT Search"] [aria-label="Search Input"]
await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').fill('Cl');
// Click text=Unnamed Clock
await Promise.all([
page.waitForNavigation(),
page.locator('text=Unnamed Clock').click()
]);
await percySnapshot(page, 'Clicking on search results should navigate to them if not editing');
});
});

View File

@ -89,7 +89,7 @@
"test": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run",
"test:firefox": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --single-run --browsers=FirefoxHeadless",
"test:debug": "cross-env NODE_ENV=debug karma start --no-single-run",
"test:e2e:ci": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome smoke branding default condition timeConductor clock exampleImagery persistence performance",
"test:e2e:ci": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome smoke branding default condition timeConductor clock exampleImagery persistence performance grandsearch",
"test:e2e:local": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome",
"test:e2e:updatesnapshots": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome --grep @snapshot --update-snapshots",
"test:e2e:visual": "percy exec --config ./e2e/.percy.yml -- npx playwright test --config=e2e/playwright-visual.config.js",

View File

@ -44,6 +44,7 @@
<div
v-if="!hideOptions"
class="c-menu c-input--autocomplete__options"
aria-label="Autocomplete Options"
@blur="hideOptions = true"
>
<ul>

View File

@ -123,10 +123,7 @@ export default class RemoveAction {
}
if (isEditing) {
let currentItemInView = this.openmct.router.path[0];
let domainObject = objectPath[0];
if (this.openmct.objects.areIdsEqual(currentItemInView.identifier, domainObject.identifier)) {
if (this.openmct.router.isNavigatedObject(objectPath)) {
return false;
}
}

View File

@ -5,6 +5,7 @@
>
<input
class="c-search__input"
aria-label="Search Input"
tabindex="10000"
type="search"
v-bind="$attrs"

View File

@ -23,6 +23,8 @@
<template>
<div
class="c-gsearch-result c-gsearch-result--annotation"
aria-label="Search Result"
role="presentation"
>
<div
class="c-gsearch-result__type-icon"

View File

@ -135,8 +135,11 @@ export default {
// 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;
const clickedPreviewClose = event.target.parentElement && event.target.parentElement.querySelector('.js-preview-window');
const searchResultsDropDown = this.$refs.searchResultsDropDown._data;
if (!clickedInsideDropdown && searchResultsDropDown.resultsShown && !searchResultsDropDown.previewVisible && !clickedPreviewClose) {
searchResultsDropDown.resultsShown = false;
document.body.removeEventListener('click', this.handleOutsideClick);
}
}
}

View File

@ -0,0 +1,204 @@
/*****************************************************************************
* 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 Vue from 'vue';
import GrandSearch from './GrandSearch.vue';
import ExampleTagsPlugin from '../../../../example/exampleTags/plugin';
import DisplayLayoutPlugin from '../../../plugins/displayLayout/plugin';
describe("GrandSearch", () => {
let openmct;
let grandSearchComponent;
let viewContainer;
let parent;
let sharedWorkerToRestore;
let mockDomainObject;
let mockDisplayLayout;
let mockFolderObject;
let mockAnnotationObject;
let originalRouterPath;
beforeEach((done) => {
openmct = createOpenMct();
originalRouterPath = openmct.router.path;
openmct.router.path = [mockDisplayLayout];
openmct.editor.edit();
openmct.install(new ExampleTagsPlugin());
openmct.install(new DisplayLayoutPlugin());
const availableTags = openmct.annotation.getAvailableTags();
mockDomainObject = {
type: 'notebook',
name: 'fooRabbitNotebook',
identifier: {
key: 'some-object',
namespace: 'fooNameSpace'
},
configuration: {
entries: {
someSection: {
somePage: [
{
id: 'fooBarEntry',
text: 'Foo Bar Text'
}
]
}
}
}
};
mockFolderObject = {
type: 'folder',
name: 'Test Folder',
identifier: {
key: 'some-folder',
namespace: 'fooNameSpace'
}
};
mockDisplayLayout = {
type: 'layout',
name: 'Bar Layout',
identifier: {
key: 'some-layout',
namespace: 'fooNameSpace'
},
configuration: {
items: [],
layoutGrid: [10, 10]
}
};
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'
}
}
};
openmct.router.isNavigatedObject = jasmine.createSpy().and.returnValue(false);
const mockObjectProvider = jasmine.createSpyObj("mock object 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 if (identifier.key === mockDisplayLayout.identifier.key) {
return mockDisplayLayout;
} else if (identifier.key === mockFolderObject.identifier.key) {
return mockFolderObject;
} else {
return null;
}
};
mockObjectProvider.create.and.returnValue(Promise.resolve(true));
mockObjectProvider.update.and.returnValue(Promise.resolve(true));
openmct.objects.addProvider('fooNameSpace', mockObjectProvider);
const mockViewProvider = jasmine.createSpyObj("mock view provider", [
"key",
"view",
"canView"
]);
openmct.objectViews.addProvider(mockViewProvider);
openmct.on('start', 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(mockDisplayLayout);
await openmct.objects.inMemorySearchProvider.index(mockFolderObject);
await openmct.objects.inMemorySearchProvider.index(mockAnnotationObject);
parent = document.createElement('div');
document.body.appendChild(parent);
viewContainer = document.createElement('div');
parent.append(viewContainer);
grandSearchComponent = new Vue({
el: viewContainer,
components: {
GrandSearch
},
provide: {
openmct
},
template: '<GrandSearch/>'
}).$mount();
await Vue.nextTick();
done();
});
openmct.startHeadless();
});
afterEach(() => {
openmct.objects.inMemorySearchProvider.worker = sharedWorkerToRestore;
openmct.router.path = originalRouterPath;
grandSearchComponent.$destroy();
return resetApplicationState(openmct);
});
it("should render an object search result", async () => {
await grandSearchComponent.$children[0].searchEverything('foo');
await Vue.nextTick();
const searchResult = document.querySelector('[aria-label="fooRabbitNotebook notebook result"]');
expect(searchResult).toBeDefined();
});
it("should render an annotation search result", async () => {
await grandSearchComponent.$children[0].searchEverything('S');
await Vue.nextTick();
const annotationResult = document.querySelector('[aria-label="Search Result"]');
expect(annotationResult).toBeDefined();
});
it("should preview object search results in edit mode if object clicked", async () => {
await grandSearchComponent.$children[0].searchEverything('Folder');
grandSearchComponent._provided.openmct.router.path = [mockDisplayLayout];
await Vue.nextTick();
const searchResult = document.querySelector('[name="Test Folder"]');
expect(searchResult).toBeDefined();
searchResult.click();
const previewWindow = document.querySelector('.js-preview-window');
expect(previewWindow).toBeDefined();
});
});

View File

@ -23,6 +23,7 @@
<template>
<div
class="c-gsearch-result c-gsearch-result--object"
aria-label="Search Result"
role="presentation"
>
<div
@ -37,6 +38,8 @@
<div
class="c-gsearch-result__title"
:name="resultName"
draggable="true"
@dragstart="dragStart"
@click="clickedResult"
>
{{ resultName }}
@ -56,6 +59,7 @@
<script>
import ObjectPath from '../../components/ObjectPath.vue';
import objectPathToUrl from '../../../tools/url';
import PreviewAction from '../../preview/PreviewAction';
export default {
name: 'ObjectSearchResult',
@ -90,12 +94,43 @@ export default {
}
};
this.$refs.objectpath.updateSelection([[selectionObject]]);
this.previewAction = new PreviewAction(this.openmct);
this.previewAction.on('isVisible', this.togglePreviewState);
},
destroyed() {
this.previewAction.off('isVisible', this.togglePreviewState);
},
methods: {
clickedResult() {
clickedResult(event) {
if (this.openmct.editor.isEditing()) {
event.preventDefault();
this.preview();
} else {
const objectPath = this.result.originalPath;
const resultUrl = objectPathToUrl(this.openmct, objectPath);
this.openmct.router.navigate(resultUrl);
}
},
togglePreviewState(previewState) {
this.$emit('preview-changed', previewState);
},
preview() {
const objectPath = this.result.originalPath;
const resultUrl = objectPathToUrl(this.openmct, objectPath);
this.openmct.router.navigate(resultUrl);
if (this.previewAction.appliesTo(objectPath)) {
this.previewAction.invoke(objectPath);
}
},
dragStart(event) {
const navigatedObject = this.openmct.router.path[0];
const objectPath = this.result.originalPath;
const serializedPath = JSON.stringify(objectPath);
const keyString = this.openmct.objects.makeKeyString(this.result.identifier);
if (this.openmct.composition.checkPolicy(navigatedObject, this.result)) {
event.dataTransfer.setData("openmct/composable-domain-object", JSON.stringify(this.result));
}
event.dataTransfer.setData("openmct/domain-object-path", serializedPath);
event.dataTransfer.setData(`openmct/domain-object/${keyString}`, this.result);
}
}
};

View File

@ -42,6 +42,7 @@
v-for="(objectResult, index) in objectResults"
:key="index"
:result="objectResult"
@preview-changed="previewChanged"
@click.native="selectedResult"
/>
</div>
@ -76,12 +77,18 @@ export default {
return {
resultsShown: false,
annotationResults: [],
objectResults: []
objectResults: [],
previewVisible: false
};
},
methods: {
selectedResult() {
this.resultsShown = false;
if (!this.previewVisible) {
this.resultsShown = false;
}
},
previewChanged(changedPreviewState) {
this.previewVisible = changedPreviewState;
},
showResults(passedAnnotationResults, passedObjectResults) {
if ((passedAnnotationResults && passedAnnotationResults.length)

View File

@ -39,6 +39,7 @@
padding: $interiorMarginLg;
min-width: 500px;
max-height: 500px;
z-index: 60;
}
&__results,

View File

@ -21,9 +21,11 @@
*****************************************************************************/
import Preview from './Preview.vue';
import Vue from 'vue';
import EventEmitter from 'EventEmitter';
export default class PreviewAction {
export default class PreviewAction extends EventEmitter {
constructor(openmct) {
super();
/**
* Metadata
*/
@ -75,10 +77,12 @@ export default class PreviewAction {
onDestroy: () => {
PreviewAction.isVisible = false;
preview.$destroy();
this.emit('isVisible', false);
}
});
PreviewAction.isVisible = true;
this.emit('isVisible', true);
}
appliesTo(objectPath, view = {}) {