diff --git a/examples/controls_example/public/app/app.tsx b/examples/controls_example/public/app/app.tsx index 59d8271163b00a..e940daec0dab3e 100644 --- a/examples/controls_example/public/app/app.tsx +++ b/examples/controls_example/public/app/app.tsx @@ -19,11 +19,10 @@ import { import React, { useState } from 'react'; import ReactDOM from 'react-dom'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; - import { AppMountParameters, CoreStart } from '@kbn/core/public'; import { ControlsExampleStartDeps } from '../plugin'; import { ControlGroupRendererExamples } from './control_group_renderer_examples'; -import { ReactControlExample } from './react_control_example'; +import { ReactControlExample } from './react_control_example/react_control_example'; const CONTROLS_AS_A_BUILDING_BLOCK = 'controls_as_a_building_block'; const CONTROLS_REFACTOR_TEST = 'controls_refactor_test'; diff --git a/examples/controls_example/public/app/react_control_example.tsx b/examples/controls_example/public/app/react_control_example/react_control_example.tsx similarity index 73% rename from examples/controls_example/public/app/react_control_example.tsx rename to examples/controls_example/public/app/react_control_example/react_control_example.tsx index 8e24eb10cbabd6..c3420cf22b6093 100644 --- a/examples/controls_example/public/app/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example/react_control_example.tsx @@ -7,10 +7,12 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest, Subject } from 'rxjs'; import { + EuiBadge, EuiButton, + EuiButtonEmpty, EuiButtonGroup, EuiCallOut, EuiCodeBlock, @@ -18,6 +20,7 @@ import { EuiFlexItem, EuiSpacer, EuiSuperDatePicker, + EuiToolTip, OnTimeChangeProps, } from '@elastic/eui'; import { @@ -39,12 +42,19 @@ import { } from '@kbn/presentation-publishing'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import { ControlGroupApi } from '../react_controls/control_group/types'; -import { OPTIONS_LIST_CONTROL_TYPE } from '../react_controls/data_controls/options_list_control/constants'; -import { RANGE_SLIDER_CONTROL_TYPE } from '../react_controls/data_controls/range_slider/types'; -import { SEARCH_CONTROL_TYPE } from '../react_controls/data_controls/search_control/types'; -import { TIMESLIDER_CONTROL_TYPE } from '../react_controls/timeslider_control/types'; -import { openDataControlEditor } from '../react_controls/data_controls/open_data_control_editor'; +import { + clearControlGroupSerializedState, + getControlGroupSerializedState, + setControlGroupSerializedState, + WEB_LOGS_DATA_VIEW_ID, +} from './serialized_control_group_state'; +import { + clearControlGroupRuntimeState, + getControlGroupRuntimeState, + setControlGroupRuntimeState, +} from './runtime_control_group_state'; +import { ControlGroupApi } from '../../react_controls/control_group/types'; +import { openDataControlEditor } from '../../react_controls/data_controls/open_data_control_editor'; const toggleViewButtons = [ { @@ -59,67 +69,6 @@ const toggleViewButtons = [ }, ]; -const optionsListId = 'optionsList1'; -const searchControlId = 'searchControl1'; -const rangeSliderControlId = 'rangeSliderControl1'; -const timesliderControlId = 'timesliderControl1'; -const controlGroupPanels = { - [searchControlId]: { - type: SEARCH_CONTROL_TYPE, - order: 3, - grow: true, - width: 'medium', - explicitInput: { - id: searchControlId, - fieldName: 'message', - title: 'Message', - grow: true, - width: 'medium', - enhancements: {}, - }, - }, - [rangeSliderControlId]: { - type: RANGE_SLIDER_CONTROL_TYPE, - order: 1, - grow: true, - width: 'medium', - explicitInput: { - id: rangeSliderControlId, - fieldName: 'bytes', - title: 'Bytes', - grow: true, - width: 'medium', - enhancements: {}, - }, - }, - [timesliderControlId]: { - type: TIMESLIDER_CONTROL_TYPE, - order: 4, - grow: true, - width: 'medium', - explicitInput: { - id: timesliderControlId, - enhancements: {}, - }, - }, - [optionsListId]: { - type: OPTIONS_LIST_CONTROL_TYPE, - order: 2, - grow: true, - width: 'medium', - explicitInput: { - id: searchControlId, - fieldName: 'agent.keyword', - title: 'Agent', - grow: true, - width: 'medium', - enhancements: {}, - }, - }, -}; - -const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247'; - export const ReactControlExample = ({ core, dataViews: dataViewsService, @@ -151,6 +100,9 @@ export const ReactControlExample = ({ const viewMode$ = useMemo(() => { return new BehaviorSubject(ViewMode.EDIT as ViewModeType); }, []); + const saveNotification$ = useMemo(() => { + return new Subject(); + }, []); const [dataLoading, timeRange, viewMode] = useBatchedPublishingSubjects( dataLoading$, timeRange$, @@ -188,6 +140,7 @@ export const ReactControlExample = ({ return Promise.resolve(undefined); }, lastUsedDataViewId: new BehaviorSubject(WEB_LOGS_DATA_VIEW_ID), + saveNotification$, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -277,16 +230,57 @@ export const ReactControlExample = ({ }; }, [controlGroupFilters$, filters$, unifiedSearchFilters$]); + const [unsavedChanges, setUnsavedChanges] = useState(undefined); + useEffect(() => { + if (!controlGroupApi) { + return; + } + const subscription = controlGroupApi.unsavedChanges.subscribe((nextUnsavedChanges) => { + if (!nextUnsavedChanges) { + clearControlGroupRuntimeState(); + setUnsavedChanges(undefined); + return; + } + + setControlGroupRuntimeState(nextUnsavedChanges); + + // JSON.stringify removes keys where value is `undefined` + // switch `undefined` to `null` to see when value has been cleared + const replacer = (key: unknown, value: unknown) => + typeof value === 'undefined' ? null : value; + setUnsavedChanges(JSON.stringify(nextUnsavedChanges, replacer, ' ')); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [controlGroupApi]); + return ( <> {dataViewNotFound && ( - <> - -

{`Install "Sample web logs" to run example`}

-
- - + +

{`Install "Sample web logs" to run example`}

+
+ )} + {!dataViewNotFound && ( + + { + clearControlGroupSerializedState(); + clearControlGroupRuntimeState(); + window.location.reload(); + }} + > + Reset example + + )} + + + + {unsavedChanges !== undefined && viewMode === 'edit' && ( + <> + + {unsavedChanges}}> + Unsaved changes + + + + { + controlGroupApi?.resetUnsavedChanges(); + }} + > + Reset + + + + { + if (controlGroupApi) { + saveNotification$.next(); + setControlGroupSerializedState(await controlGroupApi.serializeState()); + } + }} + > + Save + + + + )} ({ ...dashboardApi, - getSerializedStateForChild: () => ({ - rawState: { - controlStyle: 'oneLine', - chainingSystem: 'HIERARCHICAL', - showApplySelections: false, - panelsJSON: JSON.stringify(controlGroupPanels), - ignoreParentSettingsJSON: - '{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}', - } as object, - references: [ - { - name: `controlGroup_${searchControlId}:${SEARCH_CONTROL_TYPE}DataView`, - type: 'index-pattern', - id: WEB_LOGS_DATA_VIEW_ID, - }, - { - name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL_TYPE}DataView`, - type: 'index-pattern', - id: WEB_LOGS_DATA_VIEW_ID, - }, - { - name: `controlGroup_${optionsListId}:optionsListControlDataView`, - type: 'index-pattern', - id: WEB_LOGS_DATA_VIEW_ID, - }, - ], - }), + getSerializedStateForChild: getControlGroupSerializedState, + getRuntimeStateForChild: getControlGroupRuntimeState, })} key={`control_group`} /> diff --git a/examples/controls_example/public/app/react_control_example/runtime_control_group_state.ts b/examples/controls_example/public/app/react_control_example/runtime_control_group_state.ts new file mode 100644 index 00000000000000..ce4bf2e1c9310f --- /dev/null +++ b/examples/controls_example/public/app/react_control_example/runtime_control_group_state.ts @@ -0,0 +1,25 @@ +/* + * 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 { ControlGroupRuntimeState } from '../../react_controls/control_group/types'; + +const RUNTIME_STATE_SESSION_STORAGE_KEY = + 'kibana.examples.controls.reactControlExample.controlGroupRuntimeState'; + +export function clearControlGroupRuntimeState() { + sessionStorage.removeItem(RUNTIME_STATE_SESSION_STORAGE_KEY); +} + +export function getControlGroupRuntimeState(): Partial { + const runtimeStateJSON = sessionStorage.getItem(RUNTIME_STATE_SESSION_STORAGE_KEY); + return runtimeStateJSON ? JSON.parse(runtimeStateJSON) : {}; +} + +export function setControlGroupRuntimeState(runtimeState: Partial) { + sessionStorage.setItem(RUNTIME_STATE_SESSION_STORAGE_KEY, JSON.stringify(runtimeState)); +} diff --git a/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts b/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts new file mode 100644 index 00000000000000..23071623b7ac0d --- /dev/null +++ b/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts @@ -0,0 +1,121 @@ +/* + * 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 { SerializedPanelState } from '@kbn/presentation-containers'; +import { ControlGroupSerializedState } from '../../react_controls/control_group/types'; +import { OPTIONS_LIST_CONTROL_TYPE } from '../../react_controls/data_controls/options_list_control/constants'; +import { RANGE_SLIDER_CONTROL_TYPE } from '../../react_controls/data_controls/range_slider/types'; +import { SEARCH_CONTROL_TYPE } from '../../react_controls/data_controls/search_control/types'; +import { TIMESLIDER_CONTROL_TYPE } from '../../react_controls/timeslider_control/types'; + +const SERIALIZED_STATE_SESSION_STORAGE_KEY = + 'kibana.examples.controls.reactControlExample.controlGroupSerializedState'; +export const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247'; + +export function clearControlGroupSerializedState() { + sessionStorage.removeItem(SERIALIZED_STATE_SESSION_STORAGE_KEY); +} + +export function getControlGroupSerializedState(): SerializedPanelState { + const serializedStateJSON = sessionStorage.getItem(SERIALIZED_STATE_SESSION_STORAGE_KEY); + return serializedStateJSON ? JSON.parse(serializedStateJSON) : initialSerializedControlGroupState; +} + +export function setControlGroupSerializedState( + serializedState: SerializedPanelState +) { + sessionStorage.setItem(SERIALIZED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)); +} + +const optionsListId = 'optionsList1'; +const searchControlId = 'searchControl1'; +const rangeSliderControlId = 'rangeSliderControl1'; +const timesliderControlId = 'timesliderControl1'; +const controlGroupPanels = { + [searchControlId]: { + type: SEARCH_CONTROL_TYPE, + order: 3, + grow: true, + width: 'medium', + explicitInput: { + id: searchControlId, + fieldName: 'message', + title: 'Message', + grow: true, + width: 'medium', + enhancements: {}, + }, + }, + [rangeSliderControlId]: { + type: RANGE_SLIDER_CONTROL_TYPE, + order: 1, + grow: true, + width: 'medium', + explicitInput: { + id: rangeSliderControlId, + fieldName: 'bytes', + title: 'Bytes', + grow: true, + width: 'medium', + enhancements: {}, + }, + }, + [timesliderControlId]: { + type: TIMESLIDER_CONTROL_TYPE, + order: 4, + grow: true, + width: 'medium', + explicitInput: { + id: timesliderControlId, + title: 'Time slider', + enhancements: {}, + }, + }, + [optionsListId]: { + type: OPTIONS_LIST_CONTROL_TYPE, + order: 2, + grow: true, + width: 'medium', + explicitInput: { + id: searchControlId, + fieldName: 'agent.keyword', + title: 'Agent', + grow: true, + width: 'medium', + enhancements: {}, + }, + }, +}; + +const initialSerializedControlGroupState = { + rawState: { + controlStyle: 'oneLine', + chainingSystem: 'HIERARCHICAL', + showApplySelections: false, + panelsJSON: JSON.stringify(controlGroupPanels), + ignoreParentSettingsJSON: + '{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}', + } as object, + references: [ + { + name: `controlGroup_${searchControlId}:${SEARCH_CONTROL_TYPE}DataView`, + type: 'index-pattern', + id: WEB_LOGS_DATA_VIEW_ID, + }, + { + name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL_TYPE}DataView`, + type: 'index-pattern', + id: WEB_LOGS_DATA_VIEW_ID, + }, + { + name: `controlGroup_${optionsListId}:optionsListControlDataView`, + type: 'index-pattern', + id: WEB_LOGS_DATA_VIEW_ID, + }, + ], +}; diff --git a/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts new file mode 100644 index 00000000000000..399fbf6c463cd6 --- /dev/null +++ b/examples/controls_example/public/react_controls/control_group/control_group_unsaved_changes_api.ts @@ -0,0 +1,79 @@ +/* + * 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 { omit } from 'lodash'; +import { + childrenUnsavedChanges$, + initializeUnsavedChanges, + PresentationContainer, +} from '@kbn/presentation-containers'; +import { + apiPublishesUnsavedChanges, + PublishesUnsavedChanges, + StateComparators, +} from '@kbn/presentation-publishing'; +import { combineLatest, map } from 'rxjs'; +import { ControlsInOrder, getControlsInOrder } from './init_controls_manager'; +import { ControlGroupRuntimeState, ControlPanelsState } from './types'; + +export type ControlGroupComparatorState = Pick< + ControlGroupRuntimeState, + | 'autoApplySelections' + | 'chainingSystem' + | 'ignoreParentSettings' + | 'initialChildControlState' + | 'labelPosition' +> & { + controlsInOrder: ControlsInOrder; +}; + +export function initializeControlGroupUnsavedChanges( + children$: PresentationContainer['children$'], + comparators: StateComparators, + snapshotControlsRuntimeState: () => ControlPanelsState, + parentApi: unknown, + lastSavedRuntimeState: ControlGroupRuntimeState +) { + const controlGroupUnsavedChanges = initializeUnsavedChanges( + { + autoApplySelections: lastSavedRuntimeState.autoApplySelections, + chainingSystem: lastSavedRuntimeState.chainingSystem, + controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState), + ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings, + initialChildControlState: lastSavedRuntimeState.initialChildControlState, + labelPosition: lastSavedRuntimeState.labelPosition, + }, + parentApi, + comparators + ); + + return { + api: { + unsavedChanges: combineLatest([ + controlGroupUnsavedChanges.api.unsavedChanges, + childrenUnsavedChanges$(children$), + ]).pipe( + map(([unsavedControlGroupState, unsavedControlsState]) => { + const unsavedChanges: Partial = unsavedControlGroupState + ? omit(unsavedControlGroupState, 'controlsInOrder') + : {}; + if (unsavedControlsState || unsavedControlGroupState?.controlsInOrder) { + unsavedChanges.initialChildControlState = snapshotControlsRuntimeState(); + } + return Object.keys(unsavedChanges).length ? unsavedChanges : undefined; + }) + ), + resetUnsavedChanges: () => { + controlGroupUnsavedChanges.api.resetUnsavedChanges(); + Object.values(children$.value).forEach((controlApi) => { + if (apiPublishesUnsavedChanges(controlApi)) controlApi.resetUnsavedChanges(); + }); + }, + } as PublishesUnsavedChanges, + }; +} diff --git a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx index 64e452aae11808..13e6456071a477 100644 --- a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx +++ b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx @@ -8,6 +8,7 @@ import React, { useEffect } from 'react'; import { BehaviorSubject } from 'rxjs'; +import fastIsEqual from 'fast-deep-equal'; import { ControlGroupChainingSystem, ControlWidth, @@ -22,7 +23,10 @@ import { DataView } from '@kbn/data-views-plugin/common'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; -import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; +import { + apiHasSaveNotification, + combineCompatibleChildrenApis, +} from '@kbn/presentation-containers'; import { apiPublishesDataViews, PublishesDataViews, @@ -32,14 +36,10 @@ import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch'; import { initControlsManager } from './init_controls_manager'; import { openEditControlGroupFlyout } from './open_edit_control_group_flyout'; import { deserializeControlGroup } from './serialization_utils'; -import { - ControlGroupApi, - ControlGroupRuntimeState, - ControlGroupSerializedState, - ControlGroupUnsavedChanges, -} from './types'; +import { ControlGroupApi, ControlGroupRuntimeState, ControlGroupSerializedState } from './types'; import { ControlGroup } from './components/control_group'; import { initSelectionsManager } from './selections_manager'; +import { initializeControlGroupUnsavedChanges } from './control_group_unsaved_changes_api'; export const getControlGroupEmbeddableFactory = (services: { core: CoreStart; @@ -52,7 +52,14 @@ export const getControlGroupEmbeddableFactory = (services: { > = { type: CONTROL_GROUP_TYPE, deserializeState: (state) => deserializeControlGroup(state), - buildEmbeddable: async (initialState, buildApi, uuid, parentApi, setApi) => { + buildEmbeddable: async ( + initialRuntimeState, + buildApi, + uuid, + parentApi, + setApi, + lastSavedRuntimeState + ) => { const { initialChildControlState, defaultControlGrow, @@ -61,7 +68,7 @@ export const getControlGroupEmbeddableFactory = (services: { chainingSystem, autoApplySelections, ignoreParentSettings, - } = initialState; + } = initialRuntimeState; const autoApplySelections$ = new BehaviorSubject(autoApplySelections); const controlsManager = initControlsManager(initialChildControlState); @@ -88,20 +95,36 @@ export const getControlGroupEmbeddableFactory = (services: { /** TODO: Handle loading; loading should be true if any child is loading */ const dataLoading$ = new BehaviorSubject(false); - /** TODO: Handle unsaved changes - * - Each child has an unsaved changed behaviour subject it pushes to - * - The control group listens to all of them (anyChildHasUnsavedChanges) and publishes its - * own unsaved changes if either one of its children has unsaved changes **or** one of - * the control group settings changed. - * - Children should **not** publish unsaved changes based on their output filters or selections. - * Instead, the control group will handle unsaved changes for filters. - */ - const unsavedChanges = new BehaviorSubject | undefined>( - undefined + const unsavedChanges = initializeControlGroupUnsavedChanges( + controlsManager.api.children$, + { + ...controlsManager.comparators, + autoApplySelections: [ + autoApplySelections$, + (next: boolean) => autoApplySelections$.next(next), + ], + chainingSystem: [ + chainingSystem$, + (next: ControlGroupChainingSystem) => chainingSystem$.next(next), + ], + ignoreParentSettings: [ + ignoreParentSettings$, + (next: ParentIgnoreSettings | undefined) => ignoreParentSettings$.next(next), + fastIsEqual, + ], + labelPosition: [labelPosition$, (next: ControlStyle) => labelPosition$.next(next)], + }, + controlsManager.snapshotControlsRuntimeState, + parentApi, + lastSavedRuntimeState ); const api = setApi({ ...controlsManager.api, + getLastSavedControlState: (controlUuid: string) => { + return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {}; + }, + ...unsavedChanges.api, ...selectionsManager.api, controlFetch$: (controlUuid: string) => controlFetch$( @@ -116,10 +139,6 @@ export const getControlGroupEmbeddableFactory = (services: { ignoreParentSettings$, autoApplySelections$, allowExpensiveQueries$, - unsavedChanges, - resetUnsavedChanges: () => { - // TODO: Implement this - }, snapshotRuntimeState: () => { // TODO: Remove this if it ends up being unnecessary return {} as unknown as ControlGroupRuntimeState; @@ -159,6 +178,9 @@ export const getControlGroupEmbeddableFactory = (services: { width, dataViews, labelPosition: labelPosition$, + saveNotification$: apiHasSaveNotification(parentApi) + ? parentApi.saveNotification$ + : undefined, }); /** Subscribe to all children's output data views, combine them, and output them */ diff --git a/examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts b/examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts index 98210d3d19eac5..cc3b8492dce4a6 100644 --- a/examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts +++ b/examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts @@ -16,12 +16,12 @@ jest.mock('uuid', () => ({ describe('PresentationContainer api', () => { test('addNewPanel should add control at end of controls', async () => { const controlsManager = initControlsManager({ - alpha: { type: 'whatever', order: 0 }, - bravo: { type: 'whatever', order: 1 }, - charlie: { type: 'whatever', order: 2 }, + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + charlie: { type: 'testControl', order: 2 }, }); const addNewPanelPromise = controlsManager.api.addNewPanel({ - panelType: 'whatever', + panelType: 'testControl', initialState: {}, }); controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); @@ -36,9 +36,9 @@ describe('PresentationContainer api', () => { test('removePanel should remove control', () => { const controlsManager = initControlsManager({ - alpha: { type: 'whatever', order: 0 }, - bravo: { type: 'whatever', order: 1 }, - charlie: { type: 'whatever', order: 2 }, + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + charlie: { type: 'testControl', order: 2 }, }); controlsManager.api.removePanel('bravo'); expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([ @@ -49,12 +49,12 @@ describe('PresentationContainer api', () => { test('replacePanel should replace control', async () => { const controlsManager = initControlsManager({ - alpha: { type: 'whatever', order: 0 }, - bravo: { type: 'whatever', order: 1 }, - charlie: { type: 'whatever', order: 2 }, + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, + charlie: { type: 'testControl', order: 2 }, }); const replacePanelPromise = controlsManager.api.replacePanel('bravo', { - panelType: 'whatever', + panelType: 'testControl', initialState: {}, }); controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); @@ -69,8 +69,8 @@ describe('PresentationContainer api', () => { describe('untilInitialized', () => { test('should not resolve until all controls are initialized', async () => { const controlsManager = initControlsManager({ - alpha: { type: 'whatever', order: 0 }, - bravo: { type: 'whatever', order: 1 }, + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, }); let isDone = false; controlsManager.api.untilInitialized().then(() => { @@ -90,8 +90,8 @@ describe('PresentationContainer api', () => { test('should resolve when all control already initialized ', async () => { const controlsManager = initControlsManager({ - alpha: { type: 'whatever', order: 0 }, - bravo: { type: 'whatever', order: 1 }, + alpha: { type: 'testControl', order: 0 }, + bravo: { type: 'testControl', order: 1 }, }); controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi); @@ -106,3 +106,34 @@ describe('PresentationContainer api', () => { }); }); }); + +describe('snapshotControlsRuntimeState', () => { + test('should snapshot runtime state for all controls', async () => { + const controlsManager = initControlsManager({ + alpha: { type: 'testControl', order: 1 }, + bravo: { type: 'testControl', order: 0 }, + }); + controlsManager.setControlApi('alpha', { + snapshotRuntimeState: () => { + return { key1: 'alpha value' }; + }, + } as unknown as DefaultControlApi); + controlsManager.setControlApi('bravo', { + snapshotRuntimeState: () => { + return { key1: 'bravo value' }; + }, + } as unknown as DefaultControlApi); + expect(controlsManager.snapshotControlsRuntimeState()).toEqual({ + alpha: { + key1: 'alpha value', + order: 1, + type: 'testControl', + }, + bravo: { + key1: 'bravo value', + order: 0, + type: 'testControl', + }, + }); + }); +}); diff --git a/examples/controls_example/public/react_controls/control_group/init_controls_manager.ts b/examples/controls_example/public/react_controls/control_group/init_controls_manager.ts index 378bfe4d82567f..dad214daf96b9c 100644 --- a/examples/controls_example/public/react_controls/control_group/init_controls_manager.ts +++ b/examples/controls_example/public/react_controls/control_group/init_controls_manager.ts @@ -7,6 +7,7 @@ */ import { v4 as generateId } from 'uuid'; +import fastIsEqual from 'fast-deep-equal'; import { HasSerializedChildState, PanelPackage, @@ -14,28 +15,35 @@ import { } from '@kbn/presentation-containers'; import type { Reference } from '@kbn/content-management-utils'; import { BehaviorSubject, first, merge } from 'rxjs'; -import { PublishingSubject } from '@kbn/presentation-publishing'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; import { omit } from 'lodash'; +import { apiHasSnapshottableState } from '@kbn/presentation-containers/interfaces/serialized_state'; import { ControlPanelsState, ControlPanelState } from './types'; import { DefaultControlApi, DefaultControlState } from '../types'; +import { ControlGroupComparatorState } from './control_group_unsaved_changes_api'; export type ControlsInOrder = Array<{ id: string; type: string }>; +export function getControlsInOrder(initialControlPanelsState: ControlPanelsState) { + return Object.keys(initialControlPanelsState) + .map((key) => ({ + id: key, + order: initialControlPanelsState[key].order, + type: initialControlPanelsState[key].type, + })) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map(({ id, type }) => ({ id, type })); // filter out `order` +} + export function initControlsManager(initialControlPanelsState: ControlPanelsState) { + const lastSavedControlsPanelState$ = new BehaviorSubject(initialControlPanelsState); const initialControlIds = Object.keys(initialControlPanelsState); const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); - const controlsPanelState: { [panelId: string]: DefaultControlState } = { + let controlsPanelState: { [panelId: string]: DefaultControlState } = { ...initialControlPanelsState, }; const controlsInOrder$ = new BehaviorSubject( - Object.keys(initialControlPanelsState) - .map((key) => ({ - id: key, - order: initialControlPanelsState[key].order, - type: initialControlPanelsState[key].type, - })) - .sort((a, b) => (a.order > b.order ? 1 : -1)) - .map(({ id, type }) => ({ id, type })) // filter out `order` + getControlsInOrder(initialControlPanelsState) ); function untilControlLoaded( @@ -133,6 +141,20 @@ export function initControlsManager(initialControlPanelsState: ControlPanelsStat references, }; }, + snapshotControlsRuntimeState: () => { + const controlsRuntimeState: ControlPanelsState = {}; + controlsInOrder$.getValue().forEach(({ id, type }, index) => { + const controlApi = getControlApi(id); + if (controlApi && apiHasSnapshottableState(controlApi)) { + controlsRuntimeState[id] = { + order: index, + type, + ...controlApi.snapshotRuntimeState(), + }; + } + }); + return controlsRuntimeState; + }, api: { getSerializedStateForChild: (childId: string) => { const controlPanelState = controlsPanelState[childId]; @@ -175,5 +197,28 @@ export function initControlsManager(initialControlPanelsState: ControlPanelsStat }, } as PresentationContainer & HasSerializedChildState & { untilInitialized: () => Promise }, + comparators: { + controlsInOrder: [ + controlsInOrder$, + (next: ControlsInOrder) => controlsInOrder$.next(next), + fastIsEqual, + ], + // Control state differences tracked by controlApi comparators + // Control ordering differences tracked by controlsInOrder comparator + // initialChildControlState comparatator exists to reset controls manager to last saved state + initialChildControlState: [ + lastSavedControlsPanelState$, + (lastSavedControlPanelsState: ControlPanelsState) => { + lastSavedControlsPanelState$.next(lastSavedControlPanelsState); + controlsPanelState = { + ...lastSavedControlPanelsState, + }; + controlsInOrder$.next(getControlsInOrder(lastSavedControlPanelsState)); + }, + () => true, + ], + } as StateComparators< + Pick + >, }; } diff --git a/examples/controls_example/public/react_controls/control_group/types.ts b/examples/controls_example/public/react_controls/control_group/types.ts index 9d1a390125dad6..65db5b8121b1b3 100644 --- a/examples/controls_example/public/react_controls/control_group/types.ts +++ b/examples/controls_example/public/react_controls/control_group/types.ts @@ -11,7 +11,11 @@ import { ParentIgnoreSettings } from '@kbn/controls-plugin/public'; import { ControlStyle, ControlWidth } from '@kbn/controls-plugin/public/types'; import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import { Filter } from '@kbn/es-query'; -import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers'; +import { + HasSaveNotification, + HasSerializedChildState, + PresentationContainer, +} from '@kbn/presentation-containers'; import { HasEditCapabilities, HasParentApi, @@ -54,9 +58,10 @@ export type ControlGroupApi = PresentationContainer & PublishesUnsavedChanges & PublishesControlGroupDisplaySettings & PublishesTimeslice & - Partial> & { + Partial & HasSaveNotification> & { autoApplySelections$: PublishingSubject; controlFetch$: (controlUuid: string) => Observable; + getLastSavedControlState: (controlUuid: string) => object; ignoreParentSettings$: PublishingSubject; allowExpensiveQueries$: PublishingSubject; untilInitialized: () => Promise; @@ -84,16 +89,8 @@ export type ControlGroupEditorState = Pick< 'chainingSystem' | 'labelPosition' | 'autoApplySelections' | 'ignoreParentSettings' >; -export type ControlGroupSerializedState = Omit< - ControlGroupRuntimeState, - | 'labelPosition' - | 'ignoreParentSettings' - | 'defaultControlGrow' - | 'defaultControlWidth' - | 'anyChildHasUnsavedChanges' - | 'initialChildControlState' - | 'autoApplySelections' -> & { +export interface ControlGroupSerializedState { + chainingSystem: ControlGroupChainingSystem; panelsJSON: string; ignoreParentSettingsJSON: string; // In runtime state, we refer to this property as `labelPosition`; @@ -102,4 +99,4 @@ export type ControlGroupSerializedState = Omit< // In runtime state, we refer to the inverse of this property as `autoApplySelections` // to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state showApplySelections: boolean | undefined; -}; +} diff --git a/examples/controls_example/public/react_controls/control_renderer.tsx b/examples/controls_example/public/react_controls/control_renderer.tsx index cdaf53276760b1..2c248d7c05f79f 100644 --- a/examples/controls_example/public/react_controls/control_renderer.tsx +++ b/examples/controls_example/public/react_controls/control_renderer.tsx @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import React, { useEffect, useImperativeHandle, useState } from 'react'; +import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { StateComparators } from '@kbn/presentation-publishing'; import { getControlFactory } from './control_factory_registry'; @@ -35,6 +36,8 @@ export const ControlRenderer = < onApiAvailable?: (api: ApiType) => void; isControlGroupInitialized: boolean; }) => { + const cleanupFunction = useRef<(() => void) | null>(null); + const [component, setComponent] = useState>( undefined ); @@ -48,25 +51,29 @@ export const ControlRenderer = < const factory = getControlFactory(type); const buildApi = ( apiRegistration: ControlApiRegistration, - comparators: StateComparators // TODO: Use these to calculate unsaved changes + comparators: StateComparators ): ApiType => { + const unsavedChanges = initializeUnsavedChanges( + parentApi.getLastSavedControlState(uuid) as StateType, + parentApi, + comparators + ); + + cleanupFunction.current = () => unsavedChanges.cleanup(); + return { ...apiRegistration, + ...unsavedChanges.api, uuid, parentApi, - unsavedChanges: new BehaviorSubject | undefined>(undefined), - resetUnsavedChanges: () => {}, type: factory.type, } as unknown as ApiType; }; - const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? {}; - - return await factory.buildControl( - initialState as unknown as StateType, - buildApi, - uuid, - parentApi - ); + + const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? { + rawState: {}, + }; + return await factory.buildControl(initialState as StateType, buildApi, uuid, parentApi); } buildControl() @@ -118,6 +125,12 @@ export const ControlRenderer = < [type] ); + useEffect(() => { + return () => { + cleanupFunction.current?.(); + }; + }, []); + return component && isControlGroupInitialized ? ( // @ts-expect-error Component={component} uuid={uuid} /> diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx index f52dd0c3e528b2..b653cc95425882 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx @@ -21,7 +21,7 @@ interface Props { max: number | undefined; min: number | undefined; onChange: (value: RangeValue | undefined) => void; - step: number | undefined; + step: number; value: RangeValue | undefined; uuid: string; controlPanelClassName?: string; diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 8893d114c98f34..385a93cf7e1d5f 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -100,7 +100,11 @@ export const getRangesliderControlFactory = ( }, { ...dataControl.comparators, - step: [step$, (nextStep: number | undefined) => step$.next(nextStep)], + step: [ + step$, + (nextStep: number | undefined) => step$.next(nextStep), + (a, b) => (a ?? 1) === (b ?? 1), + ], value: [value$, setValue], } ); @@ -237,7 +241,7 @@ export const getRangesliderControlFactory = ( max={max} min={min} onChange={setValue} - step={step} + step={step ?? 1} value={value} uuid={uuid} /> diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx index f57e7a19c00ff3..6eed314ccdaa21 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx @@ -122,6 +122,7 @@ export const getSearchControlFactory = ( searchTechnique, (newTechnique: SearchControlTechniques | undefined) => searchTechnique.next(newTechnique), + (a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE), ], searchString: [ searchString, diff --git a/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx b/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx index 4f086444da56bf..709166ca6fed48 100644 --- a/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/timeslider_control/get_timeslider_control_factory.tsx @@ -13,6 +13,7 @@ import { EuiInputPopover } from '@elastic/eui'; import { apiHasParentApi, apiPublishesDataLoading, + getUnchangingComparator, getViewModeSubject, useBatchedPublishingSubjects, ViewMode, @@ -185,7 +186,6 @@ export const getTimesliderControlFactory = ( const viewModeSubject = getViewModeSubject(controlGroupApi) ?? new BehaviorSubject('view' as ViewMode); - // overwrite the `width` attribute because time slider should always have a width of large const defaultControl = initializeDefaultControlApi({ ...initialState, width: 'large' }); const dashboardDataLoading$ = @@ -243,6 +243,7 @@ export const getTimesliderControlFactory = ( }, { ...defaultControl.comparators, + width: getUnchangingComparator(), ...timeRangePercentage.comparators, isAnchored: [isAnchored$, setIsAnchored], } diff --git a/packages/presentation/presentation_containers/index.ts b/packages/presentation/presentation_containers/index.ts index 06c1d7c04ee9cf..224cfbb8762145 100644 --- a/packages/presentation/presentation_containers/index.ts +++ b/packages/presentation/presentation_containers/index.ts @@ -13,6 +13,8 @@ export { type HasRuntimeChildState, type HasSerializedChildState, } from './interfaces/child_state'; +export { childrenUnsavedChanges$ } from './interfaces/unsaved_changes/children_unsaved_changes'; +export { initializeUnsavedChanges } from './interfaces/unsaved_changes/initialize_unsaved_changes'; export { apiHasSaveNotification, type HasSaveNotification, diff --git a/packages/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.test.ts b/packages/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.test.ts new file mode 100644 index 00000000000000..d02ac5ad2e9a7c --- /dev/null +++ b/packages/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { BehaviorSubject, skip } from 'rxjs'; +import { childrenUnsavedChanges$, DEBOUNCE_TIME } from './children_unsaved_changes'; + +describe('childrenUnsavedChanges$', () => { + const child1Api = { + unsavedChanges: new BehaviorSubject(undefined), + resetUnsavedChanges: () => undefined, + }; + const child2Api = { + unsavedChanges: new BehaviorSubject(undefined), + resetUnsavedChanges: () => undefined, + }; + const children$ = new BehaviorSubject<{ [key: string]: unknown }>({}); + const onFireMock = jest.fn(); + + beforeEach(() => { + onFireMock.mockReset(); + child1Api.unsavedChanges.next(undefined); + child2Api.unsavedChanges.next(undefined); + children$.next({ + child1: child1Api, + child2: child2Api, + }); + }); + + test('should emit on subscribe', async () => { + const subscription = childrenUnsavedChanges$(children$).subscribe(onFireMock); + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1)); + + expect(onFireMock).toHaveBeenCalledTimes(1); + const childUnsavedChanges = onFireMock.mock.calls[0][0]; + expect(childUnsavedChanges).toBeUndefined(); + + subscription.unsubscribe(); + }); + + test('should emit when child has new unsaved changes', async () => { + const subscription = childrenUnsavedChanges$(children$).pipe(skip(1)).subscribe(onFireMock); + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1)); + expect(onFireMock).toHaveBeenCalledTimes(0); + + child1Api.unsavedChanges.next({ + key1: 'modified value', + }); + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1)); + + expect(onFireMock).toHaveBeenCalledTimes(1); + const childUnsavedChanges = onFireMock.mock.calls[0][0]; + expect(childUnsavedChanges).toEqual({ + child1: { + key1: 'modified value', + }, + }); + + subscription.unsubscribe(); + }); + + test('should emit when children changes', async () => { + const subscription = childrenUnsavedChanges$(children$).pipe(skip(1)).subscribe(onFireMock); + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1)); + expect(onFireMock).toHaveBeenCalledTimes(0); + + // add child + children$.next({ + ...children$.value, + child3: { + unsavedChanges: new BehaviorSubject({ key1: 'modified value' }), + resetUnsavedChanges: () => undefined, + }, + }); + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1)); + + expect(onFireMock).toHaveBeenCalledTimes(1); + const childUnsavedChanges = onFireMock.mock.calls[0][0]; + expect(childUnsavedChanges).toEqual({ + child3: { + key1: 'modified value', + }, + }); + + subscription.unsubscribe(); + }); +}); diff --git a/packages/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts b/packages/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts new file mode 100644 index 00000000000000..fa504f8eec4a35 --- /dev/null +++ b/packages/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts @@ -0,0 +1,50 @@ +/* + * 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 { combineLatest, debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; +import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing'; +import { PresentationContainer } from '../presentation_container'; + +export const DEBOUNCE_TIME = 100; + +/** + * Create an observable stream of unsaved changes from all react embeddable children + */ +export function childrenUnsavedChanges$(children$: PresentationContainer['children$']) { + return children$.pipe( + map((children) => Object.keys(children)), + distinctUntilChanged(deepEqual), + + // children may change, so make sure we subscribe/unsubscribe with switchMap + switchMap((newChildIds: string[]) => { + if (newChildIds.length === 0) return of([]); + const childrenThatPublishUnsavedChanges = Object.entries(children$.value).filter( + ([childId, child]) => apiPublishesUnsavedChanges(child) + ) as Array<[string, PublishesUnsavedChanges]>; + + return childrenThatPublishUnsavedChanges.length === 0 + ? of([]) + : combineLatest( + childrenThatPublishUnsavedChanges.map(([childId, child]) => + child.unsavedChanges.pipe(map((unsavedChanges) => ({ childId, unsavedChanges }))) + ) + ); + }), + debounceTime(DEBOUNCE_TIME), + map((unsavedChildStates) => { + const unsavedChildrenState: { [key: string]: object } = {}; + unsavedChildStates.forEach(({ childId, unsavedChanges }) => { + if (unsavedChanges) { + unsavedChildrenState[childId] = unsavedChanges; + } + }); + return Object.keys(unsavedChildrenState).length ? unsavedChildrenState : undefined; + }) + ); +} diff --git a/packages/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts b/packages/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts new file mode 100644 index 00000000000000..fbbe33e4ffdea3 --- /dev/null +++ b/packages/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { BehaviorSubject, Subject } from 'rxjs'; +import { + COMPARATOR_SUBJECTS_DEBOUNCE, + initializeUnsavedChanges, +} from './initialize_unsaved_changes'; +import { PublishesUnsavedChanges, StateComparators } from '@kbn/presentation-publishing'; + +interface TestState { + key1: string; + key2: string; +} + +describe('unsavedChanges api', () => { + const lastSavedState = { + key1: 'original key1 value', + key2: 'original key2 value', + } as TestState; + const key1$ = new BehaviorSubject(lastSavedState.key1); + const key2$ = new BehaviorSubject(lastSavedState.key2); + const comparators = { + key1: [key1$, (next: string) => key1$.next(next)], + key2: [key2$, (next: string) => key2$.next(next)], + } as StateComparators; + const parentApi = { + saveNotification$: new Subject(), + }; + + let api: undefined | PublishesUnsavedChanges; + beforeEach(() => { + key1$.next(lastSavedState.key1); + key2$.next(lastSavedState.key2); + ({ api } = initializeUnsavedChanges(lastSavedState, parentApi, comparators)); + }); + + test('should have no unsaved changes after initialization', () => { + expect(api?.unsavedChanges.value).toBeUndefined(); + }); + + test('should have unsaved changes when state changes', async () => { + key1$.next('modified key1 value'); + await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1)); + expect(api?.unsavedChanges.value).toEqual({ + key1: 'modified key1 value', + }); + }); + + test('should have no unsaved changes after save', async () => { + key1$.next('modified key1 value'); + await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1)); + expect(api?.unsavedChanges.value).not.toBeUndefined(); + + // trigger save + parentApi.saveNotification$.next(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(api?.unsavedChanges.value).toBeUndefined(); + }); + + test('should have no unsaved changes after reset', async () => { + key1$.next('modified key1 value'); + await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1)); + expect(api?.unsavedChanges.value).not.toBeUndefined(); + + // trigger reset + api?.resetUnsavedChanges(); + + await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1)); + expect(api?.unsavedChanges.value).toBeUndefined(); + }); +}); diff --git a/packages/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts b/packages/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts new file mode 100644 index 00000000000000..7f4770d39cd2dd --- /dev/null +++ b/packages/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts @@ -0,0 +1,110 @@ +/* + * 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 { + BehaviorSubject, + combineLatest, + combineLatestWith, + debounceTime, + map, + skip, + Subscription, +} from 'rxjs'; +import { + getInitialValuesFromComparators, + PublishesUnsavedChanges, + PublishingSubject, + runComparators, + StateComparators, +} from '@kbn/presentation-publishing'; +import { HasSnapshottableState } from '../serialized_state'; +import { apiHasSaveNotification } from '../has_save_notification'; + +export const COMPARATOR_SUBJECTS_DEBOUNCE = 100; + +export const initializeUnsavedChanges = ( + initialLastSavedState: RuntimeState, + parentApi: unknown, + comparators: StateComparators +) => { + const subscriptions: Subscription[] = []; + const lastSavedState$ = new BehaviorSubject(initialLastSavedState); + + const snapshotRuntimeState = () => { + const comparatorKeys = Object.keys(comparators) as Array; + const snapshot = {} as RuntimeState; + comparatorKeys.forEach((key) => { + const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject + snapshot[key] = comparatorSubject.value as RuntimeState[typeof key]; + }); + return snapshot; + }; + + if (apiHasSaveNotification(parentApi)) { + subscriptions.push( + // any time the parent saves, the current state becomes the last saved state... + parentApi.saveNotification$.subscribe(() => { + lastSavedState$.next(snapshotRuntimeState()); + }) + ); + } + + const comparatorSubjects: Array> = []; + const comparatorKeys: Array = []; // index maps comparator subject to comparator key + for (const key of Object.keys(comparators) as Array) { + const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject + comparatorSubjects.push(comparatorSubject as PublishingSubject); + comparatorKeys.push(key); + } + + const unsavedChanges = new BehaviorSubject | undefined>( + runComparators( + comparators, + comparatorKeys, + lastSavedState$.getValue() as RuntimeState, + getInitialValuesFromComparators(comparators, comparatorKeys) + ) + ); + + subscriptions.push( + combineLatest(comparatorSubjects) + .pipe( + skip(1), + debounceTime(COMPARATOR_SUBJECTS_DEBOUNCE), + map((latestStates) => + comparatorKeys.reduce((acc, key, index) => { + acc[key] = latestStates[index] as RuntimeState[typeof key]; + return acc; + }, {} as Partial) + ), + combineLatestWith(lastSavedState$) + ) + .subscribe(([latestState, lastSavedState]) => { + unsavedChanges.next( + runComparators(comparators, comparatorKeys, lastSavedState, latestState) + ); + }) + ); + + return { + api: { + unsavedChanges, + resetUnsavedChanges: () => { + const lastSaved = lastSavedState$.getValue(); + for (const key of comparatorKeys) { + const setter = comparators[key][1]; // setter function is the 1st element of the tuple + setter(lastSaved?.[key] as RuntimeState[typeof key]); + } + }, + snapshotRuntimeState, + } as PublishesUnsavedChanges & HasSnapshottableState, + cleanup: () => { + subscriptions.forEach((subscription) => subscription.unsubscribe()); + }, + }; +}; diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts b/packages/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts index 4ac551620c376d..626959f41a9415 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts @@ -8,8 +8,8 @@ import { PublishingSubject } from '../publishing_subject'; -export interface PublishesUnsavedChanges { - unsavedChanges: PublishingSubject; +export interface PublishesUnsavedChanges { + unsavedChanges: PublishingSubject | undefined>; resetUnsavedChanges: () => void; } diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts index 4fe3184f619c97..89f71c074d9fdf 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts @@ -6,12 +6,10 @@ * Side Public License, v 1. */ import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; -import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing'; -import deepEqual from 'fast-deep-equal'; +import { childrenUnsavedChanges$ } from '@kbn/presentation-containers'; import { omit } from 'lodash'; import { AnyAction, Middleware } from 'redux'; import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs'; import { DashboardContainer, DashboardCreationOptions } from '../..'; import { DashboardContainerInput } from '../../../../common'; import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; @@ -84,32 +82,6 @@ export function startDiffingDashboardState( this: DashboardContainer, creationOptions?: DashboardCreationOptions ) { - /** - * Create an observable stream of unsaved changes from all react embeddable children - */ - const reactEmbeddableUnsavedChanges = this.children$.pipe( - map((children) => Object.keys(children)), - distinctUntilChanged(deepEqual), - - // children may change, so make sure we subscribe/unsubscribe with switchMap - switchMap((newChildIds: string[]) => { - if (newChildIds.length === 0) return of([]); - const childrenThatPublishUnsavedChanges = Object.entries(this.children$.value).filter( - ([childId, child]) => apiPublishesUnsavedChanges(child) - ) as Array<[string, PublishesUnsavedChanges]>; - - if (childrenThatPublishUnsavedChanges.length === 0) return of([]); - - return combineLatest( - childrenThatPublishUnsavedChanges.map(([childId, child]) => - child.unsavedChanges.pipe(map((unsavedChanges) => ({ childId, unsavedChanges }))) - ) - ); - }), - debounceTime(CHANGE_CHECK_DEBOUNCE), - map((children) => children.filter((child) => Boolean(child.unsavedChanges))) - ); - /** * Create an observable stream that checks for unsaved changes in the Dashboard state * and the state of all of its legacy embeddable children. @@ -138,30 +110,26 @@ export function startDiffingDashboardState( this.diffingSubscription.add( combineLatest([ dashboardUnsavedChanges, - reactEmbeddableUnsavedChanges, + childrenUnsavedChanges$(this.children$), this.controlGroup?.unsavedChanges ?? (of(undefined) as Observable), - ]).subscribe(([dashboardChanges, reactEmbeddableChanges, controlGroupChanges]) => { + ]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => { // calculate unsaved changes const hasUnsavedChanges = Object.keys(omit(dashboardChanges, keysNotConsideredUnsavedChanges)).length > 0 || - reactEmbeddableChanges.length > 0 || + unsavedPanelState !== undefined || controlGroupChanges !== undefined; if (hasUnsavedChanges !== this.getState().componentState.hasUnsavedChanges) { this.dispatch.setHasUnsavedChanges(hasUnsavedChanges); } - const unsavedPanelState = reactEmbeddableChanges.reduce( - (acc, { childId, unsavedChanges }) => { - acc[childId] = unsavedChanges; - return acc; - }, - {} as UnsavedPanelState - ); - // backup unsaved changes if configured to do so if (creationOptions?.useSessionStorageIntegration) { - backupUnsavedChanges.bind(this)(dashboardChanges, unsavedPanelState, controlGroupChanges); + backupUnsavedChanges.bind(this)( + dashboardChanges, + unsavedPanelState ? unsavedPanelState : {}, + controlGroupChanges + ); } }) ); diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx index 3d909ae2871775..085798658cc75d 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx @@ -123,12 +123,14 @@ describe('saved search embeddable', () => { describe('search embeddable component', () => { it('should render empty grid when empty data is returned', async () => { const { search, resolveSearch } = createSearchFnMock(0); + const initialRuntimeState = getInitialRuntimeState({ searchMock: search }); const { Component, api } = await factory.buildEmbeddable( - getInitialRuntimeState({ searchMock: search }), + initialRuntimeState, buildApiMock, uuid, mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi) + jest.fn().mockImplementation((newApi) => newApi), + initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState ); await waitOneTick(); // wait for build to complete const discoverComponent = render(); @@ -148,15 +150,17 @@ describe('saved search embeddable', () => { it('should render field stats table in AGGREGATED_LEVEL view mode', async () => { const { search, resolveSearch } = createSearchFnMock(0); + const initialRuntimeState = getInitialRuntimeState({ + searchMock: search, + partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL }, + }); const { Component, api } = await factory.buildEmbeddable( - getInitialRuntimeState({ - searchMock: search, - partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL }, - }), + initialRuntimeState, buildApiMock, uuid, mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi) + jest.fn().mockImplementation((newApi) => newApi), + initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState ); await waitOneTick(); // wait for build to complete @@ -178,15 +182,17 @@ describe('saved search embeddable', () => { describe('search embeddable api', () => { it('should not fetch data if only a new input title is set', async () => { const { search, resolveSearch } = createSearchFnMock(1); + const initialRuntimeState = getInitialRuntimeState({ + searchMock: search, + partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL }, + }); const { api } = await factory.buildEmbeddable( - getInitialRuntimeState({ - searchMock: search, - partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL }, - }), + initialRuntimeState, buildApiMock, uuid, mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi) + jest.fn().mockImplementation((newApi) => newApi), + initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState ); await waitOneTick(); // wait for build to complete @@ -219,12 +225,14 @@ describe('saved search embeddable', () => { discoverServiceMock.profilesManager, 'resolveRootProfile' ); + const initialRuntimeState = getInitialRuntimeState(); await factory.buildEmbeddable( - getInitialRuntimeState(), + initialRuntimeState, buildApiMock, uuid, mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi) + jest.fn().mockImplementation((newApi) => newApi), + initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState ); await waitOneTick(); // wait for build to complete @@ -238,12 +246,14 @@ describe('saved search embeddable', () => { discoverServiceMock.profilesManager, 'resolveDataSourceProfile' ); + const initialRuntimeState = getInitialRuntimeState(); const { api } = await factory.buildEmbeddable( - getInitialRuntimeState(), + initialRuntimeState, buildApiMock, uuid, mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi) + jest.fn().mockImplementation((newApi) => newApi), + initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState ); await waitOneTick(); // wait for build to complete @@ -263,15 +273,17 @@ describe('saved search embeddable', () => { it('should pass cell renderers from profile', async () => { const { search, resolveSearch } = createSearchFnMock(1); + const initialRuntimeState = getInitialRuntimeState({ + searchMock: search, + partialState: { columns: ['rootProfile', 'message', 'extension'] }, + }); const { Component, api } = await factory.buildEmbeddable( - getInitialRuntimeState({ - searchMock: search, - partialState: { columns: ['rootProfile', 'message', 'extension'] }, - }), + initialRuntimeState, buildApiMock, uuid, mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi) + jest.fn().mockImplementation((newApi) => newApi), + initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState ); await waitOneTick(); // wait for build to complete diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx index 5c753777eae9b7..b92556bfc6b55e 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx @@ -94,7 +94,8 @@ describe('react embeddable renderer', () => { expect.any(Function), expect.any(String), expect.any(Object), - expect.any(Function) + expect.any(Function), + { bork: 'blorp?' } ); }); }); @@ -120,7 +121,8 @@ describe('react embeddable renderer', () => { expect.any(Function), '12345', expect.any(Object), - expect.any(Function) + expect.any(Function), + { bork: 'blorp?' } ); }); }); @@ -142,7 +144,8 @@ describe('react embeddable renderer', () => { expect.any(Function), expect.any(String), parentApi, - expect.any(Function) + expect.any(Function), + { bork: 'blorp?' } ); }); }); diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx index 953b57e4b207cd..f538c7b1164b1c 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -7,9 +7,11 @@ */ import { + apiHasRuntimeChildState, apiIsPresentationContainer, HasSerializedChildState, HasSnapshottableState, + initializeUnsavedChanges, SerializedPanelState, } from '@kbn/presentation-containers'; import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public'; @@ -23,7 +25,6 @@ import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import { BehaviorSubject, combineLatest, debounceTime, skip, Subscription, switchMap } from 'rxjs'; import { v4 as generateId } from 'uuid'; import { getReactEmbeddableFactory } from './react_embeddable_registry'; -import { initializeReactEmbeddableState } from './react_embeddable_state'; import { BuildReactEmbeddableApiRegistration, DefaultEmbeddableApi, @@ -115,11 +116,18 @@ export const ReactEmbeddableRenderer = < }; const buildEmbeddable = async () => { - const { initialState, startStateDiffing } = await initializeReactEmbeddableState< - SerializedState, - RuntimeState, - Api - >(uuid, factory, parentApi); + const serializedState = parentApi.getSerializedStateForChild(uuid); + const lastSavedRuntimeState = serializedState + ? await factory.deserializeState(serializedState) + : ({} as RuntimeState); + + // If the parent provides runtime state for the child (usually as a state backup or cache), + // we merge it with the last saved runtime state. + const partialRuntimeState = apiHasRuntimeChildState(parentApi) + ? parentApi.getRuntimeStateForChild(uuid) ?? ({} as Partial) + : ({} as Partial); + + const initialRuntimeState = { ...lastSavedRuntimeState, ...partialRuntimeState }; const buildApi = ( apiRegistration: BuildReactEmbeddableApiRegistration< @@ -152,32 +160,34 @@ export const ReactEmbeddableRenderer = < : Promise.resolve(apiRegistration.serializeState()); }) ) - .subscribe((serializedState) => { - onAnyStateChange(serializedState); + .subscribe((nextSerializedState) => { + onAnyStateChange(nextSerializedState); }) ); } - const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } = - startStateDiffing(comparators); + const unsavedChanges = initializeUnsavedChanges( + lastSavedRuntimeState, + parentApi, + comparators + ); const fullApi = setApi({ ...apiRegistration, - unsavedChanges, - resetUnsavedChanges, - snapshotRuntimeState, + ...unsavedChanges.api, } as unknown as SetReactEmbeddableApiRegistration); - cleanupFunction.current = () => cleanup(); + cleanupFunction.current = () => unsavedChanges.cleanup(); return fullApi as Api & HasSnapshottableState; }; const { api, Component } = await factory.buildEmbeddable( - initialState, + initialRuntimeState, buildApi, uuid, parentApi, - setApi + setApi, + lastSavedRuntimeState ); if (apiPublishesDataLoading(api)) { diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.test.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.test.ts deleted file mode 100644 index 6f34b4f04bffd6..00000000000000 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * 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 { - HasRuntimeChildState, - HasSaveNotification, - HasSerializedChildState, - PresentationContainer, -} from '@kbn/presentation-containers'; -import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; -import { StateComparators } from '@kbn/presentation-publishing'; -import { waitFor } from '@testing-library/react'; -import { BehaviorSubject, Subject } from 'rxjs'; -import { initializeReactEmbeddableState } from './react_embeddable_state'; -import { ReactEmbeddableFactory } from './types'; - -interface SuperTestStateType { - name: string; - age: number; - tagline: string; -} - -describe('react embeddable unsaved changes', () => { - let serializedStateForChild: SuperTestStateType; - - let comparators: StateComparators; - let parentApi: PresentationContainer & - HasSerializedChildState & - Partial> & - HasSaveNotification; - - beforeEach(() => { - serializedStateForChild = { - name: 'Sir Testsalot', - age: 42, - tagline: `Oh he's a glutton for testing!`, - }; - parentApi = { - saveNotification$: new Subject(), - ...getMockPresentationContainer(), - getSerializedStateForChild: () => ({ rawState: serializedStateForChild }), - getRuntimeStateForChild: () => undefined, - }; - }); - - const initializeDefaultComparators = () => { - const latestState: SuperTestStateType = { - ...serializedStateForChild, - ...(parentApi.getRuntimeStateForChild?.('uuid') ?? {}), - }; - const nameSubject = new BehaviorSubject(latestState.name); - const ageSubject = new BehaviorSubject(latestState.age); - const taglineSubject = new BehaviorSubject(latestState.tagline); - const defaultComparators: StateComparators = { - name: [nameSubject, jest.fn((nextName) => nameSubject.next(nextName))], - age: [ageSubject, jest.fn((nextAge) => ageSubject.next(nextAge))], - tagline: [taglineSubject, jest.fn((nextTagline) => taglineSubject.next(nextTagline))], - }; - return defaultComparators; - }; - - const startTrackingUnsavedChanges = async ( - customComparators?: StateComparators - ) => { - comparators = customComparators ?? initializeDefaultComparators(); - - const factory: ReactEmbeddableFactory = { - type: 'superTest', - deserializeState: jest.fn().mockImplementation((state) => state.rawState), - buildEmbeddable: async (runtimeState, buildApi) => { - const api = buildApi({ serializeState: jest.fn() }, comparators); - return { api, Component: () => null }; - }, - }; - const { startStateDiffing } = await initializeReactEmbeddableState('uuid', factory, parentApi); - return startStateDiffing(comparators); - }; - - it('should return undefined unsaved changes when parent API does not provide runtime state', async () => { - const unsavedChangesApi = await startTrackingUnsavedChanges(); - parentApi.getRuntimeStateForChild = undefined; - expect(unsavedChangesApi).toBeDefined(); - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - }); - - it('should return undefined unsaved changes when parent API does not have runtime state for this child', async () => { - const unsavedChangesApi = await startTrackingUnsavedChanges(); - // no change here becuase getRuntimeStateForChild already returns undefined - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - }); - - it('should return unsaved changes subject initialized to undefined when no unsaved changes are detected', async () => { - parentApi.getRuntimeStateForChild = () => ({ - name: 'Sir Testsalot', - age: 42, - tagline: `Oh he's a glutton for testing!`, - }); - const unsavedChangesApi = await startTrackingUnsavedChanges(); - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - }); - - it('should return unsaved changes subject initialized with diff when unsaved changes are detected', async () => { - parentApi.getRuntimeStateForChild = () => ({ - tagline: 'Testing is my speciality!', - }); - const unsavedChangesApi = await startTrackingUnsavedChanges(); - expect(unsavedChangesApi.unsavedChanges.value).toEqual({ - tagline: 'Testing is my speciality!', - }); - }); - - it('should detect unsaved changes when state changes during the lifetime of the component', async () => { - const unsavedChangesApi = await startTrackingUnsavedChanges(); - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - - comparators.tagline[1]('Testing is my speciality!'); - await waitFor(() => { - expect(unsavedChangesApi.unsavedChanges.value).toEqual({ - tagline: 'Testing is my speciality!', - }); - }); - }); - - it('current runtime state should become last saved state when parent save notification is triggered', async () => { - const unsavedChangesApi = await startTrackingUnsavedChanges(); - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - - comparators.tagline[1]('Testing is my speciality!'); - await waitFor(() => { - expect(unsavedChangesApi.unsavedChanges.value).toEqual({ - tagline: 'Testing is my speciality!', - }); - }); - - parentApi.saveNotification$.next(); - await waitFor(() => { - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - }); - }); - - it('should reset unsaved changes, calling given setters with last saved values. This should remove all unsaved state', async () => { - const unsavedChangesApi = await startTrackingUnsavedChanges(); - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - - comparators.tagline[1]('Testing is my speciality!'); - await waitFor(() => { - expect(unsavedChangesApi.unsavedChanges.value).toEqual({ - tagline: 'Testing is my speciality!', - }); - }); - - unsavedChangesApi.resetUnsavedChanges(); - expect(comparators.tagline[1]).toHaveBeenCalledWith(`Oh he's a glutton for testing!`); - await waitFor(() => { - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - }); - }); - - it('uses a custom comparator when supplied', async () => { - serializedStateForChild.age = 20; - parentApi.getRuntimeStateForChild = () => ({ - age: 50, - }); - const ageSubject = new BehaviorSubject(50); - const customComparators: StateComparators = { - ...initializeDefaultComparators(), - age: [ - ageSubject, - jest.fn((nextAge) => ageSubject.next(nextAge)), - (lastAge, currentAge) => lastAge?.toString().length === currentAge?.toString().length, - ], - }; - - const unsavedChangesApi = await startTrackingUnsavedChanges(customComparators); - - // here we expect there to be no unsaved changes, both unsaved state and last saved state have two digits. - expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); - - comparators.age[1](101); - - await waitFor(() => { - // here we expect there to be unsaved changes, because now the latest state has three digits. - expect(unsavedChangesApi.unsavedChanges.value).toEqual({ - age: 101, - }); - }); - }); -}); diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.ts deleted file mode 100644 index f29d418bd39638..00000000000000 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_state.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 { - apiHasRuntimeChildState, - apiHasSaveNotification, - HasSerializedChildState, -} from '@kbn/presentation-containers'; -import { - getInitialValuesFromComparators, - PublishingSubject, - runComparators, - StateComparators, -} from '@kbn/presentation-publishing'; -import { - BehaviorSubject, - combineLatest, - combineLatestWith, - debounceTime, - map, - Subscription, -} from 'rxjs'; -import { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types'; - -export const initializeReactEmbeddableState = async < - SerializedState extends object = object, - RuntimeState extends object = SerializedState, - Api extends DefaultEmbeddableApi = DefaultEmbeddableApi< - SerializedState, - RuntimeState - > ->( - uuid: string, - factory: ReactEmbeddableFactory, - parentApi: HasSerializedChildState -) => { - const serializedState = parentApi.getSerializedStateForChild(uuid); - const lastSavedRuntimeState = serializedState - ? await factory.deserializeState(serializedState) - : ({} as RuntimeState); - - // If the parent provides runtime state for the child (usually as a state backup or cache), - // we merge it with the last saved runtime state. - const partialRuntimeState = apiHasRuntimeChildState(parentApi) - ? parentApi.getRuntimeStateForChild(uuid) ?? ({} as Partial) - : ({} as Partial); - - const initialRuntimeState = { ...lastSavedRuntimeState, ...partialRuntimeState }; - - const startStateDiffing = (comparators: StateComparators) => { - const subscription = new Subscription(); - const snapshotRuntimeState = () => { - const comparatorKeys = Object.keys(comparators) as Array; - return comparatorKeys.reduce((acc, key) => { - acc[key] = comparators[key][0].value as RuntimeState[typeof key]; - return acc; - }, {} as RuntimeState); - }; - - // the last saved state subject is always initialized with the deserialized state from the parent. - const lastSavedState$ = new BehaviorSubject(lastSavedRuntimeState); - if (apiHasSaveNotification(parentApi)) { - subscription.add( - // any time the parent saves, the current state becomes the last saved state... - parentApi.saveNotification$.subscribe(() => { - lastSavedState$.next(snapshotRuntimeState()); - }) - ); - } - - const comparatorSubjects: Array> = []; - const comparatorKeys: Array = []; - for (const key of Object.keys(comparators) as Array) { - const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject - comparatorSubjects.push(comparatorSubject as PublishingSubject); - comparatorKeys.push(key); - } - - const unsavedChanges = new BehaviorSubject | undefined>( - runComparators( - comparators, - comparatorKeys, - lastSavedState$.getValue() as RuntimeState, - getInitialValuesFromComparators(comparators, comparatorKeys) - ) - ); - - subscription.add( - combineLatest(comparatorSubjects) - .pipe( - debounceTime(100), - map((latestStates) => - comparatorKeys.reduce((acc, key, index) => { - acc[key] = latestStates[index] as RuntimeState[typeof key]; - return acc; - }, {} as Partial) - ), - combineLatestWith(lastSavedState$) - ) - .subscribe(([latest, last]) => { - unsavedChanges.next(runComparators(comparators, comparatorKeys, last, latest)); - }) - ); - return { - unsavedChanges, - resetUnsavedChanges: () => { - const lastSaved = lastSavedState$.getValue(); - for (const key of comparatorKeys) { - const setter = comparators[key][1]; // setter function is the 1st element of the tuple - setter(lastSaved?.[key] as RuntimeState[typeof key]); - } - }, - snapshotRuntimeState, - cleanup: () => subscription.unsubscribe(), - }; - }; - - return { initialState: initialRuntimeState, startStateDiffing }; -}; diff --git a/src/plugins/embeddable/public/react_embeddable_system/types.ts b/src/plugins/embeddable/public/react_embeddable_system/types.ts index e9a5a697f07e5b..8973cef9ce109d 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/types.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/types.ts @@ -106,7 +106,10 @@ export interface ReactEmbeddableFactory< * function. */ buildEmbeddable: ( - initialState: RuntimeState, + /** + * Initial runtime state. Composed from last saved state and previous sessions's unsaved changes + */ + initialRuntimeState: RuntimeState, /** * `buildApi` should be used by most embeddables that are used in dashboards, since it implements the unsaved * changes logic that the dashboard expects using the provided comparators @@ -118,6 +121,11 @@ export interface ReactEmbeddableFactory< uuid: string, parentApi: unknown | undefined, /** `setApi` should be used when the unsaved changes logic in `buildApi` is unnecessary */ - setApi: (api: SetReactEmbeddableApiRegistration) => Api + setApi: (api: SetReactEmbeddableApiRegistration) => Api, + /** + * Last saved runtime state. Different from initialRuntimeState in that it does not contain previous sessions's unsaved changes + * Compare with initialRuntimeState to flag unsaved changes on load + */ + lastSavedRuntimeState: RuntimeState ) => Promise<{ Component: React.FC<{}>; api: Api }>; }