diff --git a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js index 37c413755b..5c41f2d418 100644 --- a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js +++ b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js @@ -205,6 +205,71 @@ test.describe('Display Layout', () => { expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); }); + + test('When multiple plots are contained in a layout, we only ask for annotations once @couchdb', async ({ + page + }) => { + // Create another Sine Wave Generator + const anotherSineWaveObject = await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator' + }); + // Create a Display Layout + await createDomainObjectWithDefaults(page, { + type: 'Display Layout', + name: 'Test Display Layout' + }); + // Edit Display Layout + await page.locator('[title="Edit"]').click(); + + // Expand the 'My Items' folder in the left tree + await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); + // Add the Sine Wave Generator to the Display Layout and save changes + const treePane = page.getByRole('tree', { + name: 'Main Tree' + }); + const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(sineWaveObject.name) + }); + + let layoutGridHolder = page.locator('.l-layout__grid-holder'); + // eslint-disable-next-line playwright/no-force-option + await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); + + await page.getByText('View type').click(); + await page.getByText('Overlay Plot').click(); + + const anotherSineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(anotherSineWaveObject.name) + }); + layoutGridHolder = page.locator('.l-layout__grid-holder'); + // eslint-disable-next-line playwright/no-force-option + await anotherSineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); + + await page.getByText('View type').click(); + await page.getByText('Overlay Plot').click(); + + await page.locator('button[title="Save"]').click(); + await page.locator('text=Save and Finish Editing').click(); + + // Time to inspect some network traffic + let networkRequests = []; + page.on('request', (request) => { + const searchRequest = request.url().endsWith('_find'); + const fetchRequest = request.resourceType() === 'fetch'; + if (searchRequest && fetchRequest) { + networkRequests.push(request); + } + }); + + await page.reload(); + + // wait for annotations requests to be batched and requested + await page.waitForLoadState('networkidle'); + + // Network requests for the composite telemetry with multiple items should be: + // 1. a single batched request for annotations + expect(networkRequests.length).toBe(1); + }); }); /** diff --git a/src/plugins/persistence/couch/CouchSearchProvider.js b/src/plugins/persistence/couch/CouchSearchProvider.js index aa7efa74d8..53cb8b9d68 100644 --- a/src/plugins/persistence/couch/CouchSearchProvider.js +++ b/src/plugins/persistence/couch/CouchSearchProvider.js @@ -27,7 +27,13 @@ // If the above namespace is ever resolved, we can fold this search provider // back into the object provider. +const BATCH_ANNOTATION_DEBOUNCE_MS = 100; + class CouchSearchProvider { + #bulkPromise; + #batchIds; + #lastAbortSignal; + constructor(couchObjectProvider) { this.couchObjectProvider = couchObjectProvider; this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES; @@ -36,6 +42,8 @@ class CouchSearchProvider { this.searchTypes.ANNOTATIONS, this.searchTypes.TAGS ]; + this.#batchIds = []; + this.#bulkPromise = null; } supportsSearchType(searchType) { @@ -68,28 +76,77 @@ class CouchSearchProvider { return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); } - searchForAnnotations(keyString, abortSignal) { + async #deferBatchAnnotationSearch() { + // We until the next event loop cycle to "collect" all of the get + // requests triggered in this iteration of the event loop + await this.#waitForDebounce(); + const batchIdsToSearch = [...this.#batchIds]; + this.#clearBatch(); + return this.#bulkAnnotationSearch(batchIdsToSearch); + } + + #clearBatch() { + this.#batchIds = []; + this.#bulkPromise = undefined; + } + + #waitForDebounce() { + let timeoutID; + clearTimeout(timeoutID); + + return new Promise((resolve) => { + timeoutID = setTimeout(() => { + resolve(); + }, BATCH_ANNOTATION_DEBOUNCE_MS); + }); + } + + #bulkAnnotationSearch(batchIdsToSearch) { const filter = { selector: { $and: [ - { - model: { - targets: {} - } - }, { 'model.type': { $eq: 'annotation' } + }, + { + $or: [] } ] } }; - filter.selector.$and[0].model.targets[keyString] = { - $exists: true - }; + let lastAbortSignal = null; + // TODO: should remove duplicates from batchIds + batchIdsToSearch.forEach(({ keyString, abortSignal }) => { + const modelFilter = { + model: { + targets: {} + } + }; + modelFilter.model.targets[keyString] = { + $exists: true + }; - return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); + filter.selector.$and[1].$or.push(modelFilter); + lastAbortSignal = abortSignal; + }); + + return this.couchObjectProvider.getObjectsByFilter(filter, lastAbortSignal); + } + + async searchForAnnotations(keyString, abortSignal) { + this.#batchIds.push({ keyString, abortSignal }); + if (!this.#bulkPromise) { + this.#bulkPromise = this.#deferBatchAnnotationSearch(); + } + + const returnedData = await this.#bulkPromise; + // only return data that matches the keystring + const filteredByKeyString = returnedData.filter((foundAnnotation) => { + return foundAnnotation.targets[keyString]; + }); + return filteredByKeyString; } searchForTags(tagsArray, abortSignal) {