From 504eea643c58e5fd7a942a0fcd33347119f65ca7 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Tue, 14 Dec 2021 15:47:43 -0500 Subject: [PATCH 1/8] initial implementation of explicitInputIsEqual method --- .../public/book/book_embeddable.tsx | 6 +- .../public/book/edit_book_action.tsx | 2 +- .../hooks/use_dashboard_app_state.ts | 8 +- .../application/lib/diff_dashboard_state.ts | 240 ++++++++++-------- .../dashboard/public/application/lib/index.ts | 2 +- .../attribute_service/attribute_service.tsx | 7 - .../public/lib/containers/container.ts | 28 ++ .../lib/embeddables/diff_embeddable_input.ts | 48 ++++ .../public/lib/embeddables/embeddable.tsx | 30 ++- .../public/lib/embeddables/i_embeddable.ts | 19 ++ .../lens/public/embeddable/embeddable.tsx | 7 +- .../maps/public/embeddable/map_embeddable.tsx | 6 +- 12 files changed, 275 insertions(+), 128 deletions(-) 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 0f25d564e5580..024d9d90448eb 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 edf04901e4e0d..ab5694d7782fa 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 { + .subscribe(async (states) => { const [lastSaved, current] = states; - const unsavedChanges = diffDashboardState(lastSaved, current); + const unsavedChanges = await diffDashboardState({ + getEmbeddable: dashboardContainer.untilEmbeddableLoaded, + originalState: lastSaved, + newState: current, + }); const savedTimeChanged = lastSaved.timeRestore && 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 17b4f9f515f7a..025af1955f1fb 100644 --- a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts @@ -6,133 +6,171 @@ * 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'; + +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; -type DashboardDiffCommonFilters = DashboardDiffCommon & { filters: Filter[] }; +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); + + const commonInputDiff: Partial = commonDiff(commonOriginal, commonNew); + const filtersAreEqual = getFiltersAreEqual(originalInput.filters, newInput.filters); + + return { + ...commonInputDiff, + ...(filtersAreEqual ? {} : { filters: newInput.filters }), + }; }; -export const diffDashboardState = ( - original: DashboardState, - newState: DashboardState -): Partial => { - const common = commonDiffFilters( - original as unknown as DashboardDiffCommonFilters, - newState as unknown as DashboardDiffCommonFilters, - [ - 'viewMode', - 'panels', - 'options', - 'fullScreenMode', - 'savedQuery', - 'expandedPanelId', - 'controlGroupInput', - ], - true +/** + * 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 { + 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 = 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 { - ...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 }), + ...commonStateDiff, + ...(panelsAreEqual ? {} : { panels: newState.panels }), + ...(filtersAreEqual ? {} : { filters: newState.filters }), + ...(optionsAreEqual ? {} : { options: newState.options }), + ...(controlGroupIsEqual ? {} : { controlGroupInput: newState.controlGroupInput }), }; }; -const optionsAreEqual = (optionsA: DashboardOptions, optionsB: DashboardOptions): boolean => { - const optionKeys = [...Object.keys(optionsA), ...Object.keys(optionsB)]; +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 +): { hasChanged: boolean; changeString?: string } => { + 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 { hasChanged: false }; } - return true; + return { hasChanged: 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; - } - } + for (const embeddableId of newEmbeddableIds) { + const { + explicitInput: originalExplicitInput, + panelRefName: panelRefA, + ...commonPanelDiffOriginal + } = originalPanels[embeddableId]; + const { + explicitInput: newExplicitInput, + panelRefName: panelRefB, + ...commonPanelDiffNew + } = newPanels[embeddableId]; - return true; -}; + if (!isEmpty(commonDiff(commonPanelDiffOriginal, commonPanelDiffNew))) return false; -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 } : {}), - }); + // the position and type of this embeddable is equal. Now we compare the embeddable input + const embeddable = await getEmbeddable(embeddableId); + if (!embeddable.getExplicitInputIsEqual(originalExplicitInput)) return false; + } + return true; }; -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 845cfcb096c51..58f962591b67c 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/lib/attribute_service/attribute_service.tsx b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx index e7a9127f17040..4a7a6ddfb8a68 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 as ValType; diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 06133fb2160c0..471016711a9ee 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'; @@ -195,6 +197,32 @@ export abstract class Container< }); } + public async isInputEqual(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 (currentEmbeddable.isInputEqual(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.ts b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts new file mode 100644 index 0000000000000..f0675494ff618 --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts @@ -0,0 +1,48 @@ +/* + * 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 } from 'lodash'; +import { EmbeddableInput } from '.'; + +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 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 de1a723590683..4a255c9d3ccd0 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 } 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,27 @@ export abstract class Embeddable< return this.output; } + public async getExplicitInputIsEqual(lastInput: Partial): Promise { + return ( + genericEmbeddableInputIsEqual(lastInput, this.getExplicitInput()) && + fastIsEqual(lastInput, this.getExplicitInput()) + ); + } + + public getExplicitInput() { + if (this.getRoot().isContainer) { + return ( + ((this.getRoot() as IContainer).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 +235,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 +252,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 b53f036024259..0ee288cb4b8c6 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/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index aef3cda8679ea..ea115e40d8106 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -70,6 +70,7 @@ export type LensSavedObjectAttributes = Omit export interface LensUnwrapMetaInfo { sharingSavedObjectProps?: SharingSavedObjectProps; + title?: string; } export interface LensUnwrapResult { @@ -610,16 +611,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 d15a3efc5375d..e941f69cfe2dc 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -210,16 +210,14 @@ 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 getInputAsValueType(): Promise { - const input = getMapAttributeService().getExplicitInputFromEmbeddable(this); - return getMapAttributeService().getInputAsValueType(input); + return getMapAttributeService().getInputAsValueType(this.getExplicitInput()); } public getDescription() { From d2b14576bbab1f285babad7e8eefa003dd832d8c Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Tue, 14 Dec 2021 15:37:07 -0500 Subject: [PATCH 2/8] implemented getExplicitInputIsEqual for map embeddable, fixed issues and wrote jest tests --- .../hooks/use_dashboard_app_state.ts | 2 +- .../lib/diff_dashboard_state.test.ts | 166 ++++++++++++++++++ .../application/lib/diff_dashboard_state.ts | 11 +- src/plugins/embeddable/public/index.ts | 2 + .../public/lib/containers/container.ts | 4 +- .../embeddables/diff_embeddable_input.test.ts | 109 ++++++++++++ .../lib/embeddables/diff_embeddable_input.ts | 23 ++- .../public/lib/embeddables/embeddable.tsx | 7 +- .../public/lib/embeddables/index.ts | 1 + .../maps/public/embeddable/map_embeddable.tsx | 15 ++ 10 files changed, 326 insertions(+), 14 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 diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index 6d6cfb7f97f57..59184a901844e 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -265,7 +265,7 @@ export const useDashboardAppState = ({ .subscribe(async (states) => { const [lastSaved, current] = states; const unsavedChanges = await diffDashboardState({ - getEmbeddable: dashboardContainer.untilEmbeddableLoaded, + getEmbeddable: (id: string) => dashboardContainer.untilEmbeddableLoaded(id), originalState: lastSaved, newState: current, }); 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 0000000000000..9668999d20911 --- /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 025af1955f1fb..264c8fcb1de2e 100644 --- a/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/diff_dashboard_state.ts @@ -113,18 +113,15 @@ const getFiltersAreEqual = ( ); }; -const getOptionsAreEqual = ( - optionsA: DashboardOptions, - optionsB: DashboardOptions -): { hasChanged: boolean; changeString?: string } => { +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[key]) !== Boolean(optionsB[key])) return { hasChanged: false }; + if (Boolean(optionsA[key]) !== Boolean(optionsB[key])) return false; } - return { hasChanged: true }; + return true; }; const getPanelsAreEqual = async ( @@ -157,7 +154,7 @@ const getPanelsAreEqual = async ( // the position and type of this embeddable is equal. Now we compare the embeddable input const embeddable = await getEmbeddable(embeddableId); - if (!embeddable.getExplicitInputIsEqual(originalExplicitInput)) return false; + if (!(await embeddable.getExplicitInputIsEqual(originalExplicitInput))) return false; } return true; }; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index c6beccd5e3365..f3759ffdb39e5 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -65,6 +65,8 @@ export { VALUE_CLICK_TRIGGER, ViewMode, withEmbeddableSubscription, + genericEmbeddableInputIsEqual, + omitGenericEmbeddableInput, isSavedObjectEmbeddableInput, isRangeSelectTriggerContext, isValueClickTriggerContext, diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 471016711a9ee..0351bec1592e1 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -197,7 +197,7 @@ export abstract class Container< }); } - public async isInputEqual(lastInput: TContainerInput) { + public async getExplicitInputIsEqual(lastInput: TContainerInput) { const { panels: lastPanels, ...restOfLastInput } = lastInput; const { panels: currentPanels, ...restOfCurrentInput } = this.getInput(); const otherInputIsEqual = isEqual(restOfLastInput, restOfCurrentInput); @@ -216,7 +216,7 @@ export abstract class Container< const currentEmbeddable = await this.untilEmbeddableLoaded(id); const lastPanelInput = lastPanels[id].explicitInput; if (isErrorEmbeddable(currentEmbeddable)) continue; - if (currentEmbeddable.isInputEqual(lastPanelInput)) { + if (currentEmbeddable.getExplicitInputIsEqual(lastPanelInput)) { return false; } } 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 0000000000000..01d776610f947 --- /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 index f0675494ff618..a396ed324a949 100644 --- a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts @@ -7,9 +7,24 @@ */ import fastIsEqual from 'fast-deep-equal'; -import { pick } from 'lodash'; +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', @@ -26,6 +41,12 @@ type GenericEmbedableInputToCompare = Pick< typeof genericInputKeysToCompare[number] >; +export const omitGenericEmbeddableInput = < + I extends Partial = Partial +>( + input: I +): Omit => omit(input, allGenericInputKeys); + export const genericEmbeddableInputIsEqual = ( currentInput: Partial, lastInput: Partial diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index 4a255c9d3ccd0..626f2dbc7d516 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -16,7 +16,7 @@ import { Adapters } from '../types'; import { IContainer } from '../containers'; import { EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { EmbeddableInput, ViewMode } from '../../../common/types'; -import { genericEmbeddableInputIsEqual } from './diff_embeddable_input'; +import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; @@ -133,9 +133,10 @@ export abstract class Embeddable< } public async getExplicitInputIsEqual(lastInput: Partial): Promise { + const currentInput = this.getExplicitInput(); return ( - genericEmbeddableInputIsEqual(lastInput, this.getExplicitInput()) && - fastIsEqual(lastInput, this.getExplicitInput()) + genericEmbeddableInputIsEqual(lastInput, currentInput) && + fastIsEqual(omitGenericEmbeddableInput(lastInput), omitGenericEmbeddableInput(currentInput)) ); } diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 1745c64c73bf5..0c1048af9182c 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/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index e941f69cfe2dc..9e90ed772acd3 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 { @@ -216,6 +219,18 @@ export class MapEmbeddable }); } + public async getExplicitInputIsEqual( + lastInput: Partial + ): Promise { + const currentInput = this.getExplicitInput(); + if (!genericEmbeddableInputIsEqual(lastInput, currentInput)) return false; + + // generic embeddable input is equal, now we compare map specific input elements, ignoring 'mapBuffer'. + const lastMapInput = omitGenericEmbeddableInput(_.omit(lastInput, 'mapBuffer')); + const currentMapInput = omitGenericEmbeddableInput(_.omit(lastInput, 'mapBuffer')); + return fastIsEqual(lastMapInput, currentMapInput); + } + public async getInputAsValueType(): Promise { return getMapAttributeService().getInputAsValueType(this.getExplicitInput()); } From 520ad374dd1bf741d75f88d2e9d7769037cdbe0a Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Tue, 14 Dec 2021 17:11:16 -0500 Subject: [PATCH 3/8] add missing await --- src/plugins/embeddable/public/lib/containers/container.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 0351bec1592e1..264096825b4b3 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -216,7 +216,7 @@ export abstract class Container< const currentEmbeddable = await this.untilEmbeddableLoaded(id); const lastPanelInput = lastPanels[id].explicitInput; if (isErrorEmbeddable(currentEmbeddable)) continue; - if (currentEmbeddable.getExplicitInputIsEqual(lastPanelInput)) { + if (await currentEmbeddable.getExplicitInputIsEqual(lastPanelInput)) { return false; } } From ebb8f6271657ba5958c58ed48b32748cbd6f3ee9 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 16 Dec 2021 11:08:27 -0500 Subject: [PATCH 4/8] Update src/plugins/embeddable/public/lib/containers/container.ts Co-authored-by: Anton Dosov --- src/plugins/embeddable/public/lib/containers/container.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 264096825b4b3..a032126396d4f 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -216,7 +216,7 @@ export abstract class Container< const currentEmbeddable = await this.untilEmbeddableLoaded(id); const lastPanelInput = lastPanels[id].explicitInput; if (isErrorEmbeddable(currentEmbeddable)) continue; - if (await currentEmbeddable.getExplicitInputIsEqual(lastPanelInput)) { + if (!(await currentEmbeddable.getExplicitInputIsEqual(lastPanelInput))) { return false; } } From 8c66f429357e66c3f44c3cc3da914119d1c281ad Mon Sep 17 00:00:00 2001 From: nreese Date: Fri, 17 Dec 2021 15:13:17 -0700 Subject: [PATCH 5/8] fix map embeddable equality check --- x-pack/plugins/maps/public/embeddable/map_embeddable.tsx | 2 +- x-pack/plugins/maps/server/plugin.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 9cec0160420af..32d419c37b8fe 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -227,7 +227,7 @@ export class MapEmbeddable // generic embeddable input is equal, now we compare map specific input elements, ignoring 'mapBuffer'. const lastMapInput = omitGenericEmbeddableInput(_.omit(lastInput, 'mapBuffer')); - const currentMapInput = omitGenericEmbeddableInput(_.omit(lastInput, 'mapBuffer')); + const currentMapInput = omitGenericEmbeddableInput(_.omit(currentInput, 'mapBuffer')); return fastIsEqual(lastMapInput, currentMapInput); } diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index ec9b3652fddbd..71d86aa85680a 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -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: [], }, }); From cd996270a0d5caa60399782235416e57123c5876 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Tue, 21 Dec 2021 17:20:05 -0500 Subject: [PATCH 6/8] change variable names to last and current explicit input. Implemented isContainer type guard --- .../public/lib/embeddables/embeddable.tsx | 19 ++++++++++++------- .../maps/public/embeddable/map_embeddable.tsx | 10 +++++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index 626f2dbc7d516..c8c0aea80e1e2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -132,19 +132,24 @@ export abstract class Embeddable< return this.output; } - public async getExplicitInputIsEqual(lastInput: Partial): Promise { - const currentInput = this.getExplicitInput(); + public async getExplicitInputIsEqual( + lastExplicitInput: Partial + ): Promise { + const currentExplicitInput = this.getExplicitInput(); return ( - genericEmbeddableInputIsEqual(lastInput, currentInput) && - fastIsEqual(omitGenericEmbeddableInput(lastInput), omitGenericEmbeddableInput(currentInput)) + genericEmbeddableInputIsEqual(lastExplicitInput, currentExplicitInput) && + fastIsEqual( + omitGenericEmbeddableInput(lastExplicitInput), + omitGenericEmbeddableInput(currentExplicitInput) + ) ); } public getExplicitInput() { - if (this.getRoot().isContainer) { + const root = this.getRoot(); + if (root.getIsContainer()) { return ( - ((this.getRoot() as IContainer).getInput().panels?.[this.id] - ?.explicitInput as TEmbeddableInput) ?? this.getInput() + (root.getInput().panels?.[this.id]?.explicitInput as TEmbeddableInput) ?? this.getInput() ); } return this.getInput(); diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 32d419c37b8fe..40ee17d176706 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -220,14 +220,14 @@ export class MapEmbeddable } public async getExplicitInputIsEqual( - lastInput: Partial + lastExplicitInput: Partial ): Promise { - const currentInput = this.getExplicitInput(); - if (!genericEmbeddableInputIsEqual(lastInput, currentInput)) return false; + 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(lastInput, 'mapBuffer')); - const currentMapInput = omitGenericEmbeddableInput(_.omit(currentInput, 'mapBuffer')); + const lastMapInput = omitGenericEmbeddableInput(_.omit(lastExplicitInput, 'mapBuffer')); + const currentMapInput = omitGenericEmbeddableInput(_.omit(currentExplicitInput, 'mapBuffer')); return fastIsEqual(lastMapInput, currentMapInput); } From 29080433253e964ca766350d0bc8fd62bd0aa525 Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Wed, 22 Dec 2021 13:36:53 -0500 Subject: [PATCH 7/8] Use switchmap for previous task cancellation --- .../hooks/use_dashboard_app_state.ts | 80 ++++++++++--------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index 59184a901844e..8c58eab0ded83 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -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,41 +261,47 @@ export const useDashboardAppState = ({ dashboardAppState.$onDashboardStateChange, dashboardBuildContext.$checkForUnsavedChanges, ]) - .pipe(debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE)) - .subscribe(async (states) => { - const [lastSaved, current] = states; - const unsavedChanges = await diffDashboardState({ - getEmbeddable: (id: string) => dashboardContainer.untilEmbeddableLoaded(id), - originalState: lastSaved, - newState: current, - }); - - 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 })); - - unsavedChanges.viewMode = current.viewMode; // always push view mode into session store. - dashboardSessionStorage.setState(savedDashboardId, unsavedChanges); - }); + .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() + )); + + /** + * 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); + }); + }); + }) + ) + .subscribe(); /** * initialize the last saved state, and build a callback which can be used to update From 7103225d1792024c234fc48ec996afd24cc204cf Mon Sep 17 00:00:00 2001 From: Devon A Thomson Date: Thu, 6 Jan 2022 14:10:42 -0500 Subject: [PATCH 8/8] Remove unused title from LensUnwrapMetaInfo --- x-pack/plugins/lens/public/embeddable/embeddable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 192d86ad274d7..22acfb7aa063f 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -70,7 +70,6 @@ export type LensSavedObjectAttributes = Omit export interface LensUnwrapMetaInfo { sharingSavedObjectProps?: SharingSavedObjectProps; - title?: string; } export interface LensUnwrapResult {