From 1da55faa8fad0a5d6eadb2c6b3315feceaf3a28d Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 2 Feb 2022 14:35:57 -0500 Subject: [PATCH] [7.17] [Dashboard][Embeddable] Create Explicit Diffing System (#121241) (#124293) * [Dashboard][Embeddable] Create Explicit Diffing System (#121241) Co-authored-by: Anton Dosov Co-authored-by: nreese (cherry picked from commit 944ccf10e8891514f17086ebba14267efb4c722b) # Conflicts: # src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts # x-pack/plugins/maps/server/plugin.ts * type fix * replace ecommerce sample map id --- .../public/book/book_embeddable.tsx | 6 +- .../public/book/edit_book_action.tsx | 2 +- .../hooks/use_dashboard_app_state.ts | 72 +++--- .../lib/diff_dashboard_state.test.ts | 166 +++++++++++++ .../application/lib/diff_dashboard_state.ts | 223 ++++++++++-------- .../dashboard/public/application/lib/index.ts | 2 +- src/plugins/embeddable/public/index.ts | 2 + .../attribute_service/attribute_service.tsx | 7 - .../public/lib/containers/container.ts | 28 +++ .../embeddables/diff_embeddable_input.test.ts | 109 +++++++++ .../lib/embeddables/diff_embeddable_input.ts | 69 ++++++ .../public/lib/embeddables/embeddable.tsx | 36 ++- .../public/lib/embeddables/i_embeddable.ts | 19 ++ .../public/lib/embeddables/index.ts | 1 + .../lens/public/embeddable/embeddable.tsx | 6 +- .../maps/public/embeddable/map_embeddable.tsx | 21 +- x-pack/plugins/maps/server/plugin.ts | 21 ++ 17 files changed, 642 insertions(+), 148 deletions(-) create mode 100644 src/plugins/dashboard/public/application/lib/diff_dashboard_state.test.ts create mode 100644 src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts create mode 100644 src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index 0f25d564e558..024d9d90448e 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -92,13 +92,11 @@ export class BookEmbeddable }; readonly getInputAsValueType = async (): Promise => { - const input = this.attributeService.getExplicitInputFromEmbeddable(this); - return this.attributeService.getInputAsValueType(input); + return this.attributeService.getInputAsValueType(this.getExplicitInput()); }; readonly getInputAsRefType = async (): Promise => { - const input = this.attributeService.getExplicitInputFromEmbeddable(this); - return this.attributeService.getInputAsRefType(input, { + return this.attributeService.getInputAsRefType(this.getExplicitInput(), { showSaveModal: true, saveModalTitle: this.getTitle(), }); diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx index edf04901e4e0..ab5694d7782f 100644 --- a/examples/embeddable_examples/public/book/edit_book_action.tsx +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -71,7 +71,7 @@ export const createEditBookAction = (getStartServices: () => Promise { - 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 diff --git a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.test.ts b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.test.ts new file mode 100644 index 000000000000..9668999d2091 --- /dev/null +++ b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.test.ts @@ -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 => { + 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): Promise => + 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']); + }); +}); diff --git a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts index 920dc0b9d5d8..576cfff556cb 100644 --- a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts @@ -6,121 +6,160 @@ * Side Public License, v 1. */ -import _ from 'lodash'; -import { DashboardPanelState } from '..'; -import { esFilters, Filter } from '../../services/data'; -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'; -interface DashboardDiffCommon { - [key: string]: unknown; -} +import { DashboardContainerInput } from '../..'; +import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types'; +import { IEmbeddable } from '../../services/embeddable'; -type DashboardDiffCommonFilters = DashboardDiffCommon & { filters: Filter[] }; +const stateKeystoIgnore = [ + 'expandedPanelId', + 'fullScreenMode', + 'savedQuery', + 'viewMode', + 'tags', +] as const; +type DashboardStateToCompare = Omit; +const inputKeystoIgnore = ['searchSessionId', 'lastReloadRequestTime', 'executionContext'] as const; +type DashboardInputToCompare = Omit; + +/** + * 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( - originalInput as unknown as DashboardDiffCommonFilters, - newInput as unknown as DashboardDiffCommonFilters, - ['searchSessionId', 'lastReloadRequestTime', 'executionContext'] - ); -}; +): Partial => { + const { filters: originalFilters, ...commonOriginal } = omit(originalInput, inputKeystoIgnore); + const { filters: newFilters, ...commonNew } = omit(newInput, inputKeystoIgnore); -export const diffDashboardState = ( - original: DashboardState, - newState: DashboardState -): Partial => { - const common = commonDiffFilters( - original as unknown as DashboardDiffCommonFilters, - newState as unknown as DashboardDiffCommonFilters, - ['viewMode', 'panels', 'options', 'savedQuery', 'expandedPanelId'], - true - ); + const commonInputDiff: Partial = 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 }), + ...commonInputDiff, + ...(filtersAreEqual ? {} : { filters: newInput.filters }), }; }; -const optionsAreEqual = (optionsA: DashboardOptions, optionsB: DashboardOptions): boolean => { - const optionKeys = [...Object.keys(optionsA), ...Object.keys(optionsB)]; +/** + * 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; +}): Promise> => { + const { + options: originalOptions, + filters: originalFilters, + panels: originalPanels, + ...commonCompareOriginal + } = omit(originalState, stateKeystoIgnore); + const { + options: newOptions, + filters: newFilters, + panels: newPanels, + ...commonCompareNew + } = omit(newState, stateKeystoIgnore); + + const commonStateDiff: Partial = 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); + + return { + ...commonStateDiff, + ...(panelsAreEqual ? {} : { panels: newState.panels }), + ...(filtersAreEqual ? {} : { filters: newState.filters }), + ...(optionsAreEqual ? {} : { options: newState.options }), + }; +}; + +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), + ...(Object.keys(optionsB) as Array), + ]; for (const key of optionKeys) { - if ( - Boolean((optionsA as unknown as { [key: string]: boolean })[key]) !== - Boolean((optionsB as unknown as { [key: string]: boolean })[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 +): Promise => { + 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( - panelsA[id] as unknown as DashboardDiffCommon, - panelsB[id] as unknown as DashboardDiffCommon, - ['panelRefName'] - ); - if (Object.keys(panelCommonDiff).length > 0) { - return false; - } - } + // embeddable ids are equal so let's compare individual panels. + 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; }; -const commonDiffFilters = ( - originalObj: DashboardDiffCommonFilters, - newObj: DashboardDiffCommonFilters, - omitKeys: string[], - ignorePinned?: boolean -): Partial => { - const filtersAreDifferent = () => - !esFilters.compareFilters( - originalObj.filters, - ignorePinned ? newObj.filters.filter((f) => !esFilters.isFilterPinned(f)) : newObj.filters, - esFilters.COMPARE_ALL_OPTIONS - ); - const otherDifferences = commonDiff(originalObj, newObj, [...omitKeys, 'filters']); - return _.cloneDeep({ - ...otherDifferences, - ...(filtersAreDifferent() ? { filters: newObj.filters } : {}), - }); -}; - -const commonDiff = ( - originalObj: DashboardDiffCommon, - newObj: DashboardDiffCommon, - omitKeys: string[] -) => { +const commonDiff = (originalObj: Partial, newObj: Partial) => { const differences: Partial = {}; - 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), + ...(Object.keys(newObj) as Array), + ]; + for (const key of keys) { + if (key === undefined) continue; + if (!fastIsEqual(originalObj[key], newObj[key])) differences[key] = newObj[key]; + } return differences; }; diff --git a/src/plugins/dashboard/public/application/lib/index.ts b/src/plugins/dashboard/public/application/lib/index.ts index 845cfcb096c5..58f962591b67 100644 --- a/src/plugins/dashboard/public/application/lib/index.ts +++ b/src/plugins/dashboard/public/application/lib/index.ts @@ -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 { diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 6a6b5b2df2dd..41babe40e11e 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -64,6 +64,8 @@ export { VALUE_CLICK_TRIGGER, ViewMode, withEmbeddableSubscription, + genericEmbeddableInputIsEqual, + omitGenericEmbeddableInput, isSavedObjectEmbeddableInput, isRangeSelectTriggerContext, isValueClickTriggerContext, diff --git a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx index 507d2be7198d..72dd0727b88b 100644 --- a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx +++ b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx @@ -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 => { if (!this.inputIsRefType(input)) { return input; diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index a1d4b5b68d20..fe0cee246bbe 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -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'; @@ -194,6 +196,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 diff --git a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts new file mode 100644 index 000000000000..01d776610f94 --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts @@ -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 => { + 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 = [ + '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); + }); +}); diff --git a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts new file mode 100644 index 000000000000..a396ed324a94 --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts @@ -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> = [ + '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 = Partial +>( + input: I +): Omit => omit(input, allGenericInputKeys); + +export const genericEmbeddableInputIsEqual = ( + currentInput: Partial, + lastInput: Partial +) => { + 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; +}; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index de1a72359068..c8c0aea80e1e 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -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 + ): Promise { + 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 { 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); diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index b53f03602425..0ee288cb4b8c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -103,6 +103,20 @@ export interface IEmbeddable< **/ getInput(): Readonly; + /** + * 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>; + + /** + * 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>; + /** * 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): Promise; } diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 1745c64c73bf..0c1048af9182 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -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'; diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index ba7d82ce0df2..789b45a96e6f 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -546,16 +546,14 @@ export class Embeddable }; public getInputAsRefType = async (): Promise => { - 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 => { - const input = this.deps.attributeService.getExplicitInputFromEmbeddable(this); - return this.deps.attributeService.getInputAsValueType(input); + return this.deps.attributeService.getInputAsValueType(this.getExplicitInput()); }; // same API as Visualize diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index ef7ca4655a60..723e43426dd2 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -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'; @@ -17,7 +18,9 @@ import { Embeddable, IContainer, ReferenceOrValueEmbeddable, + genericEmbeddableInputIsEqual, VALUE_CLICK_TRIGGER, + omitGenericEmbeddableInput, } from '../../../../../src/plugins/embeddable/public'; import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; import { @@ -206,16 +209,26 @@ export class MapEmbeddable } public async getInputAsRefType(): Promise { - const input = getMapAttributeService().getExplicitInputFromEmbeddable(this); - return getMapAttributeService().getInputAsRefType(input, { + return getMapAttributeService().getInputAsRefType(this.getExplicitInput(), { showSaveModal: true, saveModalTitle: this.getTitle(), }); } + public async getExplicitInputIsEqual( + lastExplicitInput: Partial + ): Promise { + 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 { - const input = getMapAttributeService().getExplicitInputFromEmbeddable(this); - return getMapAttributeService().getInputAsValueType(input); + return getMapAttributeService().getInputAsValueType(this.getExplicitInput()); } public getDescription() { diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index 121b5059cfb8..b12c3e19bb6e 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -83,6 +83,21 @@ export class MapsPlugin implements Plugin { }, ]); + home.sampleData.replacePanelInSampleDatasetDashboard({ + sampleDataId: 'ecommerce', + dashboardId: '722b74f0-b882-11e8-a6d9-e546fe2bba5f', + oldEmbeddableId: '2c9c1f60-1909-11e9-919b-ffe5949a18d2', + embeddableId: '2c9c1f60-1909-11e9-919b-ffe5949a18d2', + // @ts-ignore + embeddableType: MAP_SAVED_OBJECT_TYPE, + embeddableConfig: { + isLayerTOCOpen: false, + hiddenLayers: [], + mapCenter: { lat: 45.88578, lon: -15.07605, zoom: 2.11 }, + openTOCDetails: [], + }, + }); + home.sampleData.addSavedObjectsToSampleDataset('flights', getFlightsSavedObjects()); home.sampleData.addAppLinksToSampleDataset('flights', [ @@ -102,6 +117,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: [], }, }); @@ -122,6 +140,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: [], }, });