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
This commit is contained in:
Scott Bell 2023-11-13 19:27:50 +01:00 committed by GitHub
parent 29b7c389ad
commit deacd91078
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 413 additions and 66 deletions

View File

@ -488,6 +488,7 @@
"blockquotes",
"Blockquote",
"Blockquotes",
"oger",
"lcovonly",
"gcov"
],

View File

@ -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();
});
});

View File

@ -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"]');
});
});

View File

@ -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);
});
});

View File

@ -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;
}
}

View File

@ -22,6 +22,7 @@
<template>
<tr
ref="tableRow"
class="js-lad-table__body__row c-table__selectable-row"
@click="clickedRow"
@contextmenu.prevent="showContextMenu"
@ -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 {
@ -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);
@ -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;

View File

@ -73,6 +73,7 @@ import {
} from '@/plugins/notebook/utils/notebook-storage.js';
import stalenessMixin from '@/ui/mixins/staleness-mixin';
import NicelyCalled from '../../../api/nice/NicelyCalled';
import tooltipHelpers from '../../../api/tooltips/tooltipMixins';
import conditionalStylesMixin from '../mixins/objectStyles-mixin';
import LayoutFrame from './LayoutFrame.vue';
@ -231,13 +232,7 @@ export default {
}
},
mounted() {
if (this.openmct.objects.supportsMutation(this.item.identifier)) {
this.mutablePromise = this.openmct.objects
.getMutable(this.item.identifier)
.then(this.setObject);
} else {
this.openmct.objects.get(this.item.identifier).then(this.setObject);
}
this.getAndSetObject();
this.status = this.openmct.status.get(this.item.identifier);
this.removeStatusListener = this.openmct.status.observe(this.item.identifier, this.setStatus);
@ -247,7 +242,7 @@ export default {
this.subscribeToStaleness(domainObject);
});
},
beforeUnmount() {
async beforeUnmount() {
this.removeStatusListener();
if (this.removeSelectable) {
@ -262,14 +257,25 @@ export default {
}
if (this.mutablePromise) {
this.mutablePromise.then(() => {
this.openmct.objects.destroyMutable(this.domainObject);
});
await this.mutablePromise();
this.openmct.objects.destroyMutable(this.domainObject);
} else if (this?.domainObject?.isMutable) {
this.openmct.objects.destroyMutable(this.domainObject);
}
},
methods: {
async getAndSetObject() {
let foundObject = null;
if (this.openmct.objects.supportsMutation(this.item.identifier)) {
this.mutablePromise = this.openmct.objects.getMutable(this.item.identifier);
foundObject = await this.mutablePromise;
} else {
foundObject = await this.openmct.objects.get(this.item.identifier);
}
this.setObject(foundObject);
await this.$nextTick();
this.nicelyCalled = new NicelyCalled(this.$refs.telemetryViewWrapper);
},
formattedValueForCopy() {
const timeFormatterKey = this.openmct.time.timeSystem().key;
const timeFormatter = this.formats[timeFormatterKey];
@ -285,8 +291,7 @@ export default {
},
updateView() {
if (!this.updatingView) {
this.updatingView = true;
requestAnimationFrame(() => {
this.updatingView = this.nicelyCalled.execute(() => {
this.datum = this.latestDatum;
this.updatingView = false;
});

View File

@ -20,7 +20,12 @@
at runtime from the About dialog for additional information.
-->
<template>
<div class="c-gauge__wrapper js-gauge-wrapper" :class="gaugeClasses" :title="gaugeTitle">
<div
ref="gaugeWrapper"
class="c-gauge__wrapper js-gauge-wrapper"
:class="gaugeClasses"
:title="gaugeTitle"
>
<template v-if="typeDial">
<svg
ref="gauge"
@ -331,6 +336,7 @@
<script>
import stalenessMixin from '@/ui/mixins/staleness-mixin';
import NicelyCalled from '../../../api/nice/NicelyCalled';
import tooltipHelpers from '../../../api/tooltips/tooltipMixins';
import { DIAL_VALUE_DEG_OFFSET, getLimitDegree } from '../gauge-limit-util';
@ -533,6 +539,7 @@ export default {
}
},
mounted() {
this.nicelyCalled = new NicelyCalled(this.$refs.gaugeWrapper);
this.composition.on('add', this.addedToComposition);
this.composition.on('remove', this.removeTelemetryObject);
@ -556,6 +563,8 @@ export default {
this.openmct.time.off('bounds', this.refreshData);
this.openmct.time.off('timeSystem', this.setTimeSystem);
this.nicelyCalled.destroy();
},
methods: {
getLimitDegree: getLimitDegree,
@ -728,8 +737,7 @@ export default {
return;
}
this.isRendering = true;
requestAnimationFrame(() => {
this.isRendering = this.nicelyCalled.execute(() => {
this.isRendering = false;
this.curVal = this.round(this.formats[this.valueKey].format(this.datum), this.precision);

View File

@ -21,7 +21,7 @@
-->
<template>
<div class="gl-plot-chart-area">
<div ref="chart" class="gl-plot-chart-area">
<canvas :style="canvasStyle"></canvas>
<canvas :style="canvasStyle"></canvas>
<div ref="limitArea" class="js-limit-area">
@ -45,6 +45,7 @@
import mount from 'utils/mount';
import { toRaw } from 'vue';
import NicelyCalled from '../../../api/nice/NicelyCalled';
import configStore from '../configuration/ConfigStore';
import PlotConfigurationModel from '../configuration/PlotConfigurationModel';
import { DrawLoader } from '../draw/DrawLoader';
@ -198,6 +199,7 @@ export default {
},
mounted() {
eventHelpers.extend(this);
this.nicelyCalled = new NicelyCalled(this.$refs.chart);
this.seriesModels = [];
this.config = this.getConfig();
this.isDestroyed = false;
@ -256,6 +258,7 @@ export default {
},
beforeUnmount() {
this.destroy();
this.nicelyCalled.destroy();
},
methods: {
getConfig() {
@ -647,8 +650,8 @@ export default {
},
scheduleDraw() {
if (!this.drawScheduled) {
requestAnimationFrame(this.draw);
this.drawScheduled = true;
const called = this.nicelyCalled.execute(this.draw);
this.drawScheduled = called;
}
},
draw() {

View File

@ -23,6 +23,7 @@
<div ref="tabs" class="c-tabs-view">
<div
ref="tabsHolder"
role="tablist"
class="c-tabs-view__tabs-holder c-tabs"
:class="{
'is-dragging': isDragging && allowEditing,
@ -37,7 +38,9 @@
v-for="(tab, index) in tabsList"
:ref="tab.keyString"
:key="tab.keyString"
:aria-label="`${tab.domainObject.name} tab`"
class="c-tab c-tabs-view__tab js-tab"
role="tab"
:class="{
'is-current': isCurrent(tab)
}"

View File

@ -174,6 +174,7 @@
:header-index="headerIndex"
:column-width="columnWidths[key]"
:is-editing="isEditing"
:aria-label="`${headers[key]} filter header`"
@resize-column="resizeColumn"
@drop-target-offset-changed="setDropTargetOffset"
@drop-target-active="dropTargetActive"
@ -181,9 +182,10 @@
@resize-column-end="updateConfiguredColumnWidths"
>
<search
v-model="filters[key]"
:value="filters[key]"
class="c-table__search"
@input="filterChanged(key)"
:aria-label="`${key} filter input`"
@input="filterChanged(key, $event)"
@clear="clearFilter(key)"
>
<button
@ -278,6 +280,7 @@ import { toRaw } from 'vue';
import stalenessMixin from '@/ui/mixins/staleness-mixin';
import NicelyCalled from '../../../api/nice/NicelyCalled';
import CSVExporter from '../../../exporters/CSVExporter.js';
import ProgressBar from '../../../ui/components/ProgressBar.vue';
import Search from '../../../ui/components/SearchComponent.vue';
@ -478,6 +481,7 @@ export default {
this.filterChanged = _.debounce(this.filterChanged, 500);
},
mounted() {
this.nicelyCalled = new NicelyCalled(this.$refs.root);
this.csvExporter = new CSVExporter();
this.rowsAdded = _.throttle(this.rowsAdded, 200);
this.rowsRemoved = _.throttle(this.rowsRemoved, 200);
@ -543,12 +547,13 @@ export default {
this.table.configuration.destroy();
this.table.destroy();
this.nicelyCalled.destroy();
},
methods: {
updateVisibleRows() {
if (!this.updatingView) {
this.updatingView = true;
requestAnimationFrame(() => {
this.updatingView = this.nicelyCalled.execute(() => {
let start = 0;
let end = VISIBLE_ROW_COUNT;
let tableRows = this.table.tableRows.getRows();
@ -666,7 +671,8 @@ export default {
this.headersHolderEl.scrollLeft = this.scrollable.scrollLeft;
}
},
filterChanged(columnKey) {
filterChanged(columnKey, newFilterValue) {
this.filters[columnKey] = newFilterValue;
if (this.enableRegexSearch[columnKey]) {
if (this.isCompleteRegex(this.filters[columnKey])) {
this.table.tableRows.setColumnRegexFilter(
@ -823,21 +829,23 @@ export default {
let scrollTop = this.scrollable.scrollTop;
this.resizePollHandle = setInterval(() => {
if ((el.clientWidth !== width || el.clientHeight !== height) && this.isAutosizeEnabled) {
this.calculateTableSize();
// On some resize events scrollTop is reset to 0. Possibly due to a transition we're using?
// Need to preserve scroll position in this case.
if (this.autoScroll) {
this.scrollToBottom();
} else {
this.scrollable.scrollTop = scrollTop;
this.nicelyCalled.execute(() => {
if ((el.clientWidth !== width || el.clientHeight !== height) && this.isAutosizeEnabled) {
this.calculateTableSize();
// On some resize events scrollTop is reset to 0. Possibly due to a transition we're using?
// Need to preserve scroll position in this case.
if (this.autoScroll) {
this.scrollToBottom();
} else {
this.scrollable.scrollTop = scrollTop;
}
width = el.clientWidth;
height = el.clientHeight;
}
width = el.clientWidth;
height = el.clientHeight;
}
scrollTop = this.scrollable.scrollTop;
scrollTop = this.scrollable.scrollTop;
});
}, RESIZE_POLL_INTERVAL);
},
clearRowsAndRerender() {

View File

@ -335,33 +335,6 @@ describe('the plugin', () => {
expect(toColumnText).toEqual(firstColumnText);
});
it('Supports filtering telemetry by regular text search', async () => {
tableInstance.tableRows.setColumnFilter('some-key', '1');
await nextTick();
let filteredRowElements = element.querySelectorAll('table.c-telemetry-table__body tr');
expect(filteredRowElements.length).toEqual(1);
tableInstance.tableRows.setColumnFilter('some-key', '');
await nextTick();
let allRowElements = element.querySelectorAll('table.c-telemetry-table__body tr');
expect(allRowElements.length).toEqual(3);
});
it('Supports filtering using Regex', async () => {
tableInstance.tableRows.setColumnRegexFilter('some-key', '^some-value$');
await nextTick();
let filteredRowElements = element.querySelectorAll('table.c-telemetry-table__body tr');
expect(filteredRowElements.length).toEqual(0);
tableInstance.tableRows.setColumnRegexFilter('some-key', '^some-value');
await nextTick();
let allRowElements = element.querySelectorAll('table.c-telemetry-table__body tr');
expect(allRowElements.length).toEqual(3);
});
it('displays the correct number of column headers when the configuration is mutated', async () => {
const tableInstanceConfiguration = tableInstance.domainObject.configuration;
tableInstanceConfiguration.hiddenColumns['some-key'] = true;