Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Defer rendering for inactive tabs in open mct tabbed view #7149

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4c922c6
simple prototype
scottbell Oct 16, 2023
5fc3875
Merge remote-tracking branch 'origin/master' into 7132-defer-renderin…
scottbell Oct 19, 2023
da342a9
add a few examples
scottbell Oct 19, 2023
41026dc
revert to original
scottbell Oct 19, 2023
2515d06
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Oct 23, 2023
161b0d9
only check first element
scottbell Oct 23, 2023
0911667
Merge branch '7132-defer-rendering-for-inactive-tabs-in-open-mct-tabb…
scottbell Oct 23, 2023
a62ab28
only print when we're firing
scottbell Oct 23, 2023
4c74ab1
need to return status
scottbell Oct 23, 2023
b60b822
Merge remote-tracking branch 'origin/master' into 7132-defer-renderin…
scottbell Oct 24, 2023
35b237a
ignore polling logic if not visible
scottbell Oct 24, 2023
20acc8f
convert to es6 classes
scottbell Oct 24, 2023
fae985d
add private variables
scottbell Oct 24, 2023
da43b5f
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Oct 26, 2023
5942a5c
remove debug code
scottbell Oct 26, 2023
492f504
revert on this branch webgl changes
scottbell Oct 26, 2023
09d491e
fix draw loader import
scottbell Oct 26, 2023
2148515
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Oct 30, 2023
f06e5f3
do not use v-model for search component
scottbell Oct 30, 2023
9d785b3
remove flakey unit tests and add e2e tests for same behavior
scottbell Oct 30, 2023
1ca9fcf
remove fdescribe
scottbell Oct 30, 2023
22e7793
add test word
scottbell Oct 30, 2023
d2a4e43
add simple functional test for tabs
scottbell Oct 30, 2023
7b55b72
add performance test for tabs
scottbell Oct 30, 2023
c57a618
make tab selection more explict
scottbell Oct 30, 2023
24acb3a
better describe expects
scottbell Oct 30, 2023
b4795e4
lint
scottbell Oct 30, 2023
66c904f
switch back to fixed time
scottbell Oct 30, 2023
6ebf682
fix perf test for webpacked version
scottbell Oct 30, 2023
da1ca55
lint
scottbell Oct 30, 2023
fb6e431
relax condition
scottbell Oct 30, 2023
0c4cd6a
relax condition
scottbell Oct 30, 2023
88efe20
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Oct 31, 2023
1406096
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 1, 2023
8f718d4
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 3, 2023
bc21335
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 6, 2023
6866a03
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 7, 2023
4e9c9b8
resolve PR comments
scottbell Nov 7, 2023
f1c70a1
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 7, 2023
69a3130
address PR review comments
scottbell Nov 7, 2023
7f857ec
Merge branch '7132-defer-rendering-for-inactive-tabs-in-open-mct-tabb…
scottbell Nov 7, 2023
f7b3c51
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 7, 2023
7c99541
typo on role vs locator
scottbell Nov 7, 2023
829457e
Merge branch '7132-defer-rendering-for-inactive-tabs-in-open-mct-tabb…
scottbell Nov 7, 2023
aaed525
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 8, 2023
363524a
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
scottbell Nov 9, 2023
25c457d
Merge branch 'master' into 7132-defer-rendering-for-inactive-tabs-in-…
ozyx Nov 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@
"blockquotes",
"Blockquote",
"Blockquotes",
"oger",
"lcovonly",
"gcov"
],
Expand Down
74 changes: 74 additions & 0 deletions e2e/tests/functional/plugins/tabs/tabs.e2e.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
});
});
100 changes: 100 additions & 0 deletions e2e/tests/performance/tabs.e2e.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
88 changes: 88 additions & 0 deletions src/api/nice/NicelyCalled.js
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As much as I love this name, is there a more descriptive name we could give it? Maybe "VisibilityObserver" with a function called renderWhenVisible or something?

Copy link
Contributor Author

@scottbell scottbell Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boring 😀 but will do!

#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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is initial state established here? From my reading of the API the IntersectionObserver detects changes in inersection, but how do we know whether or not the element is intersecting when we create the observer?

Copy link
Contributor Author

@scottbell scottbell Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We assume it's visible until the IntersectionObserver tells us otherwise. The IntersectionObserver, upon registering, will eventually tell us via callback its initial state (see second bullet):

The Intersection Observer API allows you to configure a callback that is called when either of these circumstances occur:
* A target element intersects either the device's viewport or a specified element. That specified element is called the root element or root for the purposes of the Intersection Observer API.
* The first time the observer is initially asked to watch a target element.

so we'll eventually know whether it's visible or not, and stop future calls to requestAnimationFrame.

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;
}
}
7 changes: 5 additions & 2 deletions src/plugins/LADTable/components/LadRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

<template>
<tr
ref="tableRow"
class="js-lad-table__body__row c-table__selectable-row"
@click="clickedRow"
@contextmenu.prevent="showContextMenu"
Expand Down Expand Up @@ -53,6 +54,7 @@ const BLANK_VALUE = '---';
import identifierToString from '/src/tools/url';
import PreviewAction from '@/ui/preview/PreviewAction.js';

import NicelyCalled from '../../../api/nice/NicelyCalled';
import tooltipHelpers from '../../../api/tooltips/tooltipMixins';

export default {
Expand Down Expand Up @@ -188,6 +190,7 @@ export default {
}
},
async mounted() {
this.nicelyCalled = new NicelyCalled(this.$refs.tableRow);
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
Expand Down Expand Up @@ -236,12 +239,12 @@ export default {
this.previewAction.off('isVisible', this.togglePreviewState);

this.telemetryCollection.destroy();
this.nicelyCalled.destroy();
},
methods: {
updateView() {
if (!this.updatingView) {
this.updatingView = true;
requestAnimationFrame(() => {
this.updatingView = this.nicelyCalled.execute(() => {
this.timestamp = this.getParsedTimestamp(this.latestDatum);
this.datum = this.latestDatum;
this.updatingView = false;
Expand Down
Loading