[Object API] add object provider search (#3610)

* add search method to object api
* use object api search
* do not index objects that have a provided search capability
* provide indexed search for objects without a search provider
This commit is contained in:
David Tsay 2021-02-12 12:48:34 -08:00 committed by GitHub
parent 5e2fe7dc42
commit abb1a5c75b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 226 additions and 42 deletions

View File

@ -146,10 +146,15 @@ define([
* @param {String} id to be indexed.
*/
GenericSearchProvider.prototype.scheduleForIndexing = function (id) {
if (!this.indexedIds[id] && !this.pendingIndex[id]) {
this.indexedIds[id] = true;
this.pendingIndex[id] = true;
this.idsToIndex.push(id);
const identifier = objectUtils.parseKeyString(id);
const objectProvider = this.openmct.objects.getProvider(identifier);
if (objectProvider === undefined || objectProvider.search === undefined) {
if (!this.indexedIds[id] && !this.pendingIndex[id]) {
this.indexedIds[id] = true;
this.pendingIndex[id] = true;
this.idsToIndex.push(id);
}
}
this.keepIndexing();

View File

@ -139,6 +139,12 @@ define([
});
};
ObjectServiceProvider.prototype.superSecretFallbackSearch = function (query, options) {
const searchService = this.$injector.get('searchService');
return searchService.query(query);
};
// Injects new object API as a decorator so that it hijacks all requests.
// Object providers implemented on new API should just work, old API should just work, many things may break.
function LegacyObjectAPIInterceptor(openmct, ROOTS, instantiate, topic, objectService) {

View File

@ -168,6 +168,34 @@ ObjectAPI.prototype.get = function (identifier) {
});
};
/**
* Search for domain objects.
*
* Object providersSearches and combines results of each object provider search.
* Objects without search provided will have been indexed
* and will be searched using the fallback indexed search.
* Search results are asynchronous and resolve in parallel.
*
* @method search
* @memberof module:openmct.ObjectAPI#
* @param {string} query the term to search for
* @param {Object} options search options
* @returns {Array.<Promise.<module:openmct.DomainObject>>}
* an array of promises returned from each object provider's search function
* each resolving to domain objects matching provided search query and options.
*/
ObjectAPI.prototype.search = function (query, options) {
const searchPromises = Object.values(this.providers)
.filter(provider => provider.search !== undefined)
.map(provider => provider.search(query, options));
searchPromises.push(this.fallbackProvider.superSecretFallbackSearch(query, options)
.then(results => results.hits
.map(hit => utils.toNewFormat(hit.object.getModel(), hit.object.getId()))));
return searchPromises;
};
/**
* Will fetch object for the given identifier, returning a version of the object that will automatically keep
* itself updated as it is mutated. Before using this function, you should ask yourself whether you really need it.

View File

@ -0,0 +1,119 @@
import ObjectAPI from './ObjectAPI.js';
describe("The Object API Search Function", () => {
const MOCK_PROVIDER_KEY = 'mockProvider';
const ANOTHER_MOCK_PROVIDER_KEY = 'anotherMockProvider';
const MOCK_PROVIDER_SEARCH_DELAY = 15000;
const ANOTHER_MOCK_PROVIDER_SEARCH_DELAY = 20000;
const TOTAL_TIME_ELAPSED = 21000;
const BASE_TIME = new Date(2021, 0, 1);
let objectAPI;
let mockObjectProvider;
let anotherMockObjectProvider;
let mockFallbackProvider;
let fallbackProviderSearchResults;
let resultsPromises;
beforeEach(() => {
jasmine.clock().install();
jasmine.clock().mockDate(BASE_TIME);
resultsPromises = [];
fallbackProviderSearchResults = {
hits: []
};
objectAPI = new ObjectAPI();
mockObjectProvider = jasmine.createSpyObj("mock object provider", [
"search"
]);
anotherMockObjectProvider = jasmine.createSpyObj("another mock object provider", [
"search"
]);
mockFallbackProvider = jasmine.createSpyObj("super secret fallback provider", [
"superSecretFallbackSearch"
]);
objectAPI.addProvider('objects', mockObjectProvider);
objectAPI.addProvider('other-objects', anotherMockObjectProvider);
objectAPI.supersecretSetFallbackProvider(mockFallbackProvider);
mockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const mockProviderSearch = {
name: MOCK_PROVIDER_KEY,
start: new Date()
};
setTimeout(() => {
mockProviderSearch.end = new Date();
return resolve(mockProviderSearch);
}, MOCK_PROVIDER_SEARCH_DELAY);
});
});
anotherMockObjectProvider.search.and.callFake(() => {
return new Promise(resolve => {
const anotherMockProviderSearch = {
name: ANOTHER_MOCK_PROVIDER_KEY,
start: new Date()
};
setTimeout(() => {
anotherMockProviderSearch.end = new Date();
return resolve(anotherMockProviderSearch);
}, ANOTHER_MOCK_PROVIDER_SEARCH_DELAY);
});
});
mockFallbackProvider.superSecretFallbackSearch.and.callFake(
() => new Promise(
resolve => setTimeout(
() => resolve(fallbackProviderSearchResults),
50
)
)
);
resultsPromises = objectAPI.search('foo');
jasmine.clock().tick(TOTAL_TIME_ELAPSED);
});
afterEach(() => {
jasmine.clock().uninstall();
});
it("uses each objects given provider's search function", () => {
expect(mockObjectProvider.search).toHaveBeenCalled();
expect(anotherMockObjectProvider.search).toHaveBeenCalled();
});
it("uses the fallback indexed search for objects without a search function provided", () => {
expect(mockFallbackProvider.superSecretFallbackSearch).toHaveBeenCalled();
});
it("provides each providers results as promises that resolve in parallel", async () => {
const results = await Promise.all(resultsPromises);
const mockProviderResults = results.find(
result => result.name === MOCK_PROVIDER_KEY
);
const anotherMockProviderResults = results.find(
result => result.name === ANOTHER_MOCK_PROVIDER_KEY
);
const mockProviderStart = mockProviderResults.start.getTime();
const mockProviderEnd = mockProviderResults.end.getTime();
const anotherMockProviderStart = anotherMockProviderResults.start.getTime();
const anotherMockProviderEnd = anotherMockProviderResults.end.getTime();
const searchElapsedTime = Math.max(mockProviderEnd, anotherMockProviderEnd)
- Math.min(mockProviderEnd, anotherMockProviderEnd);
expect(mockProviderStart).toBeLessThan(anotherMockProviderEnd);
expect(anotherMockProviderStart).toBeLessThan(mockProviderEnd);
expect(searchElapsedTime).toBeLessThan(
MOCK_PROVIDER_SEARCH_DELAY
+ ANOTHER_MOCK_PROVIDER_SEARCH_DELAY
);
});
});

View File

@ -41,7 +41,7 @@
</div>
</div>
<ul
v-if="expanded"
v-if="expanded && !isLoading"
class="c-tree"
>
<li

View File

@ -41,7 +41,7 @@
></div>
<!-- end loading -->
<div v-if="(allTreeItems.length === 0) || (searchValue && filteredTreeItems.length === 0)"
<div v-if="shouldDisplayNoResultsText"
class="c-tree-and-search__no-results"
>
No results found
@ -63,7 +63,7 @@
<!-- end main tree -->
<!-- search tree -->
<ul v-if="searchValue"
<ul v-if="searchValue && !isLoading"
class="c-tree-and-search__tree c-tree"
>
<condition-set-dialog-tree-item
@ -80,6 +80,7 @@
</template>
<script>
import debounce from 'lodash/debounce';
import search from '@/ui/components/search.vue';
import ConditionSetDialogTreeItem from './ConditionSetDialogTreeItem.vue';
@ -100,8 +101,20 @@ export default {
selectedItem: undefined
};
},
computed: {
shouldDisplayNoResultsText() {
if (this.isLoading) {
return false;
}
return this.allTreeItems.length === 0
|| (this.searchValue && this.filteredTreeItems.length === 0);
}
},
created() {
this.getDebouncedFilteredChildren = debounce(this.getFilteredChildren, 400);
},
mounted() {
this.searchService = this.openmct.$injector.get('searchService');
this.getAllChildren();
},
methods: {
@ -124,37 +137,44 @@ export default {
});
},
getFilteredChildren() {
this.searchService.query(this.searchValue).then(children => {
this.filteredTreeItems = children.hits.map(child => {
// clear any previous search results
this.filteredTreeItems = [];
let context = child.object.getCapability('context');
let object = child.object.useCapability('adapter');
let objectPath = [];
let navigateToParent;
const promises = this.openmct.objects.search(this.searchValue)
.map(promise => promise
.then(results => this.aggregateFilteredChildren(results)));
if (context) {
objectPath = context.getPath().slice(1)
.map(oldObject => oldObject.useCapability('adapter'))
.reverse();
navigateToParent = '/browse/' + objectPath.slice(1)
.map((parent) => this.openmct.objects.makeKeyString(parent.identifier))
.join('/');
}
return {
id: this.openmct.objects.makeKeyString(object.identifier),
object,
objectPath,
navigateToParent
};
});
Promise.all(promises).then(() => {
this.isLoading = false;
});
},
async aggregateFilteredChildren(results) {
for (const object of results) {
const objectPath = await this.openmct.objects.getOriginalPath(object.identifier);
const navigateToParent = '/browse/'
+ objectPath.slice(1)
.map(parent => this.openmct.objects.makeKeyString(parent.identifier))
.join('/');
const filteredChild = {
id: this.openmct.objects.makeKeyString(object.identifier),
object,
objectPath,
navigateToParent
};
this.filteredTreeItems.push(filteredChild);
}
},
searchTree(value) {
this.searchValue = value;
this.isLoading = true;
if (this.searchValue !== '') {
this.getFilteredChildren();
this.getDebouncedFilteredChildren();
} else {
this.isLoading = false;
}
},
handleItemSelection(item, node) {

View File

@ -132,7 +132,6 @@
import _ from 'lodash';
import treeItem from './tree-item.vue';
import search from '../components/search.vue';
import objectUtils from 'objectUtils';
import uuid from 'uuid';
const LOCAL_STORAGE_KEY__TREE_EXPANDED__OLD = 'mct-tree-expanded';
@ -308,7 +307,9 @@ export default {
},
methods: {
async initialize() {
this.searchService = this.openmct.$injector.get('searchService');
// required to index tree objects that do not have search providers
this.openmct.$injector.get('searchService');
window.addEventListener('resize', this.handleWindowResize);
this.backwardsCompatibilityCheck();
await this.calculateHeights();
@ -698,14 +699,21 @@ export default {
}
}
},
async getSearchResults() {
let results = await this.searchService.query(this.searchValue);
getSearchResults() {
// clear any previous search results
this.searchResultItems = [];
for (let i = 0; i < results.hits.length; i++) {
let result = results.hits[i];
let newStyleObject = objectUtils.toNewFormat(result.object.getModel(), result.object.getId());
let objectPath = await this.openmct.objects.getOriginalPath(newStyleObject.identifier);
const promises = this.openmct.objects.search(this.searchValue)
.map(promise => promise
.then(results => this.aggregateSearchResults(results)));
Promise.all(promises).then(() => {
this.searchLoading = false;
});
},
async aggregateSearchResults(results) {
for (const result of results) {
const objectPath = await this.openmct.objects.getOriginalPath(result.identifier);
// removing the item itself, as the path we pass to buildTreeItem is a parent path
objectPath.shift();
@ -718,12 +726,10 @@ export default {
// we reverse the objectPath in the tree, so have to do it here first,
// since this one is already in the correct direction
let resultObject = this.buildTreeItem(newStyleObject, objectPath.reverse());
let resultObject = this.buildTreeItem(result, objectPath.reverse());
this.searchResultItems.push(resultObject);
}
this.searchLoading = false;
},
searchTree(value) {
this.searchValue = value;