[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
7 changed files with 226 additions and 42 deletions

View File

@ -146,10 +146,15 @@ define([
* @param {String} id to be indexed. * @param {String} id to be indexed.
*/ */
GenericSearchProvider.prototype.scheduleForIndexing = function (id) { GenericSearchProvider.prototype.scheduleForIndexing = function (id) {
if (!this.indexedIds[id] && !this.pendingIndex[id]) { const identifier = objectUtils.parseKeyString(id);
this.indexedIds[id] = true; const objectProvider = this.openmct.objects.getProvider(identifier);
this.pendingIndex[id] = true;
this.idsToIndex.push(id); 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(); 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. // 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. // 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) { 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 * 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. * 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>
</div> </div>
<ul <ul
v-if="expanded" v-if="expanded && !isLoading"
class="c-tree" class="c-tree"
> >
<li <li

View File

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

View File

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