[Dashboard][Embeddable] Create Explicit Diffing System (#121241)

Co-authored-by: Anton Dosov <dosantappdev@gmail.com>
Co-authored-by: nreese <reese.nathan@elastic.co>
This commit is contained in:
Devon Thomson 2022-01-11 10:46:42 -05:00 committed by GitHub
parent 27a9df79e7
commit 944ccf10e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 632 additions and 179 deletions

View file

@ -92,13 +92,11 @@ export class BookEmbeddable
};
readonly getInputAsValueType = async (): Promise<BookByValueInput> => {
const input = this.attributeService.getExplicitInputFromEmbeddable(this);
return this.attributeService.getInputAsValueType(input);
return this.attributeService.getInputAsValueType(this.getExplicitInput());
};
readonly getInputAsRefType = async (): Promise<BookByReferenceInput> => {
const input = this.attributeService.getExplicitInputFromEmbeddable(this);
return this.attributeService.getInputAsRefType(input, {
return this.attributeService.getInputAsRefType(this.getExplicitInput(), {
showSaveModal: true,
saveModalTitle: this.getTitle(),
});

View file

@ -71,7 +71,7 @@ export const createEditBookAction = (getStartServices: () => Promise<StartServic
const newInput = await attributeService.wrapAttributes(
attributes,
useRefType,
attributeService.getExplicitInputFromEmbeddable(embeddable)
embeddable.getExplicitInput()
);
if (!useRefType && (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId) {
// Set the saved object ID to null so that update input will remove the existing savedObjectId...

View file

@ -8,9 +8,9 @@
import _ from 'lodash';
import { History } from 'history';
import { debounceTime } from 'rxjs/operators';
import { debounceTime, switchMap } from 'rxjs/operators';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
import { DashboardConstants } from '../..';
import { ViewMode } from '../../services/embeddable';
@ -261,37 +261,47 @@ export const useDashboardAppState = ({
dashboardAppState.$onDashboardStateChange,
dashboardBuildContext.$checkForUnsavedChanges,
])
.pipe(debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE))
.subscribe((states) => {
const [lastSaved, current] = states;
const unsavedChanges = diffDashboardState(lastSaved, current);
.pipe(
debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE),
switchMap((states) => {
return new Observable((observer) => {
const [lastSaved, current] = states;
diffDashboardState({
getEmbeddable: (id: string) => dashboardContainer.untilEmbeddableLoaded(id),
originalState: lastSaved,
newState: current,
}).then((unsavedChanges) => {
if (observer.closed) return;
const savedTimeChanged =
lastSaved.timeRestore &&
(!areTimeRangesEqual(
{
from: savedDashboard?.timeFrom,
to: savedDashboard?.timeTo,
},
timefilter.getTime()
) ||
!areRefreshIntervalsEqual(
savedDashboard?.refreshInterval,
timefilter.getRefreshInterval()
));
const savedTimeChanged =
lastSaved.timeRestore &&
(!areTimeRangesEqual(
{
from: savedDashboard?.timeFrom,
to: savedDashboard?.timeTo,
},
timefilter.getTime()
) ||
!areRefreshIntervalsEqual(
savedDashboard?.refreshInterval,
timefilter.getRefreshInterval()
));
/**
* changes to the dashboard should only be considered 'unsaved changes' when
* editing the dashboard
*/
const hasUnsavedChanges =
current.viewMode === ViewMode.EDIT &&
(Object.keys(unsavedChanges).length > 0 || savedTimeChanged);
setDashboardAppState((s) => ({ ...s, hasUnsavedChanges }));
/**
* changes to the dashboard should only be considered 'unsaved changes' when
* editing the dashboard
*/
const hasUnsavedChanges =
current.viewMode === ViewMode.EDIT &&
(Object.keys(unsavedChanges).length > 0 || savedTimeChanged);
setDashboardAppState((s) => ({ ...s, hasUnsavedChanges }));
unsavedChanges.viewMode = current.viewMode; // always push view mode into session store.
dashboardSessionStorage.setState(savedDashboardId, unsavedChanges);
});
unsavedChanges.viewMode = current.viewMode; // always push view mode into session store.
dashboardSessionStorage.setState(savedDashboardId, unsavedChanges);
});
});
})
)
.subscribe();
/**
* initialize the last saved state, and build a callback which can be used to update

View file

@ -0,0 +1,166 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Filter } from '@kbn/es-query';
import { DashboardOptions, DashboardState } from '../../types';
import { diffDashboardState } from './diff_dashboard_state';
import { EmbeddableInput, IEmbeddable, ViewMode } from '../../services/embeddable';
const testFilter: Filter = {
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'hi' },
};
const getEmbeddable = (id: string) =>
Promise.resolve({
getExplicitInputIsEqual: (previousInput: EmbeddableInput) => true,
} as unknown as IEmbeddable);
const getDashboardState = (state?: Partial<DashboardState>): DashboardState => {
const defaultState: DashboardState = {
description: 'This is a dashboard which is very neat',
query: { query: '', language: 'kql' },
title: 'A very neat dashboard',
viewMode: ViewMode.VIEW,
fullScreenMode: false,
filters: [testFilter],
timeRestore: false,
tags: [],
options: {
hidePanelTitles: false,
useMargins: true,
syncColors: false,
},
panels: {
panel_1: {
type: 'panel_type',
gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_1' },
panelRefName: 'panel_panel_1',
explicitInput: {
id: 'panel_1',
},
},
panel_2: {
type: 'panel_type',
gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_2' },
panelRefName: 'panel_panel_2',
explicitInput: {
id: 'panel_1',
},
},
},
};
return { ...defaultState, ...state };
};
const getKeysFromDiff = async (partialState?: Partial<DashboardState>): Promise<string[]> =>
Object.keys(
await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState(partialState),
getEmbeddable,
})
);
describe('Dashboard state diff function', () => {
it('finds no difference in equal states', async () => {
expect(await getKeysFromDiff()).toEqual([]);
});
it('diffs simple state keys correctly', async () => {
expect(
(
await getKeysFromDiff({
timeRestore: true,
title: 'what a cool new title',
description: 'what a cool new description',
query: { query: 'woah a query', language: 'kql' },
})
).sort()
).toEqual(['description', 'query', 'timeRestore', 'title']);
});
it('picks up differences in dashboard options', async () => {
expect(
await getKeysFromDiff({
options: {
hidePanelTitles: false,
useMargins: false,
syncColors: false,
},
})
).toEqual(['options']);
});
it('considers undefined and false to be equivalent in dashboard options', async () => {
expect(
await getKeysFromDiff({
options: {
useMargins: true,
syncColors: undefined,
} as unknown as DashboardOptions,
})
).toEqual([]);
});
it('calls getExplicitInputIsEqual on each panel', async () => {
const mockedGetEmbeddable = jest.fn().mockImplementation((id) => getEmbeddable(id));
await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState(),
getEmbeddable: mockedGetEmbeddable,
});
expect(mockedGetEmbeddable).toHaveBeenCalledTimes(2);
});
it('short circuits panels comparison when one panel returns false', async () => {
const mockedGetEmbeddable = jest.fn().mockImplementation((id) => {
if (id === 'panel_1') {
return Promise.resolve({
getExplicitInputIsEqual: (previousInput: EmbeddableInput) => false,
} as unknown as IEmbeddable);
}
getEmbeddable(id);
});
await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState(),
getEmbeddable: mockedGetEmbeddable,
});
expect(mockedGetEmbeddable).toHaveBeenCalledTimes(1);
});
it('skips individual panel comparisons if panel ids are different', async () => {
const mockedGetEmbeddable = jest.fn().mockImplementation((id) => getEmbeddable(id));
const stateDiff = await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState({
panels: {
panel_1: {
type: 'panel_type',
gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_1' },
panelRefName: 'panel_panel_1',
explicitInput: {
id: 'panel_1',
},
},
// panel 2 has been deleted
},
}),
getEmbeddable: mockedGetEmbeddable,
});
expect(mockedGetEmbeddable).not.toHaveBeenCalled();
expect(Object.keys(stateDiff)).toEqual(['panels']);
});
});

View file

@ -6,158 +6,168 @@
* Side Public License, v 1.
*/
import _ from 'lodash';
import { DashboardPanelState } from '..';
import { esFilters, Filter } from '../../services/data';
import { EmbeddableInput } from '../../services/embeddable';
import {
DashboardContainerInput,
DashboardOptions,
DashboardPanelMap,
DashboardState,
} from '../../types';
import { xor, omit, isEmpty } from 'lodash';
import fastIsEqual from 'fast-deep-equal';
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, isFilterPinned } from '@kbn/es-query';
import { DashboardContainerInput } from '../..';
import { controlGroupInputIsEqual } from './dashboard_control_group';
import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types';
import { IEmbeddable } from '../../services/embeddable';
interface DashboardDiffCommon {
[key: string]: unknown;
}
const stateKeystoIgnore = [
'expandedPanelId',
'fullScreenMode',
'savedQuery',
'viewMode',
'tags',
] as const;
type DashboardStateToCompare = Omit<DashboardState, typeof stateKeystoIgnore[number]>;
type DashboardDiffCommonFilters = DashboardDiffCommon & { filters: Filter[] };
const inputKeystoIgnore = ['searchSessionId', 'lastReloadRequestTime', 'executionContext'] as const;
type DashboardInputToCompare = Omit<DashboardContainerInput, typeof inputKeystoIgnore[number]>;
/**
* The diff dashboard Container method is used to sync redux state and the dashboard container input.
* It should eventually be replaced with a usage of the dashboardContainer.isInputEqual function
**/
export const diffDashboardContainerInput = (
originalInput: DashboardContainerInput,
newInput: DashboardContainerInput
) => {
return commonDiffFilters<DashboardContainerInput>(
originalInput as unknown as DashboardDiffCommonFilters,
newInput as unknown as DashboardDiffCommonFilters,
['searchSessionId', 'lastReloadRequestTime', 'executionContext']
);
};
): Partial<DashboardContainerInput> => {
const { filters: originalFilters, ...commonOriginal } = omit(originalInput, inputKeystoIgnore);
const { filters: newFilters, ...commonNew } = omit(newInput, inputKeystoIgnore);
export const diffDashboardState = (
original: DashboardState,
newState: DashboardState
): Partial<DashboardState> => {
const common = commonDiffFilters<DashboardState>(
original as unknown as DashboardDiffCommonFilters,
newState as unknown as DashboardDiffCommonFilters,
[
'viewMode',
'panels',
'options',
'fullScreenMode',
'savedQuery',
'expandedPanelId',
'controlGroupInput',
],
true
);
const commonInputDiff: Partial<DashboardInputToCompare> = commonDiff(commonOriginal, commonNew);
const filtersAreEqual = getFiltersAreEqual(originalInput.filters, newInput.filters);
return {
...common,
...(panelsAreEqual(original.panels, newState.panels) ? {} : { panels: newState.panels }),
...(optionsAreEqual(original.options, newState.options) ? {} : { options: newState.options }),
...(controlGroupInputIsEqual(original.controlGroupInput, newState.controlGroupInput)
? {}
: { controlGroupInput: newState.controlGroupInput }),
...commonInputDiff,
...(filtersAreEqual ? {} : { filters: newInput.filters }),
};
};
const optionsAreEqual = (optionsA: DashboardOptions, optionsB: DashboardOptions): boolean => {
/**
* The diff dashboard state method compares dashboard state keys to determine which state keys
* have changed, and therefore should be backed up.
**/
export const diffDashboardState = async ({
originalState,
newState,
getEmbeddable,
}: {
originalState: DashboardState;
newState: DashboardState;
getEmbeddable: (id: string) => Promise<IEmbeddable>;
}): Promise<Partial<DashboardState>> => {
const {
controlGroupInput: originalControlGroupInput,
options: originalOptions,
filters: originalFilters,
panels: originalPanels,
...commonCompareOriginal
} = omit(originalState, stateKeystoIgnore);
const {
controlGroupInput: newControlGroupInput,
options: newOptions,
filters: newFilters,
panels: newPanels,
...commonCompareNew
} = omit(newState, stateKeystoIgnore);
const commonStateDiff: Partial<DashboardStateToCompare> = commonDiff(
commonCompareOriginal,
commonCompareNew
);
const panelsAreEqual = await getPanelsAreEqual(
originalState.panels,
newState.panels,
getEmbeddable
);
const optionsAreEqual = getOptionsAreEqual(originalState.options, newState.options);
const filtersAreEqual = getFiltersAreEqual(originalState.filters, newState.filters, true);
const controlGroupIsEqual = controlGroupInputIsEqual(
originalState.controlGroupInput,
newState.controlGroupInput
);
return {
...commonStateDiff,
...(panelsAreEqual ? {} : { panels: newState.panels }),
...(filtersAreEqual ? {} : { filters: newState.filters }),
...(optionsAreEqual ? {} : { options: newState.options }),
...(controlGroupIsEqual ? {} : { controlGroupInput: newState.controlGroupInput }),
};
};
const getFiltersAreEqual = (
filtersA: Filter[],
filtersB: Filter[],
ignorePinned?: boolean
): boolean => {
return compareFilters(
filtersA,
ignorePinned ? filtersB.filter((f) => !isFilterPinned(f)) : filtersB,
COMPARE_ALL_OPTIONS
);
};
const getOptionsAreEqual = (optionsA: DashboardOptions, optionsB: DashboardOptions): boolean => {
const optionKeys = [
...(Object.keys(optionsA) as Array<keyof DashboardOptions>),
...(Object.keys(optionsB) as Array<keyof DashboardOptions>),
];
for (const key of optionKeys) {
if (Boolean(optionsA[key]) !== Boolean(optionsB[key])) {
return false;
}
if (Boolean(optionsA[key]) !== Boolean(optionsB[key])) return false;
}
return true;
};
const panelsAreEqual = (panelsA: DashboardPanelMap, panelsB: DashboardPanelMap): boolean => {
const embeddableIdsA = Object.keys(panelsA);
const embeddableIdsB = Object.keys(panelsB);
if (
embeddableIdsA.length !== embeddableIdsB.length ||
_.xor(embeddableIdsA, embeddableIdsB).length > 0
) {
const getPanelsAreEqual = async (
originalPanels: DashboardPanelMap,
newPanels: DashboardPanelMap,
getEmbeddable: (id: string) => Promise<IEmbeddable>
): Promise<boolean> => {
const originalEmbeddableIds = Object.keys(originalPanels);
const newEmbeddableIds = Object.keys(newPanels);
const embeddableIdDiff = xor(originalEmbeddableIds, newEmbeddableIds);
if (embeddableIdDiff.length > 0) {
return false;
}
// embeddable ids are equal so let's compare individual panels.
for (const id of embeddableIdsA) {
const panelCommonDiff = commonDiff<DashboardPanelState>(
panelsA[id] as unknown as DashboardDiffCommon,
panelsB[id] as unknown as DashboardDiffCommon,
['panelRefName', 'explicitInput']
);
if (
Object.keys(panelCommonDiff).length > 0 ||
!explicitInputIsEqual(panelsA[id].explicitInput, panelsB[id].explicitInput)
) {
return false;
}
for (const embeddableId of newEmbeddableIds) {
const {
explicitInput: originalExplicitInput,
panelRefName: panelRefA,
...commonPanelDiffOriginal
} = originalPanels[embeddableId];
const {
explicitInput: newExplicitInput,
panelRefName: panelRefB,
...commonPanelDiffNew
} = newPanels[embeddableId];
if (!isEmpty(commonDiff(commonPanelDiffOriginal, commonPanelDiffNew))) return false;
// the position and type of this embeddable is equal. Now we compare the embeddable input
const embeddable = await getEmbeddable(embeddableId);
if (!(await embeddable.getExplicitInputIsEqual(originalExplicitInput))) return false;
}
return true;
};
/**
* Need to compare properties of explicitInput *directly* in order to handle special comparisons for 'title'
* and 'hidePanelTitles.' For example, if some object 'obj1' has 'obj1[title] = undefined' and some other
* object `obj2' simply does not have the key `title,' we want obj1 to still equal obj2 - in normal comparisons
* without this special case, `obj1 != obj2.'
* @param originalInput
* @param newInput
*/
const explicitInputIsEqual = (
originalInput: EmbeddableInput,
newInput: EmbeddableInput
): boolean => {
const diffs = commonDiff<DashboardPanelState>(originalInput, newInput, [
'hidePanelTitles',
'title',
]);
const hidePanelsAreEqual =
Boolean(originalInput.hidePanelTitles) === Boolean(newInput.hidePanelTitles);
const titlesAreEqual = originalInput.title === newInput.title;
return Object.keys(diffs).length === 0 && hidePanelsAreEqual && titlesAreEqual;
};
const commonDiffFilters = <T extends { filters: Filter[] }>(
originalObj: DashboardDiffCommonFilters,
newObj: DashboardDiffCommonFilters,
omitKeys: string[],
ignorePinned?: boolean
): Partial<T> => {
const filtersAreDifferent = () =>
!esFilters.compareFilters(
originalObj.filters,
ignorePinned ? newObj.filters.filter((f) => !esFilters.isFilterPinned(f)) : newObj.filters,
esFilters.COMPARE_ALL_OPTIONS
);
const otherDifferences = commonDiff<T>(originalObj, newObj, [...omitKeys, 'filters']);
return _.cloneDeep({
...otherDifferences,
...(filtersAreDifferent() ? { filters: newObj.filters } : {}),
});
};
const commonDiff = <T>(
originalObj: DashboardDiffCommon,
newObj: DashboardDiffCommon,
omitKeys: string[]
) => {
const commonDiff = <T>(originalObj: Partial<T>, newObj: Partial<T>) => {
const differences: Partial<T> = {};
const keys = [...Object.keys(originalObj), ...Object.keys(newObj)].filter(
(key) => !omitKeys.includes(key)
);
keys.forEach((key) => {
if (key === undefined) return;
if (!_.isEqual(originalObj[key], newObj[key])) {
(differences as { [key: string]: unknown })[key] = newObj[key];
}
});
const keys = [
...(Object.keys(originalObj) as Array<keyof T>),
...(Object.keys(newObj) as Array<keyof T>),
];
for (const key of keys) {
if (key === undefined) continue;
if (!fastIsEqual(originalObj[key], newObj[key])) differences[key] = newObj[key];
}
return differences;
};

View file

@ -11,6 +11,7 @@ export { getDashboardIdFromUrl } from './url';
export { saveDashboard } from './save_dashboard';
export { migrateAppState } from './migrate_app_state';
export { addHelpMenuToAppChrome } from './help_menu_util';
export { diffDashboardState } from './diff_dashboard_state';
export { getTagsFromSavedDashboard } from './dashboard_tagging';
export { syncDashboardUrlState } from './sync_dashboard_url_state';
export { DashboardSessionStorage } from './dashboard_session_storage';
@ -19,7 +20,6 @@ export { attemptLoadDashboardByTitle } from './load_dashboard_by_title';
export { syncDashboardFilterState } from './sync_dashboard_filter_state';
export { syncDashboardIndexPatterns } from './sync_dashboard_index_patterns';
export { syncDashboardContainerInput } from './sync_dashboard_container_input';
export { diffDashboardContainerInput, diffDashboardState } from './diff_dashboard_state';
export { loadDashboardHistoryLocationState } from './load_dashboard_history_location_state';
export { buildDashboardContainer, tryDestroyDashboardContainer } from './build_dashboard_container';
export {

View file

@ -65,6 +65,8 @@ export {
VALUE_CLICK_TRIGGER,
ViewMode,
withEmbeddableSubscription,
genericEmbeddableInputIsEqual,
omitGenericEmbeddableInput,
isSavedObjectEmbeddableInput,
isRangeSelectTriggerContext,
isValueClickTriggerContext,

View file

@ -15,8 +15,6 @@ import {
EmbeddableInput,
SavedObjectEmbeddableInput,
isSavedObjectEmbeddableInput,
IEmbeddable,
Container,
EmbeddableFactoryNotFoundError,
EmbeddableFactory,
} from '../index';
@ -134,11 +132,6 @@ export class AttributeService<
return isSavedObjectEmbeddableInput(input);
};
public getExplicitInputFromEmbeddable(embeddable: IEmbeddable): ValType | RefType {
return ((embeddable.getRoot() as Container).getInput()?.panels?.[embeddable.id]
?.explicitInput ?? embeddable.getInput()) as ValType | RefType;
}
getInputAsValueType = async (input: ValType | RefType): Promise<ValType> => {
if (!this.inputIsRefType(input)) {
return input as ValType;

View file

@ -7,6 +7,7 @@
*/
import uuid from 'uuid';
import { isEqual, xor } from 'lodash';
import { merge, Subscription } from 'rxjs';
import { startWith, pairwise } from 'rxjs/operators';
import {
@ -16,6 +17,7 @@ import {
ErrorEmbeddable,
EmbeddableFactory,
IEmbeddable,
isErrorEmbeddable,
} from '../embeddables';
import { IContainer, ContainerInput, ContainerOutput, PanelState } from './i_container';
import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors';
@ -195,6 +197,32 @@ export abstract class Container<
});
}
public async getExplicitInputIsEqual(lastInput: TContainerInput) {
const { panels: lastPanels, ...restOfLastInput } = lastInput;
const { panels: currentPanels, ...restOfCurrentInput } = this.getInput();
const otherInputIsEqual = isEqual(restOfLastInput, restOfCurrentInput);
if (!otherInputIsEqual) return false;
const embeddableIdsA = Object.keys(lastPanels);
const embeddableIdsB = Object.keys(currentPanels);
if (
embeddableIdsA.length !== embeddableIdsB.length ||
xor(embeddableIdsA, embeddableIdsB).length > 0
) {
return false;
}
// embeddable ids are equal so let's compare individual panels.
for (const id of embeddableIdsA) {
const currentEmbeddable = await this.untilEmbeddableLoaded(id);
const lastPanelInput = lastPanels[id].explicitInput;
if (isErrorEmbeddable(currentEmbeddable)) continue;
if (!(await currentEmbeddable.getExplicitInputIsEqual(lastPanelInput))) {
return false;
}
}
return true;
}
protected createNewPanelState<
TEmbeddableInput extends EmbeddableInput,
TEmbeddable extends IEmbeddable<TEmbeddableInput, any>

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ViewMode } from '..';
import { KibanaExecutionContext } from '../../../../../core/types';
import { EmbeddableInput, omitGenericEmbeddableInput, genericEmbeddableInputIsEqual } from '.';
const getGenericEmbeddableState = (state?: Partial<EmbeddableInput>): EmbeddableInput => {
const defaultState: EmbeddableInput = {
lastReloadRequestTime: 1,
executionContext: {} as KibanaExecutionContext,
searchSessionId: 'what a session',
hidePanelTitles: false,
disabledActions: [],
disableTriggers: false,
enhancements: undefined,
syncColors: false,
viewMode: ViewMode.VIEW,
title: 'So Very Generic',
id: 'soVeryGeneric',
};
return { ...defaultState, ...state };
};
test('Omitting generic embeddable input omits all generic input keys', () => {
const superEmbeddableSpecificInput = {
SuperInputKeyA: 'I am so specific',
SuperInputKeyB: 'I am extremely specific',
};
const fullInput = { ...getGenericEmbeddableState(), ...superEmbeddableSpecificInput };
const omittedState = omitGenericEmbeddableInput(fullInput);
const genericInputKeysToRemove: Array<keyof EmbeddableInput> = [
'lastReloadRequestTime',
'executionContext',
'searchSessionId',
'hidePanelTitles',
'disabledActions',
'disableTriggers',
'enhancements',
'syncColors',
'viewMode',
'title',
'id',
];
for (const key of genericInputKeysToRemove) {
expect((omittedState as unknown as EmbeddableInput)[key]).toBeUndefined();
}
expect(omittedState.SuperInputKeyA).toBeDefined();
expect(omittedState.SuperInputKeyB).toBeDefined();
});
describe('Generic embeddable input diff function', () => {
it('considers blank string title to be distinct from undefined title', () => {
const genericInputWithUndefinedTitle = getGenericEmbeddableState();
genericInputWithUndefinedTitle.title = undefined;
expect(
genericEmbeddableInputIsEqual(
getGenericEmbeddableState({ title: '' }),
genericInputWithUndefinedTitle
)
).toBe(false);
});
it('considers missing title key to be equal to input with undefined title', () => {
const genericInputWithUndefinedTitle = getGenericEmbeddableState();
genericInputWithUndefinedTitle.title = undefined;
const genericInputWithDeletedTitle = getGenericEmbeddableState();
delete genericInputWithDeletedTitle.title;
expect(
genericEmbeddableInputIsEqual(genericInputWithDeletedTitle, genericInputWithUndefinedTitle)
).toBe(true);
});
it('considers hide panel titles false to be equal to hide panel titles undefined', () => {
const genericInputWithUndefinedShowPanelTitles = getGenericEmbeddableState();
genericInputWithUndefinedShowPanelTitles.hidePanelTitles = undefined;
expect(
genericEmbeddableInputIsEqual(
getGenericEmbeddableState(),
genericInputWithUndefinedShowPanelTitles
)
).toBe(true);
});
it('ignores differences in viewMode', () => {
expect(
genericEmbeddableInputIsEqual(
getGenericEmbeddableState(),
getGenericEmbeddableState({ viewMode: ViewMode.EDIT })
)
).toBe(true);
});
it('ignores differences in searchSessionId', () => {
expect(
genericEmbeddableInputIsEqual(
getGenericEmbeddableState(),
getGenericEmbeddableState({ searchSessionId: 'What a lovely session!' })
)
).toBe(true);
});
});

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import fastIsEqual from 'fast-deep-equal';
import { pick, omit } from 'lodash';
import { EmbeddableInput } from '.';
// list out the keys from the EmbeddableInput type to allow lodash to pick them later
const allGenericInputKeys: Readonly<Array<keyof EmbeddableInput>> = [
'lastReloadRequestTime',
'executionContext',
'searchSessionId',
'hidePanelTitles',
'disabledActions',
'disableTriggers',
'enhancements',
'syncColors',
'viewMode',
'title',
'id',
] as const;
const genericInputKeysToCompare = [
'hidePanelTitles',
'disabledActions',
'disableTriggers',
'enhancements',
'syncColors',
'title',
'id',
] as const;
// type used to ensure that only keys present in EmbeddableInput are extracted
type GenericEmbedableInputToCompare = Pick<
EmbeddableInput,
typeof genericInputKeysToCompare[number]
>;
export const omitGenericEmbeddableInput = <
I extends Partial<EmbeddableInput> = Partial<EmbeddableInput>
>(
input: I
): Omit<I, keyof EmbeddableInput> => omit(input, allGenericInputKeys);
export const genericEmbeddableInputIsEqual = (
currentInput: Partial<EmbeddableInput>,
lastInput: Partial<EmbeddableInput>
) => {
const {
title: currentTitle,
hidePanelTitles: currentHidePanelTitles,
...current
} = pick(currentInput as GenericEmbedableInputToCompare, genericInputKeysToCompare);
const {
title: lastTitle,
hidePanelTitles: lastHidePanelTitles,
...last
} = pick(lastInput as GenericEmbedableInputToCompare, genericInputKeysToCompare);
if (currentTitle !== lastTitle) return false;
if (Boolean(currentHidePanelTitles) !== Boolean(lastHidePanelTitles)) return false;
if (!fastIsEqual(current, last)) return false;
return true;
};

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import { cloneDeep, isEqual } from 'lodash';
import fastIsEqual from 'fast-deep-equal';
import { cloneDeep } from 'lodash';
import * as Rx from 'rxjs';
import { merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators';
@ -15,11 +16,11 @@ import { Adapters } from '../types';
import { IContainer } from '../containers';
import { EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { EmbeddableInput, ViewMode } from '../../../common/types';
import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input';
function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) {
return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title;
}
export abstract class Embeddable<
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput
@ -131,6 +132,33 @@ export abstract class Embeddable<
return this.output;
}
public async getExplicitInputIsEqual(
lastExplicitInput: Partial<TEmbeddableInput>
): Promise<boolean> {
const currentExplicitInput = this.getExplicitInput();
return (
genericEmbeddableInputIsEqual(lastExplicitInput, currentExplicitInput) &&
fastIsEqual(
omitGenericEmbeddableInput(lastExplicitInput),
omitGenericEmbeddableInput(currentExplicitInput)
)
);
}
public getExplicitInput() {
const root = this.getRoot();
if (root.getIsContainer()) {
return (
(root.getInput().panels?.[this.id]?.explicitInput as TEmbeddableInput) ?? this.getInput()
);
}
return this.getInput();
}
public getPersistableInput() {
return this.getExplicitInput();
}
public getInput(): Readonly<TEmbeddableInput> {
return this.input;
}
@ -213,7 +241,7 @@ export abstract class Embeddable<
...this.output,
...outputChanges,
};
if (!isEqual(this.output, newOutput)) {
if (!fastIsEqual(this.output, newOutput)) {
this.output = newOutput;
this.output$.next(this.output);
}
@ -230,7 +258,7 @@ export abstract class Embeddable<
}
private onResetInput(newInput: TEmbeddableInput) {
if (!isEqual(this.input, newInput)) {
if (!fastIsEqual(this.input, newInput)) {
const oldLastReloadRequestTime = this.input.lastReloadRequestTime;
this.input = newInput;
this.input$.next(newInput);

View file

@ -103,6 +103,20 @@ export interface IEmbeddable<
**/
getInput(): Readonly<I>;
/**
* Because embeddables can inherit input from their parents, they also need a way to separate their own
* input from input which is inherited. If the embeddable does not have a parent, getExplicitInput
* and getInput should return the same.
**/
getExplicitInput(): Readonly<Partial<I>>;
/**
* Some embeddables contain input that should not be persisted anywhere beyond their own state. This method
* is a way for containers to separate input to store from input which can be ephemeral. In most cases, this
* will be the same as getExplicitInput
**/
getPersistableInput(): Readonly<Partial<I>>;
/**
* Output state is:
*
@ -170,4 +184,9 @@ export interface IEmbeddable<
* List of triggers that this embeddable will execute.
*/
supportedTriggers(): string[];
/**
* Used to diff explicit embeddable input
*/
getExplicitInputIsEqual(lastInput: Partial<I>): Promise<boolean>;
}

View file

@ -18,3 +18,4 @@ export { EmbeddableRoot } from './embeddable_root';
export * from '../../../common/lib/saved_object_embeddable';
export type { EmbeddableRendererProps } from './embeddable_renderer';
export { EmbeddableRenderer, useEmbeddableFactory } from './embeddable_renderer';
export { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input';

View file

@ -610,16 +610,14 @@ export class Embeddable
};
public getInputAsRefType = async (): Promise<LensByReferenceInput> => {
const input = this.deps.attributeService.getExplicitInputFromEmbeddable(this);
return this.deps.attributeService.getInputAsRefType(input, {
return this.deps.attributeService.getInputAsRefType(this.getExplicitInput(), {
showSaveModal: true,
saveModalTitle: this.getTitle(),
});
};
public getInputAsValueType = async (): Promise<LensByValueInput> => {
const input = this.deps.attributeService.getExplicitInputFromEmbeddable(this);
return this.deps.attributeService.getInputAsValueType(input);
return this.deps.attributeService.getInputAsValueType(this.getExplicitInput());
};
// same API as Visualize

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
import _ from 'lodash';
import React from 'react';
import { Provider } from 'react-redux';
import fastIsEqual from 'fast-deep-equal';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subscription } from 'rxjs';
import { Unsubscribe } from 'redux';
@ -18,7 +19,9 @@ import {
Embeddable,
IContainer,
ReferenceOrValueEmbeddable,
genericEmbeddableInputIsEqual,
VALUE_CLICK_TRIGGER,
omitGenericEmbeddableInput,
} from '../../../../../src/plugins/embeddable/public';
import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public';
import {
@ -210,16 +213,26 @@ export class MapEmbeddable
}
public async getInputAsRefType(): Promise<MapByReferenceInput> {
const input = getMapAttributeService().getExplicitInputFromEmbeddable(this);
return getMapAttributeService().getInputAsRefType(input, {
return getMapAttributeService().getInputAsRefType(this.getExplicitInput(), {
showSaveModal: true,
saveModalTitle: this.getTitle(),
});
}
public async getExplicitInputIsEqual(
lastExplicitInput: Partial<MapByValueInput | MapByReferenceInput>
): Promise<boolean> {
const currentExplicitInput = this.getExplicitInput();
if (!genericEmbeddableInputIsEqual(lastExplicitInput, currentExplicitInput)) return false;
// generic embeddable input is equal, now we compare map specific input elements, ignoring 'mapBuffer'.
const lastMapInput = omitGenericEmbeddableInput(_.omit(lastExplicitInput, 'mapBuffer'));
const currentMapInput = omitGenericEmbeddableInput(_.omit(currentExplicitInput, 'mapBuffer'));
return fastIsEqual(lastMapInput, currentMapInput);
}
public async getInputAsValueType(): Promise<MapByValueInput> {
const input = getMapAttributeService().getExplicitInputFromEmbeddable(this);
return getMapAttributeService().getInputAsValueType(input);
return getMapAttributeService().getInputAsValueType(this.getExplicitInput());
}
public getDescription() {

View file

@ -98,6 +98,9 @@ export class MapsPlugin implements Plugin {
embeddableType: MAP_SAVED_OBJECT_TYPE,
embeddableConfig: {
isLayerTOCOpen: false,
hiddenLayers: [],
mapCenter: { lat: 45.88578, lon: -15.07605, zoom: 2.11 },
openTOCDetails: [],
},
});
@ -124,6 +127,9 @@ export class MapsPlugin implements Plugin {
embeddableType: MAP_SAVED_OBJECT_TYPE,
embeddableConfig: {
isLayerTOCOpen: true,
hiddenLayers: [],
mapCenter: { lat: 48.72307, lon: -115.18171, zoom: 4.28 },
openTOCDetails: [],
},
});
@ -148,6 +154,9 @@ export class MapsPlugin implements Plugin {
embeddableType: MAP_SAVED_OBJECT_TYPE,
embeddableConfig: {
isLayerTOCOpen: false,
hiddenLayers: [],
mapCenter: { lat: 42.16337, lon: -88.92107, zoom: 3.64 },
openTOCDetails: [],
},
});