diff --git a/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx b/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx index 5cd4b48eacfe5..b0591d06bf832 100644 --- a/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx +++ b/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx @@ -94,7 +94,7 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio diff --git a/examples/embeddable_examples/public/app/presentation_container_example/components/top_nav.tsx b/examples/embeddable_examples/public/app/presentation_container_example/components/top_nav.tsx index a7950f863b2db..791b4934c4f91 100644 --- a/examples/embeddable_examples/public/app/presentation_container_example/components/top_nav.tsx +++ b/examples/embeddable_examples/public/app/presentation_container_example/components/top_nav.tsx @@ -14,7 +14,7 @@ import type { PublishesUnsavedChanges } from '@kbn/presentation-publishing'; interface Props { onSave: () => Promise; - resetUnsavedChanges: PublishesUnsavedChanges['resetUnsavedChanges']; + onReset: () => void; hasUnsavedChanges$: PublishesUnsavedChanges['hasUnsavedChanges$']; } @@ -40,7 +40,7 @@ export function TopNav(props: Props) { Unsaved changes - + Reset diff --git a/examples/embeddable_examples/public/app/presentation_container_example/page_api.ts b/examples/embeddable_examples/public/app/presentation_container_example/page_api.ts index 1c0b2c8fe600e..7cf749edf1f79 100644 --- a/examples/embeddable_examples/public/app/presentation_container_example/page_api.ts +++ b/examples/embeddable_examples/public/app/presentation_container_example/page_api.ts @@ -20,7 +20,6 @@ import type { import { apiHasSerializableState, apiPublishesDataLoading, - apiPublishesUnsavedChanges, childrenUnsavedChanges$, combineCompatibleChildrenApis, } from '@kbn/presentation-publishing'; @@ -156,6 +155,31 @@ export function getPageApi() { unsavedChangesSessionStorage.save(serializePage()); }); + const applySerializedState = (state?: PageState) => { + const nextState = state ?? initialState; + timeRange$.next(nextState.timeRange); + const { layout: nextLayout, childState: nextChildState } = deserializePanels(nextState.panels); + layout$.next(nextLayout); + currentChildState = nextChildState; + let childrenModified = false; + const currentChildren = { ...children$.value }; + for (const uuid of Object.keys(currentChildren)) { + const existsInNextLayout = nextLayout.some(({ id }) => id === uuid); + if (existsInNextLayout) { + const child = currentChildren[uuid]; + if (apiHasSerializableState(child)) { + void child.applySerializedState(nextChildState[uuid]); + } + } else { + // if reset resulted in panel removal, we need to update the list of children + delete currentChildren[uuid]; + delete currentChildState[uuid]; + childrenModified = true; + } + } + if (childrenModified) children$.next(currentChildren); + }; + return { cleanUp: () => { childrenDataLoadingSubscripiton.unsubscribe(); @@ -176,6 +200,9 @@ export function getPageApi() { lastSavedStateSessionStorage.save(serializedPage); unsavedChangesSessionStorage.clear(); }, + onReset: () => { + applySerializedState(lastSavedState$.value); + }, layout$, setChild: (id: string, api: unknown) => { children$.next({ @@ -219,30 +246,8 @@ export function getPageApi() { lastSavedStateForChild$: (panelId: string) => lastSavedState$.pipe(map(() => getLastSavedStateForChild(panelId))), getLastSavedStateForChild, - resetUnsavedChanges: () => { - const lastSavedState = lastSavedState$.value; - timeRange$.next(lastSavedState.timeRange); - const { layout: lastSavedLayout, childState: lastSavedChildState } = deserializePanels( - lastSavedState.panels - ); - layout$.next(lastSavedLayout); - currentChildState = lastSavedChildState; - let childrenModified = false; - const currentChildren = { ...children$.value }; - for (const uuid of Object.keys(currentChildren)) { - const existsInLastSavedLayout = lastSavedLayout.some(({ id }) => id === uuid); - if (existsInLastSavedLayout) { - const child = currentChildren[uuid]; - if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges(); - } else { - // if reset resulted in panel removal, we need to update the list of children - delete currentChildren[uuid]; - delete currentChildState[uuid]; - childrenModified = true; - } - } - if (childrenModified) children$.next(currentChildren); - }, + serializeState: serializePage, + applySerializedState, timeRange$, hasUnsavedChanges$, } as PageApi, diff --git a/examples/embeddable_examples/public/app/state_management_example/state_management_example.tsx b/examples/embeddable_examples/public/app/state_management_example/state_management_example.tsx index 0a570e76e4a69..dbe643d789bc0 100644 --- a/examples/embeddable_examples/public/app/state_management_example/state_management_example.tsx +++ b/examples/embeddable_examples/public/app/state_management_example/state_management_example.tsx @@ -134,7 +134,9 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar { - bookApi?.resetUnsavedChanges(); + bookApi?.applySerializedState( + parentApi.getLastSavedStateForChild(BOOK_EMBEDDABLE_ID) as BookEmbeddableState + ); }} > Reset diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/field_list_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_embeddable.tsx index 787c31074491f..b94ac8c751fc3 100644 --- a/examples/embeddable_examples/public/react_embeddables/field_list/field_list_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_embeddable.tsx @@ -109,7 +109,7 @@ export const getFieldListFactory = ( }) ); - function serializeState() { + function serializeState(): FieldListSerializedState { const { dataViews: selectedDataViews, ...rest } = fieldListStateManager.getLatestState(); return { ...titleManager.getLatestState(), @@ -117,7 +117,7 @@ export const getFieldListFactory = ( }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState, diff --git a/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx b/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx index 8dbd437be4f9f..f7ad5275d7ba2 100644 --- a/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx +++ b/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx @@ -22,6 +22,7 @@ export const factory: EmbeddableFactory<{}, Api> = { buildEmbeddable: async ({ finalizeApi, parentApi }) => { const api = finalizeApi({ serializeState: () => ({}), + applySerializedState: () => undefined, }); return { diff --git a/src/platform/packages/private/kbn-controls-renderer/src/components/control_panel.test.tsx b/src/platform/packages/private/kbn-controls-renderer/src/components/control_panel.test.tsx index 9eeed1d181a1e..20bfdd61276f4 100644 --- a/src/platform/packages/private/kbn-controls-renderer/src/components/control_panel.test.tsx +++ b/src/platform/packages/private/kbn-controls-renderer/src/components/control_panel.test.tsx @@ -55,6 +55,7 @@ const mockOptionsListFactory: EmbeddableFactory<{ type: typeof OPTIONS_LIST_CONT serializeState: () => ({ type: OPTIONS_LIST_CONTROL, }), + applySerializedState: () => undefined, }); return { Component: () =>
Options list control
, diff --git a/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.test.tsx b/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.test.tsx index d70a8297905ae..fe6ca8f34b04a 100644 --- a/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.test.tsx +++ b/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.test.tsx @@ -14,7 +14,7 @@ import { type EmbeddableFactory, } from '@kbn/embeddable-plugin/public/react_embeddable_system'; import type { Filter } from '@kbn/es-query'; -import type { PublishesUnsavedChanges } from '@kbn/presentation-publishing'; +import type { HasSerializableState } from '@kbn/presentation-publishing'; import { act, render, waitFor } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; @@ -44,13 +44,13 @@ const getTestEmbeddableFactory = () => serializeState: () => ({ selection: initialState.selection, }), + applySerializedState: jest.fn(), }); return { Component: () =>
{initialState.selection}
, api: { ...api, hasUnsavedChanges$: new BehaviorSubject(false), - resetUnsavedChanges: jest.fn(), }, }; }, @@ -87,7 +87,7 @@ describe('control group renderer', () => { return { component, api: controlGroupApi! as ControlGroupRendererApi }; }; - test('calling `updateInput` forces each child to be reset', async () => { + test('calling `updateInput` applies the updated child state', async () => { const { api } = await mountControlGroupRenderer({ getCreationOptions: jest.fn().mockResolvedValue({ initialState: { @@ -99,9 +99,9 @@ describe('control group renderer', () => { }, }), }); - const resetSpy = jest.spyOn( - api.children$.getValue().test as PublishesUnsavedChanges, - 'resetUnsavedChanges' + const applySpy = jest.spyOn( + api.children$.getValue().test as HasSerializableState, + 'applySerializedState' ); act(() => api.updateInput({ @@ -114,7 +114,10 @@ describe('control group renderer', () => { }) ); - expect(resetSpy).toBeCalledTimes(1); + expect(applySpy).toBeCalledWith({ + type: 'testControl', + selection: 'test selection', + }); }); test('filter changes are dispatched to control parent API if they are different', async () => { diff --git a/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.tsx b/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.tsx index fb2079265160f..8bcb5e53e6003 100644 --- a/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.tsx +++ b/src/platform/packages/shared/controls/control-group-renderer/src/control_group_renderer.tsx @@ -22,7 +22,7 @@ import { ControlsRenderer, type ControlsRendererParentApi } from '@kbn/controls- import type { Filter, Query, TimeRange } from '@kbn/es-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { - apiPublishesUnsavedChanges, + apiHasSerializableState, useSearchApi, type EmbeddableApiContext, type ViewMode, @@ -172,17 +172,15 @@ export const ControlGroupRenderer = ({ getInput$: () => input$, getInput: () => input$.value, updateInput: (newInput: Partial) => { - /** Set the last saved state to the new input and then reset each child to this state */ - const newState = lastSavedState$Ref.current.getValue(); + const newState = { ...lastSavedState$Ref.current.getValue() }; Object.entries(newInput.initialChildControlState ?? {}).forEach(([id, control]) => { newState[id] = { ...lastSavedState$Ref.current.value[id], ...control, }; }); - lastSavedState$Ref.current.next(newState); - asyncForEach(Object.values(parentApi.children$.getValue()), async (child) => { - if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges(); + asyncForEach(Object.entries(parentApi.children$.getValue()), async ([id, child]) => { + if (apiHasSerializableState(child)) child.applySerializedState(newState[id]); }); }, }; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_controls.test.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_controls.test.ts index be68dc3a82094..5107fe18fa9da 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_controls.test.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_esql_controls.test.ts @@ -42,6 +42,7 @@ const createControlApi = ( uuid, type, serializeState: () => state, + applySerializedState: () => undefined, }); describe('getEsqlControls', () => { diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/children_unsaved_changes.test.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/children_unsaved_changes.test.ts index 0be5d2d3384e0..70eae36ebe305 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/children_unsaved_changes.test.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/children_unsaved_changes.test.ts @@ -15,12 +15,10 @@ describe('childrenUnsavedChanges$', () => { const child1Api = { uuid: 'child1', hasUnsavedChanges$: new BehaviorSubject(false), - resetUnsavedChanges: () => undefined, }; const child2Api = { uuid: 'child2', hasUnsavedChanges$: new BehaviorSubject(false), - resetUnsavedChanges: () => undefined, }; const children$ = new BehaviorSubject<{ [key: string]: unknown }>({}); const onFireMock = jest.fn(); @@ -118,7 +116,6 @@ describe('childrenUnsavedChanges$', () => { child3: { uuid: 'child3', hasUnsavedChanges$: new BehaviorSubject(true), - resetUnsavedChanges: () => undefined, }, }); diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/initialize_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/initialize_unsaved_changes.ts index 61c9a367a43e9..c7f65d2f6da10 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/initialize_unsaved_changes.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/containers/unsaved_changes/initialize_unsaved_changes.ts @@ -10,6 +10,7 @@ import type { Observable } from 'rxjs'; import type { MaybePromise } from '@kbn/utility-types'; import { combineLatestWith, debounceTime, map, of } from 'rxjs'; +import type { HasSerializableState } from '../../has_serializable_state'; import type { PublishesUnsavedChanges } from '../../publishes_unsaved_changes'; import { type StateComparators, areComparatorsEqual } from '../../../state_manager'; import { getTitle } from '../../titles/publishes_title'; @@ -33,13 +34,17 @@ export const initializeUnsavedChanges = ({ serializeState: () => StateType; getComparators: () => StateComparators; defaultState?: Partial; - onReset: (lastSavedPanelState?: StateType) => MaybePromise; + onReset?: (lastSavedPanelState?: StateType) => MaybePromise; checkRefEquality?: boolean; -}): PublishesUnsavedChanges => { +}): PublishesUnsavedChanges & Pick, 'applySerializedState'> => { + const applySerializedState = async (state?: StateType) => { + await onReset?.(state); + }; + if (!apiHasLastSavedChildState(parentApi)) { return { + applySerializedState, hasUnsavedChanges$: of(false), - resetUnsavedChanges: () => Promise.resolve(), }; } @@ -67,10 +72,5 @@ export const initializeUnsavedChanges = ({ }) ); - const resetUnsavedChanges = async () => { - const lastSavedState = parentApi.getLastSavedStateForChild(uuid); - await onReset(lastSavedState); - }; - - return { hasUnsavedChanges$, resetUnsavedChanges }; + return { applySerializedState, hasUnsavedChanges$ }; }; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts index e3eeac73e427c..eceeb9273d9d0 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts @@ -7,14 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { MaybePromise } from '@kbn/utility-types'; + export interface HasSerializableState { /** * Serializes all state into a format that can be saved into * some external store. The opposite of `deserialize` in the {@link ReactEmbeddableFactory} */ serializeState: () => SerializedState; + + /** + * Applies a serialized state snapshot owned by the parent container. + */ + applySerializedState: (state?: SerializedState) => MaybePromise; } export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => { - return Boolean((api as HasSerializableState)?.serializeState); + return Boolean( + (api as HasSerializableState)?.serializeState && + (api as HasSerializableState)?.applySerializedState + ); }; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts index 596872cbe927a..aae093f25c0ff 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts @@ -7,18 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { MaybePromise } from '@kbn/utility-types'; import type { Observable } from 'rxjs'; export interface PublishesUnsavedChanges { hasUnsavedChanges$: Observable; // Observable rather than publishingSubject because it should be derived. - resetUnsavedChanges: () => MaybePromise; } export const apiPublishesUnsavedChanges = (api: unknown): api is PublishesUnsavedChanges => { - return Boolean( - api && - (api as PublishesUnsavedChanges).hasUnsavedChanges$ && - (api as PublishesUnsavedChanges).resetUnsavedChanges - ); + return Boolean(api && (api as PublishesUnsavedChanges).hasUnsavedChanges$); }; diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx index ae2525c4d536c..aac114c8e9611 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -22,11 +22,7 @@ import { } from 'rxjs'; import { OPTIONS_LIST_CONTROL, DEFAULT_DSL_OPTIONS_LIST_STATE } from '@kbn/controls-constants'; -import type { - OptionsListSelection, - OptionsListControlState, - OptionsListDSLControlState, -} from '@kbn/controls-schemas'; +import type { OptionsListSelection, OptionsListControlState } from '@kbn/controls-schemas'; import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { apiHasPinnedPanels, @@ -239,7 +235,7 @@ export const getOptionsListControlFactory = (): EmbeddableFactory< } ); - function serializeState(): OptionsListDSLControlState { + function serializeState(): OptionsListControlState { return { ...dataControlManager.getLatestState(), ...selectionsManager.getLatestState(), @@ -250,7 +246,7 @@ export const getOptionsListControlFactory = (): EmbeddableFactory< }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState, diff --git a/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx index 49d49f1edacb6..86230ab93c7b5 100644 --- a/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.test.tsx @@ -269,7 +269,7 @@ describe('TimeSliderControlApi', () => { expect(new Date(api.appliedTimeslice$.value![1]).toISOString()).toEqual( '2024-06-09T18:00:00.000Z' ); - await api.resetUnsavedChanges(); + await api.applySerializedState(controlState); await new Promise((resolve) => setTimeout(resolve, 0)); expect(new Date(api.appliedTimeslice$.value![0]).toISOString()).toEqual( diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_actions/clone_panel_action.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_actions/clone_panel_action.test.tsx index 93b3aa8e59bff..ecfdf7859dd38 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_actions/clone_panel_action.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_actions/clone_panel_action.test.tsx @@ -24,6 +24,7 @@ describe('Clone panel action', () => { uuid: 'superId', viewMode$: new BehaviorSubject('edit'), serializeState: () => ({}), + applySerializedState: () => undefined, parentApi: { duplicatePanel: jest.fn(), }, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.test.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.test.ts index 86e465f14abc9..9661f5769df36 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.test.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.test.ts @@ -23,6 +23,7 @@ import type { import { initializeTitleManager } from '@kbn/presentation-publishing'; import type { DashboardState } from '../../../common'; +import { getSampleDashboardState } from '../../mocks'; import type { initializeTrackPanel } from '../track_panel'; import type { initializeViewModeManager } from '../view_mode_manager'; import { initializeLayoutManager } from './layout_manager'; @@ -84,6 +85,7 @@ describe('layout manager', () => { phase$: {} as unknown as PublishingSubject, ...titleManager.api, serializeState: () => titleManager.getLatestState(), + applySerializedState: jest.fn(), }; const section1 = { @@ -108,6 +110,55 @@ describe('layout manager', () => { expect(layoutManager.api.children$.getValue()[PANEL_ONE_ID]).toBe(panel1Api); }); + test('should apply incoming serialized child state during reset when supported', async () => { + const layoutManager = initializeLayoutManager( + viewModeManagerMock, + undefined, + [panel1], + [], + trackPanelMock + ); + const applySerializedState = jest.fn().mockResolvedValue(undefined); + + layoutManager.api.registerChildApi({ + ...panel1Api, + applySerializedState, + hasUnsavedChanges$: new BehaviorSubject(false), + } as DefaultEmbeddableApi); + + layoutManager.internalApi.reset( + getSampleDashboardState({ + panels: [{ ...panel1, config: { title: 'Updated title' } }], + pinned_panels: [], + }) + ); + + expect(applySerializedState).toHaveBeenCalledWith({ title: 'Updated title' }); + }); + + test('should ignore child state application when child does not support it', async () => { + const layoutManager = initializeLayoutManager( + viewModeManagerMock, + undefined, + [panel1], + [], + trackPanelMock + ); + layoutManager.api.registerChildApi({ + ...panel1Api, + hasUnsavedChanges$: new BehaviorSubject(false), + } as DefaultEmbeddableApi); + + layoutManager.internalApi.reset( + getSampleDashboardState({ + panels: [{ ...panel1, config: { title: 'Updated title' } }], + pinned_panels: [], + }) + ); + + expect(layoutManager.api.children$.getValue()[PANEL_ONE_ID]).toBeDefined(); + }); + test('should append incoming embeddables to existing panels', () => { const incomingEmbeddables = [ { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.ts index 036f53d7cd7bb..79f4cb37112e9 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/layout_manager/layout_manager.ts @@ -37,7 +37,6 @@ import { apiHasLibraryTransforms, apiHasSerializableState, apiPublishesTitle, - apiPublishesUnsavedChanges, getTitle, logStateDiff, shouldLogStateDiff, @@ -159,7 +158,10 @@ export function initializeLayoutManager( for (const uuid of Object.keys(currentChildren)) { if (layoutToApply.panels[uuid] || layoutToApply.pinnedPanels[uuid]) { const child = currentChildren[uuid]; - if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges(); + const nextChildState = childStateToApply[uuid]; + if (apiHasSerializableState(child)) { + child.applySerializedState(nextChildState); + } } else { // if reset resulted in panel removal, we need to update the list of children delete currentChildren[uuid]; diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx index 3032fa6df0c68..2db894df7838a 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.test.tsx @@ -21,6 +21,7 @@ import { act, render, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { EmbeddableApiRegistration } from '@kbn/embeddable-plugin/public/react_embeddable_system/types'; import { createDataViewDataSource } from '../../common/data_sources'; import type { SearchEmbeddableState } from '../../common/embeddable/types'; import { discoverServiceMock } from '../__mocks__/services'; @@ -126,9 +127,10 @@ describe('saved search embeddable', () => { }; const finalizeApiMock = ( - api: Omit + api: EmbeddableApiRegistration ) => ({ ...api, + applySerializedState: () => undefined, uuid, type: factory.type, parentApi: mockedDashboardApi, @@ -146,9 +148,10 @@ describe('saved search embeddable', () => { }; const finalizeEditableApiMock = ( - api: Omit + api: EmbeddableApiRegistration ) => ({ ...api, + applySerializedState: () => undefined, uuid, type: factory.type, parentApi: mockedEditableDashboardApi, diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx index 46073329983f4..580bb4a7d5c97 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx @@ -26,6 +26,7 @@ const testEmbeddableFactory: EmbeddableFactory<{ name: string; bork: string }> = name: initialState.name, bork: initialState.bork, }), + applySerializedState: jest.fn(), }); return { Component: () => ( @@ -157,6 +158,7 @@ describe('embeddable renderer', () => { phase$: expect.any(Object), hasLockedHoverActions$: expect.any(Object), lockHoverActions: expect.any(Function), + applySerializedState: expect.any(Function), isCustomizable: true, isDuplicable: true, isExpandable: true, @@ -332,6 +334,7 @@ describe('reactEmbeddable phase events', () => { name: initialState.name, bork: initialState.bork, }), + applySerializedState: jest.fn(), dataLoading$, }); return { diff --git a/x-pack/platform/plugins/private/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_factory.tsx b/x-pack/platform/plugins/private/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_factory.tsx index 6f4f0c90f9f6b..7343db8a86b1e 100644 --- a/x-pack/platform/plugins/private/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_factory.tsx +++ b/x-pack/platform/plugins/private/data_visualizer/public/application/index_data_visualizer/embeddables/field_stats/field_stats_factory.tsx @@ -199,7 +199,7 @@ export const getFieldStatsChartEmbeddableFactory = ( }; }; - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState, diff --git a/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx b/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx index d1e25845e9e1c..bbb0cc085cd52 100644 --- a/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx +++ b/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.test.tsx @@ -8,7 +8,7 @@ // Write a test that verifies that the `AlertsTableEmbeddable` component renders the `AlertsTable` component with the correct props. import React from 'react'; -import { render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import type { EmbeddableAlertsTablePublicStartDependencies } from '../types'; import { coreMock } from '@kbn/core/public/mocks'; import { getMockPresentationContainer } from '@kbn/presentation-publishing/interfaces/containers/mocks'; @@ -67,6 +67,10 @@ describe('getEmbeddableAlertsTableFactory', () => { parentApi: {} as any, }; + beforeEach(() => { + mockEmbeddableAlertsTable.mockClear(); + }); + it('should render AlertsTable with the correct props', async () => { const { Component, api } = await factory.buildEmbeddable(embeddableParams); @@ -92,4 +96,48 @@ describe('getEmbeddableAlertsTableFactory', () => { expect(api.isEditingEnabled()).toBeFalsy(); }); + + it('should restore the saved query after a user edits the panel config and resets changes', async () => { + const { Component, api } = await factory.buildEmbeddable(embeddableParams); + const updatedQuery = { + type: 'alertsFilters' as const, + filters: [{ filter: { field: 'kibana.alert.rule.name', value: 'updated' } }], + }; + + render(); + + // simulate the user applying a new query to the panel + await act(async () => { + await api.applySerializedState({ + ...embeddableParams.initialState, + tableConfig: { + solution: 'observability', + query: updatedQuery, + }, + }); + }); + + await waitFor(() => { + expect(mockEmbeddableAlertsTable).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: updatedQuery, + }), + {} + ); + }); + + // simulate the user resetting the changes + await act(async () => { + await api.applySerializedState(embeddableParams.initialState); + }); + + await waitFor(() => { + expect(mockEmbeddableAlertsTable).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: embeddableParams.initialState.tableConfig.query, + }), + {} + ); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.tsx b/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.tsx index 44d31dbea3cd5..390ce1e1a2e70 100644 --- a/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.tsx +++ b/x-pack/platform/plugins/shared/embeddable_alerts_table/public/factories/alerts_table_embeddable_factory.tsx @@ -51,13 +51,13 @@ export const getAlertsTableEmbeddableFactory = ( const initialTableConfig = initialState.tableConfig; const tableConfig$ = new BehaviorSubject(initialTableConfig); - const serializeState = () => ({ + const serializeState = (): EmbeddableAlertsTableSerializedState => ({ ...titleManager.getLatestState(), ...timeRangeManager.getLatestState(), tableConfig: tableConfig$.getValue(), }); - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, anyStateChange$: merge( @@ -74,6 +74,9 @@ export const getAlertsTableEmbeddableFactory = ( onReset: (lastSaved) => { titleManager.reinitializeState(lastSaved); timeRangeManager.reinitializeState(lastSaved); + if (lastSaved?.tableConfig) { + tableConfig$.next(lastSaved.tableConfig); + } }, }); diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts index 8de213dbdfa80..7b8fca9908d2c 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts @@ -31,7 +31,7 @@ export function initializeIntegrations(getLatestState: GetStateType): { | 'updateDataLoading' | 'getTriggerCompatibleActions' > & - HasSerializableState & + Pick, 'serializeState'> & LegacyLensStateApi; } { return { diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx b/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx index 11017aba69cfa..92164fba64194 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx @@ -109,7 +109,7 @@ function getDefaultLensApiMock() { rendered$: new BehaviorSubject(false), searchSessionId$: new BehaviorSubject(undefined), hasUnsavedChanges$: new BehaviorSubject(false), - resetUnsavedChanges: jest.fn(), + applySerializedState: jest.fn(), projectRoutingOverrides$: new BehaviorSubject(undefined), }; return LensApiMock; diff --git a/x-pack/platform/plugins/shared/ml/public/ui_actions/get_embeddable_time_range.ts b/x-pack/platform/plugins/shared/ml/public/ui_actions/get_embeddable_time_range.ts index 422ff90d99047..08ab2438ead66 100644 --- a/x-pack/platform/plugins/shared/ml/public/ui_actions/get_embeddable_time_range.ts +++ b/x-pack/platform/plugins/shared/ml/public/ui_actions/get_embeddable_time_range.ts @@ -9,7 +9,9 @@ import type { TimeRange } from '@kbn/es-query'; import { apiHasParentApi, apiPublishesTimeRange } from '@kbn/presentation-publishing'; import type { MlEmbeddableBaseApi } from '../embeddables'; -export const getEmbeddableTimeRange = (embeddable: MlEmbeddableBaseApi): TimeRange | undefined => { +export const getEmbeddableTimeRange = ( + embeddable: MlEmbeddableBaseApi +): TimeRange | undefined => { let timeRange = embeddable.timeRange$?.getValue(); if (!timeRange && apiHasParentApi(embeddable) && apiPublishesTimeRange(embeddable.parentApi)) { diff --git a/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_failed_transactions_chart/react_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_failed_transactions_chart/react_embeddable_factory.tsx index 94825b9143035..fb81ecb71793e 100644 --- a/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_failed_transactions_chart/react_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_failed_transactions_chart/react_embeddable_factory.tsx @@ -40,7 +40,7 @@ export const getApmAlertingFailedTransactionsChartEmbeddableFactory = (deps: Emb const kuery$ = new BehaviorSubject(state.kuery); const filters$ = new BehaviorSubject(state.filters); - function serializeState() { + function serializeState(): EmbeddableApmAlertingVizProps { return { ...titleManager.getLatestState(), serviceName: serviceName$.getValue(), @@ -56,7 +56,7 @@ export const getApmAlertingFailedTransactionsChartEmbeddableFactory = (deps: Emb }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ parentApi, uuid, serializeState, diff --git a/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_latency_chart/react_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_latency_chart/react_embeddable_factory.tsx index f28f6a2671c93..f1a4381ad2567 100644 --- a/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_latency_chart/react_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_latency_chart/react_embeddable_factory.tsx @@ -43,7 +43,7 @@ export const getApmAlertingLatencyChartEmbeddableFactory = (deps: EmbeddableDeps const kuery$ = new BehaviorSubject(state.kuery); const filters$ = new BehaviorSubject(state.filters); - function serializeState() { + function serializeState(): EmbeddableApmAlertingLatencyVizProps { return { ...titleManager.getLatestState(), serviceName: serviceName$.getValue(), @@ -60,7 +60,7 @@ export const getApmAlertingLatencyChartEmbeddableFactory = (deps: EmbeddableDeps }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ parentApi, uuid, serializeState, diff --git a/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_throughput_chart/react_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_throughput_chart/react_embeddable_factory.tsx index ff28a019fd253..7abdf0eb97934 100644 --- a/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_throughput_chart/react_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/embeddable/alerting/alerting_throughput_chart/react_embeddable_factory.tsx @@ -39,7 +39,7 @@ export const getApmAlertingThroughputChartEmbeddableFactory = (deps: EmbeddableD const kuery$ = new BehaviorSubject(state.kuery); const filters$ = new BehaviorSubject(state.filters); - function serializeState() { + function serializeState(): EmbeddableApmAlertingVizProps { return { ...titleManager.getLatestState(), serviceName: serviceName$.getValue(), @@ -55,7 +55,7 @@ export const getApmAlertingThroughputChartEmbeddableFactory = (deps: EmbeddableD }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ parentApi, uuid, serializeState, diff --git a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/alerts/slo_alerts_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/alerts/slo_alerts_embeddable_factory.tsx index fc59e828aaeb8..c279ab887bc58 100644 --- a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/alerts/slo_alerts_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/alerts/slo_alerts_embeddable_factory.tsx @@ -73,14 +73,14 @@ export function getAlertsEmbeddableFactory({ const defaultTitle$ = new BehaviorSubject(getAlertsPanelTitle()); const reload$ = new Subject(); - function serializeState() { + function serializeState(): SloAlertsEmbeddableState { return { ...titleManager.getLatestState(), ...sloAlertsStateManager.getLatestState(), }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState, diff --git a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/burn_rate/burn_rate_react_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/burn_rate/burn_rate_react_embeddable_factory.tsx index 4a9e502c11737..e4f12b64aa401 100644 --- a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/burn_rate/burn_rate_react_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/burn_rate/burn_rate_react_embeddable_factory.tsx @@ -41,7 +41,13 @@ export const getBurnRateEmbeddableFactory = ({ }) => { const factory: EmbeddableFactory = { type: SLO_BURN_RATE_EMBEDDABLE_ID, - buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => { + buildEmbeddable: async ({ + initialState, + finalizeApi, + uuid, + parentApi, + initializeDrilldownsManager, + }) => { const deps = { ...coreStart, ...pluginsStart }; const titleManager = initializeTitleManager(initialState); const defaultTitle$ = new BehaviorSubject(getTitle()); @@ -50,22 +56,29 @@ export const getBurnRateEmbeddableFactory = ({ slo_instance_id: '*', duration: '', }); + const drilldownsManager = await initializeDrilldownsManager(uuid, initialState); const reload$ = new Subject(); - function serializeState() { + function serializeState(): BurnRateEmbeddableState { return { ...titleManager.getLatestState(), ...sloBurnRateManager.getLatestState(), + ...drilldownsManager.getLatestState(), }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, - anyStateChange$: merge(titleManager.anyStateChange$, sloBurnRateManager.anyStateChange$), + anyStateChange$: merge( + titleManager.anyStateChange$, + sloBurnRateManager.anyStateChange$, + drilldownsManager.anyStateChange$ + ), serializeState, getComparators: () => ({ ...titleComparators, + ...drilldownsManager.comparators, slo_id: 'referenceEquality', slo_instance_id: 'referenceEquality', duration: 'referenceEquality', @@ -73,12 +86,14 @@ export const getBurnRateEmbeddableFactory = ({ onReset: (lastSaved) => { sloBurnRateManager.reinitializeState(lastSaved); titleManager.reinitializeState(lastSaved); + drilldownsManager.reinitializeState(lastSaved ?? {}); }, }); const api = finalizeApi({ ...titleManager.api, ...unsavedChangesApi, + ...drilldownsManager.api, defaultTitle$, serializeState, }); @@ -101,6 +116,7 @@ export const getBurnRateEmbeddableFactory = ({ useEffect(() => { return () => { fetchSubscription.unsubscribe(); + drilldownsManager.cleanup(); }; }, []); diff --git a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/error_budget/error_budget_react_embeddable_factory.tsx b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/error_budget/error_budget_react_embeddable_factory.tsx index 0e15c553a2afb..48b74e090eafe 100644 --- a/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/error_budget/error_budget_react_embeddable_factory.tsx +++ b/x-pack/solutions/observability/plugins/slo/public/embeddable/slo/error_budget/error_budget_react_embeddable_factory.tsx @@ -63,7 +63,7 @@ export const getErrorBudgetEmbeddableFactory = ({ }); const reload$ = new Subject(); - function serializeState() { + function serializeState(): ErrorBudgetEmbeddableState { return { ...titleManager.getLatestState(), ...drilldownsManager.getLatestState(), @@ -71,7 +71,7 @@ export const getErrorBudgetEmbeddableFactory = ({ }; } - const unsavedChangesApi = initializeUnsavedChanges({ + const unsavedChangesApi = initializeUnsavedChanges({ uuid, parentApi, serializeState,