[Dashboard] fix custom time ranges not applied to panel until global query context changes (#155458)

Fixes https://github.com/elastic/kibana/issues/155409

https://github.com/elastic/kibana/pull/152516 incorrectly attempted to
resolve https://github.com/elastic/kibana/issues/151221.
https://github.com/elastic/kibana/pull/152516 updated shouldFetch$ to
only check searchSessionId to determine if re-fetching is required. This
logic did not work when custom time ranges are applied to panels since
custom time ranges do not require new search session id yet the time
range changed.

This PR reverts shouldFetch$ logic of only checking searchSessionId to
determine if re-fetching is required.

Instead, this PR moves searchSessionId out of input and into dashboard
instance state. That way, `input` updates, such as query, do not trigger
additional `input` updates. The PR also updates seachSessionId logic
from async to sync so that dashboard can update seachSessionId on input
changes prior to child embeddables updating to parent input changes.
This avoids the double fetch and allows children to only have a single
input update on query state change.

There was a functional test, panel_time_range, that should have caught
the regression but that test had a bug in it. The assertion that the
custom time range was applied looked like `expect(await
testSubjects.exists('emptyPlaceholder'))` which will never fail the test
because the last part of the expect is missing. Instead, the statements
should be `expect(await
testSubjects.exists('emptyPlaceholder')).to.be(true)`. These updates to
the functional test would have caught the regression (I verified this by
making these changes on main and running the test. They do indeed fail).

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: nreese <reese.nathan@elastic.co>
This commit is contained in:
Devon Thomson 2023-04-26 09:02:59 -04:00 committed by GitHub
parent 2376ee95e2
commit 049c51093b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 193 additions and 95 deletions

View file

@ -7,7 +7,10 @@
*/
import {
ContactCardEmbeddable,
ContactCardEmbeddableFactory,
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
CONTACT_CARD_EMBEDDABLE,
} from '@kbn/embeddable-plugin/public/lib/test_samples';
import {
@ -253,3 +256,63 @@ test('creates a control group from the control group factory and waits for it to
);
expect(mockControlGroupContainer.untilInitialized).toHaveBeenCalled();
});
/*
* dashboard.getInput$() subscriptions are used to update:
* 1) dashboard instance searchSessionId state
* 2) child input on parent input changes
*
* Rxjs subscriptions are executed in the order that they are created.
* This test ensures that searchSessionId update subscription is created before child input subscription
* to ensure child input subscription includes updated searchSessionId.
*/
test('searchSessionId is updated prior to child embeddable parent subscription execution', async () => {
const embeddableFactory = {
create: new ContactCardEmbeddableFactory((() => null) as any, {} as any),
getDefaultInput: jest.fn().mockResolvedValue({
timeRange: {
to: 'now',
from: 'now-15m',
},
}),
};
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
.fn()
.mockReturnValue(embeddableFactory);
let sessionCount = 0;
pluginServices.getServices().data.search.session.start = () => {
sessionCount++;
return `searchSessionId${sessionCount}`;
};
const dashboard = await createDashboard(embeddableId, {
searchSessionSettings: {
getSearchSessionIdFromURL: () => undefined,
removeSessionIdFromUrl: () => {},
createSessionRestorationDataProvider: () => {},
} as unknown as DashboardCreationOptions['searchSessionSettings'],
});
const embeddable = await dashboard.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
ContactCardEmbeddable
>(CONTACT_CARD_EMBEDDABLE, {
firstName: 'Bob',
});
expect(embeddable.getInput().searchSessionId).toBe('searchSessionId1');
dashboard.updateInput({
timeRange: {
to: 'now',
from: 'now-7d',
},
});
expect(sessionCount).toBeGreaterThan(1);
const embeddableInput = embeddable.getInput();
expect((embeddableInput as any).timeRange).toEqual({
to: 'now',
from: 'now-7d',
});
expect(embeddableInput.searchSessionId).toBe(`searchSessionId${sessionCount}`);
});

View file

