From deacd91078beb14a40a214312b21daebe93112fc Mon Sep 17 00:00:00 2001 From: Scott Bell Date: Mon, 13 Nov 2023 19:27:50 +0100 Subject: [PATCH] Defer rendering for inactive tabs in open mct tabbed view (#7149) * simple prototype * add a few examples * revert to original * only check first element * only print when we're firing * need to return status * ignore polling logic if not visible * convert to es6 classes * add private variables * remove debug code * revert on this branch webgl changes * fix draw loader import * do not use v-model for search component * remove flakey unit tests and add e2e tests for same behavior * remove fdescribe * add test word * add simple functional test for tabs * add performance test for tabs * make tab selection more explict * better describe expects * lint * switch back to fixed time * fix perf test for webpacked version * lint * relax condition * relax condition * resolve PR comments * address PR review comments * typo on role vs locator --- .cspell.json | 1 + .../functional/plugins/tabs/tabs.e2e.spec.js | 74 +++++++++++++ .../telemetryTable/telemetryTable.e2e.spec.js | 81 ++++++++++++++ e2e/tests/performance/tabs.e2e.spec.js | 100 ++++++++++++++++++ src/api/nice/NicelyCalled.js | 88 +++++++++++++++ src/plugins/LADTable/components/LadRow.vue | 7 +- .../components/TelemetryView.vue | 31 +++--- .../gauge/components/GaugeComponent.vue | 14 ++- src/plugins/plot/chart/MctChart.vue | 9 +- src/plugins/tabs/components/TabsComponent.vue | 3 + .../components/TableComponent.vue | 44 ++++---- src/plugins/telemetryTable/pluginSpec.js | 27 ----- 12 files changed, 413 insertions(+), 66 deletions(-) create mode 100644 e2e/tests/functional/plugins/tabs/tabs.e2e.spec.js create mode 100644 e2e/tests/performance/tabs.e2e.spec.js create mode 100644 src/api/nice/NicelyCalled.js diff --git a/.cspell.json b/.cspell.json index 684868251d..f29d189016 100644 --- a/.cspell.json +++ b/.cspell.json @@ -488,6 +488,7 @@ "blockquotes", "Blockquote", "Blockquotes", + "oger", "lcovonly", "gcov" ], diff --git a/e2e/tests/functional/plugins/tabs/tabs.e2e.spec.js b/e2e/tests/functional/plugins/tabs/tabs.e2e.spec.js new file mode 100644 index 0000000000..79ff77c3c2 --- /dev/null +++ b/e2e/tests/functional/plugins/tabs/tabs.e2e.spec.js @@ -0,0 +1,74 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, 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. + *****************************************************************************/ + +const { createDomainObjectWithDefaults } = require('../../../../appActions'); +const { test, expect } = require('../../../../pluginFixtures'); + +test.describe('Tabs View', () => { + test('Renders tabbed elements', async ({ page }) => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const tabsView = await createDomainObjectWithDefaults(page, { + type: 'Tabs View' + }); + const table = await createDomainObjectWithDefaults(page, { + type: 'Telemetry Table', + parent: tabsView.uuid + }); + await createDomainObjectWithDefaults(page, { + type: 'Event Message Generator', + parent: table.uuid + }); + const notebook = await createDomainObjectWithDefaults(page, { + type: 'Notebook', + parent: tabsView.uuid + }); + const sineWaveGenerator = await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator', + parent: tabsView.uuid + }); + + page.goto(tabsView.url); + + // select first tab + await page.getByLabel(`${table.name} tab`).click(); + // ensure table header visible + await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); + + // select second tab + await page.getByLabel(`${notebook.name} tab`).click(); + + // ensure notebook visible + await expect(page.locator('.c-notebook__drag-area')).toBeVisible(); + + // select third tab + await page.getByLabel(`${sineWaveGenerator.name} tab`).click(); + + // expect sine wave generator visible + expect(await page.locator('.c-plot').isVisible()).toBe(true); + + // now try to select the first tab again + await page.getByLabel(`${table.name} tab`).click(); + // ensure table header visible + await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); + }); +}); diff --git a/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js b/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js index 295e63dacd..fd3b25a593 100644 --- a/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js +++ b/e2e/tests/functional/plugins/telemetryTable/telemetryTable.e2e.spec.js @@ -78,4 +78,85 @@ test.describe('Telemetry Table', () => { const endBoundMilliseconds = Date.parse(endDate); expect(latestMilliseconds).toBeLessThanOrEqual(endBoundMilliseconds); }); + + test('Supports filtering telemetry by regular text search', async ({ page }) => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const table = await createDomainObjectWithDefaults(page, { type: 'Telemetry Table' }); + await createDomainObjectWithDefaults(page, { + type: 'Event Message Generator', + parent: table.uuid + }); + + // focus the Telemetry Table + await page.goto(table.url); + + await page.getByRole('searchbox', { name: 'message filter input' }).click(); + await page.getByRole('searchbox', { name: 'message filter input' }).fill('Roger'); + + let cells = await page.getByRole('cell', { name: /Roger/ }).all(); + // ensure we've got more than one cell + expect(cells.length).toBeGreaterThan(1); + // ensure the text content of each cell contains the search term + for (const cell of cells) { + const text = await cell.textContent(); + expect(text).toContain('Roger'); + } + + await page.getByRole('searchbox', { name: 'message filter input' }).click(); + await page.getByRole('searchbox', { name: 'message filter input' }).fill('Dodger'); + + cells = await page.getByRole('cell', { name: /Dodger/ }).all(); + // ensure we've got more than one cell + expect(cells.length).toBe(0); + // ensure the text content of each cell contains the search term + for (const cell of cells) { + const text = await cell.textContent(); + expect(text).not.toContain('Dodger'); + } + + // Click pause button + await page.click('button[title="Pause"]'); + }); + + test('Supports filtering using Regex', async ({ page }) => { + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const table = await createDomainObjectWithDefaults(page, { type: 'Telemetry Table' }); + await createDomainObjectWithDefaults(page, { + type: 'Event Message Generator', + parent: table.uuid + }); + + // focus the Telemetry Table + page.goto(table.url); + await page.getByRole('searchbox', { name: 'message filter input' }).hover(); + await page.getByLabel('Message filter header').getByRole('button', { name: '/R/' }).click(); + await page.getByRole('searchbox', { name: 'message filter input' }).click(); + await page.getByRole('searchbox', { name: 'message filter input' }).fill('/[Rr]oger/'); + + let cells = await page.getByRole('cell', { name: /Roger/ }).all(); + // ensure we've got more than one cell + expect(cells.length).toBeGreaterThan(1); + // ensure the text content of each cell contains the search term + for (const cell of cells) { + const text = await cell.textContent(); + expect(text).toContain('Roger'); + } + + await page.getByRole('searchbox', { name: 'message filter input' }).click(); + await page.getByRole('searchbox', { name: 'message filter input' }).fill('/[Dd]oger/'); + + cells = await page.getByRole('cell', { name: /Dodger/ }).all(); + // ensure we've got more than one cell + expect(cells.length).toBe(0); + // ensure the text content of each cell contains the search term + for (const cell of cells) { + const text = await cell.textContent(); + expect(text).not.toContain('Dodger'); + } + + // Click pause button + await page.click('button[title="Pause"]'); + }); }); diff --git a/e2e/tests/performance/tabs.e2e.spec.js b/e2e/tests/performance/tabs.e2e.spec.js new file mode 100644 index 0000000000..833f2e3669 --- /dev/null +++ b/e2e/tests/performance/tabs.e2e.spec.js @@ -0,0 +1,100 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, 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. + *****************************************************************************/ + +const { createDomainObjectWithDefaults, waitForPlotsToRender } = require('../../appActions'); +const { test, expect } = require('../../pluginFixtures'); + +test.describe('Tabs View', () => { + test('Renders tabbed elements nicely', async ({ page }) => { + // Code to hook into the requestAnimationFrame function and log each call + let animationCalls = []; + await page.exposeFunction('logCall', (callCount) => { + animationCalls.push(callCount); + }); + await page.addInitScript(() => { + const oldRequestAnimationFrame = window.requestAnimationFrame; + let callCount = 0; + window.requestAnimationFrame = function (callback) { + // eslint-disable-next-line no-undef + logCall(callCount++); + return oldRequestAnimationFrame(callback); + }; + }); + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const tabsView = await createDomainObjectWithDefaults(page, { + type: 'Tabs View' + }); + const table = await createDomainObjectWithDefaults(page, { + type: 'Telemetry Table', + parent: tabsView.uuid + }); + await createDomainObjectWithDefaults(page, { + type: 'Event Message Generator', + parent: table.uuid + }); + const notebook = await createDomainObjectWithDefaults(page, { + type: 'Notebook', + parent: tabsView.uuid + }); + const sineWaveGenerator = await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator', + parent: tabsView.uuid + }); + + page.goto(tabsView.url); + + // select first tab + await page.getByLabel(`${table.name} tab`).click(); + // ensure table header visible + await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible(); + + // select second tab + await page.getByLabel(`${notebook.name} tab`).click(); + + // expect notebook visible + await expect(page.locator('.c-notebook__drag-area')).toBeVisible(); + + // select third tab + await page.getByLabel(`${sineWaveGenerator.name} tab`).click(); + + // ensure sine wave generator visible + expect(await page.locator('.c-plot').isVisible()).toBe(true); + + // now select notebook and clear animation calls + await page.getByLabel(`${notebook.name} tab`).click(); + animationCalls = []; + // expect notebook visible + await expect(page.locator('.c-notebook__drag-area')).toBeVisible(); + const notebookAnimationCalls = animationCalls.length; + + // select sine wave generator and clear animation calls + animationCalls = []; + await page.getByLabel(`${sineWaveGenerator.name} tab`).click(); + + // ensure sine wave generator visible + await waitForPlotsToRender(page); + // we should be calling animation frames + const sineWaveAnimationCalls = animationCalls.length; + expect(sineWaveAnimationCalls).toBeGreaterThanOrEqual(notebookAnimationCalls); + }); +}); diff --git a/src/api/nice/NicelyCalled.js b/src/api/nice/NicelyCalled.js new file mode 100644 index 0000000000..d8e77363f0 --- /dev/null +++ b/src/api/nice/NicelyCalled.js @@ -0,0 +1,88 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, 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. + *****************************************************************************/ + +/** + * Optimizes `requestAnimationFrame` calls to only execute when the element is visible in the viewport. + */ +export default class NicelyCalled { + #element; + #isIntersecting; + #observer; + #lastUnfiredFunc; + + /** + * Constructs a NicelyCalled instance to manage visibility-based requestAnimationFrame calls. + * + * @param {HTMLElement} element - The DOM element to observe for visibility changes. + * @throws {Error} If element is not provided. + */ + constructor(element) { + if (!element) { + throw new Error(`Nice visibility must be created with an element`); + } + this.#element = element; + this.#isIntersecting = true; + + this.#observer = new IntersectionObserver(this.#observerCallback); + this.#observer.observe(this.#element); + this.#lastUnfiredFunc = null; + } + + #observerCallback = ([entry]) => { + if (entry.target === this.#element) { + this.#isIntersecting = entry.isIntersecting; + if (this.#isIntersecting && this.#lastUnfiredFunc) { + window.requestAnimationFrame(this.#lastUnfiredFunc); + this.#lastUnfiredFunc = null; + } + } + }; + + /** + * Executes a function within requestAnimationFrame if the observed element is visible. + * If the element is not visible, the function is stored and called when the element becomes visible. + * Note that if called multiple times while not visible, only the last execution is stored and executed. + * + * @param {Function} func - The function to execute. + * @returns {boolean} True if the function was executed immediately, false otherwise. + */ + execute(func) { + if (this.#isIntersecting) { + window.requestAnimationFrame(func); + return true; + } else { + this.#lastUnfiredFunc = func; + return false; + } + } + + /** + * Stops observing the element for visibility changes and cleans up resources to prevent memory leaks. + */ + destroy() { + this.#observer.unobserve(this.#element); + this.#element = null; + this.#isIntersecting = null; + this.#observer = null; + this.#lastUnfiredFunc = null; + } +} diff --git a/src/plugins/LADTable/components/LadRow.vue b/src/plugins/LADTable/components/LadRow.vue index 6f9e57b7a0..ffc2eb2284 100644 --- a/src/plugins/LADTable/components/LadRow.vue +++ b/src/plugins/LADTable/components/LadRow.vue @@ -22,6 +22,7 @@