@ -217,6 +217,7 @@ export const createDashboard = async (
// --------------------------------------------------------------------------------------
// Set up search sessions integration.
// --------------------------------------------------------------------------------------
let initialSearchSessionId;
if (searchSessionSettings) {
const { sessionIdToRestore } = searchSessionSettings;
@ -229,7 +230,7 @@ export const createDashboard = async (
}
const existingSession = session.getSessionId();
const initialSearchSessionId =
initialSearchSessionId =
sessionIdToRestore ??
(existingSession && incomingEmbeddable ? existingSession : session.start());
@ -238,7 +239,6 @@ export const createDashboard = async (
creationOptions?.searchSessionSettings
);
});
initialInput.searchSessionId = initialSearchSessionId;
}
// --------------------------------------------------------------------------------------
@ -284,6 +284,7 @@ export const createDashboard = async (
const dashboardContainer = new DashboardContainer(
initialInput,
reduxEmbeddablePackage,
initialSearchSessionId,
savedObjectResult?.dashboardInput,
dashboardCreationStartTime,
undefined,

View file

@ -6,14 +6,13 @@
* Side Public License, v 1.
*/
import { debounceTime, pairwise, skip } from 'rxjs/operators';
import { pairwise, skip } from 'rxjs/operators';
import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public';
import { DashboardContainer } from '../../dashboard_container';
import { DashboardContainerInput } from '../../../../../common';
import { pluginServices } from '../../../../services/plugin_services';
import { CHANGE_CHECK_DEBOUNCE } from '../../../../dashboard_constants';
import { DashboardCreationOptions } from '../../dashboard_container_factory';
import { getShouldRefresh } from '../../../state/diffing/dashboard_diffing_integration';
@ -57,10 +56,10 @@ export function startDashboardSearchSessionIntegration(
// listen to and compare states to determine when to launch a new session.
this.getInput$()
.pipe(pairwise(), debounceTime(CHANGE_CHECK_DEBOUNCE))
.subscribe(async (states) => {
.pipe(pairwise())
.subscribe((states) => {
const [previous, current] = states as DashboardContainerInput[];
const shouldRefetch = await getShouldRefresh.bind(this)(previous, current);
const shouldRefetch = getShouldRefresh.bind(this)(previous, current);
if (!shouldRefetch) return;
const currentSearchSessionId = this.getState().explicitInput.searchSessionId;
@ -83,7 +82,7 @@ export function startDashboardSearchSessionIntegration(
})();
if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) {
this.dispatch.setSearchSessionId(updatedSearchSessionId);
this.searchSessionId = updatedSearchSessionId;
}
});

View file

@ -9,6 +9,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
import { findTestSubject, nextTick } from '@kbn/test-jest-helpers';
import { I18nProvider } from '@kbn/i18n-react';
import {
@ -29,9 +30,10 @@ import { applicationServiceMock, coreMock } from '@kbn/core/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { createEditModeActionDefinition } from '@kbn/embeddable-plugin/public/lib/test_samples';
import { buildMockDashboard, getSampleDashboardPanel } from '../../mocks';
import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks';
import { pluginServices } from '../../services/plugin_services';
import { ApplicationStart } from '@kbn/core-application-browser';
import { DashboardContainer } from './dashboard_container';
const theme = coreMock.createStart().theme;
let application: ApplicationStart | undefined;
@ -171,7 +173,11 @@ test('Container view mode change propagates to new children', async () => {
test('searchSessionId propagates to children', async () => {
const searchSessionId1 = 'searchSessionId1';
const container = buildMockDashboard({ searchSessionId: searchSessionId1 });
const container = new DashboardContainer(
getSampleDashboardInput(),
mockedReduxEmbeddablePackage,
searchSessionId1
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
ContactCardEmbeddableOutput,
@ -181,11 +187,6 @@ test('searchSessionId propagates to children', async () => {
});
expect(embeddable.getInput().searchSessionId).toBe(searchSessionId1);
const searchSessionId2 = 'searchSessionId2';
container.updateInput({ searchSessionId: searchSessionId2 });
expect(embeddable.getInput().searchSessionId).toBe(searchSessionId2);
});
test('DashboardContainer in edit mode shows edit mode actions', async () => {

View file

@ -95,6 +95,8 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
public subscriptions: Subscription = new Subscription();
public controlGroup?: ControlGroupContainer;
public searchSessionId?: string;
// cleanup
public stopSyncingWithUnifiedSearch?: () => void;
private cleanupStateTools: () => void;
@ -117,6 +119,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
constructor(
initialInput: DashboardContainerInput,
reduxToolsPackage: ReduxToolsPackage,
initialSessionId?: string,
initialLastSavedInput?: DashboardContainerInput,
dashboardCreationStartTime?: number,
parent?: Container,
@ -146,6 +149,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
} = pluginServices.getServices());
this.creationOptions = creationOptions;
this.searchSessionId = initialSessionId;
this.dashboardCreationStartTime = dashboardCreationStartTime;
// start diffing dashboard state
@ -244,7 +248,6 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
syncColors,
syncTooltips,
hidePanelTitles,
searchSessionId,
refreshInterval,
executionContext,
} = this.input;
@ -254,10 +257,10 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
combinedFilters = combineDashboardFiltersWithControlGroupFilters(filters, this.controlGroup);
}
return {
searchSessionId: this.searchSessionId,
refreshConfig: refreshInterval,
filters: combinedFilters,
hidePanelTitles,
searchSessionId,
executionContext,
syncTooltips,
syncColors,

View file

@ -99,13 +99,6 @@ export const dashboardContainerReducers = {
state.explicitInput.title = action.payload;
},
setSearchSessionId: (
state: DashboardReduxState,
action: PayloadAction<DashboardContainerInput['searchSessionId']>
) => {
state.explicitInput.searchSessionId = action.payload;
},
// ------------------------------------------------------------------------------
// Unsaved Changes Reducers
// ------------------------------------------------------------------------------

View file

@ -37,7 +37,7 @@ export type DashboardDiffFunctions = {
) => boolean | Promise<boolean>;
};
export const isKeyEqual = async (
export const isKeyEqualAsync = async (
key: keyof DashboardContainerInput,
diffFunctionProps: DiffFunctionProps<typeof key>,
diffingFunctions: DashboardDiffFunctions
@ -52,6 +52,25 @@ export const isKeyEqual = async (
return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue);
};
export const isKeyEqual = (
key: keyof Omit<DashboardContainerInput, 'panels'>, // only Panels is async
diffFunctionProps: DiffFunctionProps<typeof key>,
diffingFunctions: DashboardDiffFunctions
) => {
const propsAsNever = diffFunctionProps as never; // todo figure out why props has conflicting types in some constituents.
const diffingFunction = diffingFunctions[key];
if (!diffingFunction) {
return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue);
}
if (diffingFunction?.prototype?.name === 'AsyncFunction') {
throw new Error(
`The function for key "${key}" is async, must use isKeyEqualAsync for asynchronous functions`
);
}
return diffingFunction(propsAsNever);
};
/**
* A collection of functions which diff individual keys of dashboard state. If a key is missing from this list it is
* diffed by the default diffing function, fastIsEqual.

View file

@ -29,14 +29,14 @@ describe('getShouldRefresh', () => {
);
describe('filter changes', () => {
test('should return false when filters do not change', async () => {
test('should return false when filters do not change', () => {
const lastInput = {
filters: [existsFilter],
} as unknown as DashboardContainerInput;
expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false);
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false);
});
test('should return true when pinned filters change', async () => {
test('should return true when pinned filters change', () => {
const pinnedFilter = pinFilter(existsFilter);
const lastInput = {
filters: [pinnedFilter],
@ -44,10 +44,10 @@ describe('getShouldRefresh', () => {
const input = {
filters: [toggleFilterNegated(pinnedFilter)],
} as unknown as DashboardContainerInput;
expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true);
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true);
});
test('should return false when disabled filters change', async () => {
test('should return false when disabled filters change', () => {
const disabledFilter = disableFilter(existsFilter);
const lastInput = {
filters: [disabledFilter],
@ -55,29 +55,29 @@ describe('getShouldRefresh', () => {
const input = {
filters: [toggleFilterNegated(disabledFilter)],
} as unknown as DashboardContainerInput;
expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false);
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false);
});
test('should return false when pinned filter changes to unpinned', async () => {
test('should return false when pinned filter changes to unpinned', () => {
const lastInput = {
filters: [existsFilter],
} as unknown as DashboardContainerInput;
const input = {
filters: [pinFilter(existsFilter)],
} as unknown as DashboardContainerInput;
expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false);
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false);
});
});
describe('timeRange changes', () => {
test('should return false when timeRange does not change', async () => {
test('should return false when timeRange does not change', () => {
const lastInput = {
timeRange: { from: 'now-15m', to: 'now' },
} as unknown as DashboardContainerInput;
expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false);
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false);
});
test('should return true when timeRange changes (timeRestore is true)', async () => {
test('should return true when timeRange changes (timeRestore is true)', () => {
const lastInput = {
timeRange: { from: 'now-15m', to: 'now' },
timeRestore: true,
@ -86,10 +86,10 @@ describe('getShouldRefresh', () => {
timeRange: { from: 'now-30m', to: 'now' },
timeRestore: true,
} as unknown as DashboardContainerInput;
expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true);
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true);
});
test('should return true when timeRange changes (timeRestore is false)', async () => {
test('should return true when timeRange changes (timeRestore is false)', () => {
const lastInput = {
timeRange: { from: 'now-15m', to: 'now' },
timeRestore: false,
@ -98,7 +98,26 @@ describe('getShouldRefresh', () => {
timeRange: { from: 'now-30m', to: 'now' },
timeRestore: false,
} as unknown as DashboardContainerInput;
expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true);
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true);
});
});
describe('key without custom diffing function (syncColors)', () => {
test('should return false when syncColors do not change', () => {
const lastInput = {
syncColors: false,
} as unknown as DashboardContainerInput;
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false);
});
test('should return true when syncColors change', () => {
const lastInput = {
syncColors: false,
} as unknown as DashboardContainerInput;
const input = {
syncColors: true,
} as unknown as DashboardContainerInput;
expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true);
});
});
});

View file

@ -9,13 +9,13 @@ import { omit } from 'lodash';
import { AnyAction, Middleware } from 'redux';
import { debounceTime, Observable, startWith, Subject, switchMap } from 'rxjs';
import { DashboardContainerInput } from '../../../../common';
import type { DashboardDiffFunctions } from './dashboard_diffing_functions';
import {
isKeyEqual,
isKeyEqualAsync,
shouldRefreshDiffingFunctions,
unsavedChangesDiffingFunctions,
} from './dashboard_diffing_functions';
import { DashboardContainerInput } from '../../../../common';
import { pluginServices } from '../../../services/plugin_services';
import { DashboardContainer, DashboardCreationOptions } from '../..';
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
@ -29,7 +29,6 @@ import { dashboardContainerReducers } from '../dashboard_container_reducers';
export const reducersToIgnore: Array<keyof typeof dashboardContainerReducers> = [
'setTimeslice',
'setFullScreenMode',
'setSearchSessionId',
'setExpandedPanelId',
'setHasUnsavedChanges',
];
@ -40,7 +39,6 @@ export const reducersToIgnore: Array<keyof typeof dashboardContainerReducers> =
const keysToOmitFromSessionStorage: Array<keyof DashboardContainerInput> = [
'lastReloadRequestTime',
'executionContext',
'searchSessionId',
'timeslice',
'id',
@ -55,7 +53,6 @@ const keysToOmitFromSessionStorage: Array<keyof DashboardContainerInput> = [
export const keysNotConsideredUnsavedChanges: Array<keyof DashboardContainerInput> = [
'lastReloadRequestTime',
'executionContext',
'searchSessionId',
'timeslice',
'viewMode',
'id',
@ -64,7 +61,7 @@ export const keysNotConsideredUnsavedChanges: Array<keyof DashboardContainerInpu
/**
* input keys that will cause a new session to be created.
*/
const refetchKeys: Array<keyof DashboardContainerInput> = [
const sessionChangeKeys: Array<keyof Omit<DashboardContainerInput, 'panels'>> = [
'query',
'filters',
'timeRange',
@ -139,42 +136,17 @@ export async function getUnsavedChanges(
const allKeys = [...new Set([...Object.keys(lastInput), ...Object.keys(input)])] as Array<
keyof DashboardContainerInput
>;
return await getInputChanges(this, lastInput, input, allKeys, unsavedChangesDiffingFunctions);
}
export async function getShouldRefresh(
this: DashboardContainer,
lastInput: DashboardContainerInput,
input: DashboardContainerInput
): Promise<boolean> {
const inputChanges = await getInputChanges(
this,
lastInput,
input,
refetchKeys,
shouldRefreshDiffingFunctions
);
return Object.keys(inputChanges).length > 0;
}
async function getInputChanges(
container: DashboardContainer,
lastInput: DashboardContainerInput,
input: DashboardContainerInput,
keys: Array<keyof DashboardContainerInput>,
diffingFunctions: DashboardDiffFunctions
): Promise<Partial<DashboardContainerInput>> {
const keyComparePromises = keys.map(
const keyComparePromises = allKeys.map(
(key) =>
new Promise<{ key: keyof DashboardContainerInput; isEqual: boolean }>((resolve) => {
if (input[key] === undefined && lastInput[key] === undefined) {
resolve({ key, isEqual: true });
}
isKeyEqual(
isKeyEqualAsync(
key,
{
container,
container: this,
currentValue: input[key],
currentInput: input,
@ -182,7 +154,7 @@ async function getInputChanges(
lastValue: lastInput[key],
lastInput,
},
diffingFunctions
unsavedChangesDiffingFunctions
).then((isEqual) => resolve({ key, isEqual }));
})
);
@ -196,6 +168,34 @@ async function getInputChanges(
return inputChanges;
}
export function getShouldRefresh(
this: DashboardContainer,
lastInput: DashboardContainerInput,
input: DashboardContainerInput
): boolean {
for (const key of sessionChangeKeys) {
if (input[key] === undefined && lastInput[key] === undefined) {
continue;
}
if (
!isKeyEqual(
key,
{
container: this,
currentValue: input[key],
currentInput: input,
lastValue: lastInput[key],
lastInput,
},
shouldRefreshDiffingFunctions
)
) {
return true;
}
}
return false;
}
function updateUnsavedChangesState(
this: DashboardContainer,
unsavedChanges: Partial<DashboardContainerInput>

View file

@ -27,19 +27,24 @@ export function shouldFetch$<
return updated$.pipe(map(() => getInput())).pipe(
// wrapping distinctUntilChanged with startWith and skip to prime distinctUntilChanged with an initial input value.
startWith(getInput()),
distinctUntilChanged((a: TFilterableEmbeddableInput, b: TFilterableEmbeddableInput) => {
// Only need to diff searchSessionId when container uses search sessions because
// searchSessionId changes with any filter, query, or time changes
if (a.searchSessionId !== undefined || b.searchSessionId !== undefined) {
return a.searchSessionId === b.searchSessionId;
}
distinctUntilChanged(
(previous: TFilterableEmbeddableInput, current: TFilterableEmbeddableInput) => {
if (
!fastIsEqual(
[previous.searchSessionId, previous.query, previous.timeRange, previous.timeslice],
[current.searchSessionId, current.query, current.timeRange, current.timeslice]
)
) {
return false;
}
if (!fastIsEqual([a.query, a.timeRange, a.timeslice], [b.query, b.timeRange, b.timeslice])) {
return false;
return onlyDisabledFiltersChanged(
previous.filters,
current.filters,
shouldRefreshFilterCompareOptions
);
}
return onlyDisabledFiltersChanged(a.filters, b.filters, shouldRefreshFilterCompareOptions);
}),
),
skip(1)
);
}

View file

@ -106,7 +106,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
});
it('starts a session on filter change', async () => {
await filterBar.removeAllFilters();
await filterBar.removeFilter('animal');
const sessionIds = await getSessionIds();
expect(sessionIds.length).to.be(1);
});

View file

@ -441,11 +441,6 @@ describe('embeddable', () => {
expect(expressionRenderer).toHaveBeenCalledTimes(1);
embeddable.updateInput({
filters: [{ meta: { alias: 'test', negate: false, disabled: false } }],
});
await new Promise((resolve) => setTimeout(resolve, 0));
embeddable.updateInput({
searchSessionId: 'nextSession',
});

View file

@ -50,7 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardCustomizePanel.clickSaveButton();
await PageObjects.dashboard.waitForRenderComplete();
await dashboardBadgeActions.expectExistsTimeRangeBadgeAction();
expect(await testSubjects.exists('emptyPlaceholder'));
expect(await testSubjects.exists('emptyPlaceholder')).to.be(true);
await PageObjects.dashboard.clickQuickSave();
});
@ -60,7 +60,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardCustomizePanel.clickSaveButton();
await PageObjects.dashboard.waitForRenderComplete();
await dashboardBadgeActions.expectMissingTimeRangeBadgeAction();
expect(await testSubjects.exists('xyVisChart'));
expect(await testSubjects.exists('xyVisChart')).to.be(true);
});
});
@ -74,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardCustomizePanel.clickSaveButton();
await PageObjects.dashboard.waitForRenderComplete();
await dashboardBadgeActions.expectExistsTimeRangeBadgeAction();
expect(await testSubjects.exists('emptyPlaceholder'));
expect(await testSubjects.exists('emptyPlaceholder')).to.be(true);
await PageObjects.dashboard.clickQuickSave();
});
@ -84,7 +84,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardCustomizePanel.clickSaveButton();
await PageObjects.dashboard.waitForRenderComplete();
await dashboardBadgeActions.expectMissingTimeRangeBadgeAction();
expect(await testSubjects.exists('xyVisChart'));
expect(await testSubjects.exists('xyVisChart')).to.be(true);
});
});