diff --git a/examples/controls_example/public/app/control_group_renderer_examples/edit_example.tsx b/examples/controls_example/public/app/control_group_renderer_examples/edit_example.tsx index 115a6fc014e36..e11dfef1373f4 100644 --- a/examples/controls_example/public/app/control_group_renderer_examples/edit_example.tsx +++ b/examples/controls_example/public/app/control_group_renderer_examples/edit_example.tsx @@ -68,7 +68,7 @@ export const EditExample = () => { localStorage.setItem( INPUT_KEY, JSON.stringify({ - ...controlGroupAPI.snapshotRuntimeState(), + ...controlGroupAPI.getInput(), disabledActions: controlGroupAPI.disabledActionIds$.getValue(), // not part of runtime }) ); diff --git a/examples/controls_example/public/app/react_control_example/react_control_example.tsx b/examples/controls_example/public/app/react_control_example/react_control_example.tsx index 5ec32426c829f..f4f4825eaf44b 100644 --- a/examples/controls_example/public/app/react_control_example/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example/react_control_example.tsx @@ -8,7 +8,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import { BehaviorSubject, combineLatest, Subject } from 'rxjs'; +import { BehaviorSubject, combineLatest, of, Subject } from 'rxjs'; import useMountedState from 'react-use/lib/useMountedState'; import { EuiBadge, @@ -21,36 +21,25 @@ import { EuiFlexItem, EuiSpacer, EuiSuperDatePicker, - EuiToolTip, OnTimeChangeProps, } from '@elastic/eui'; -import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; +import { CONTROL_GROUP_TYPE, ControlGroupSerializedState } from '@kbn/controls-plugin/common'; import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { CoreStart } from '@kbn/core/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { apiPublishesDataLoading, - HasUniqueId, PublishesDataLoading, + SerializedPanelState, useBatchedPublishingSubjects, ViewMode, } from '@kbn/presentation-publishing'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import { - clearControlGroupSerializedState, - getControlGroupSerializedState, - setControlGroupSerializedState, - WEB_LOGS_DATA_VIEW_ID, -} from './serialized_control_group_state'; -import { - clearControlGroupRuntimeState, - getControlGroupRuntimeState, - setControlGroupRuntimeState, -} from './runtime_control_group_state'; +import { savedStateManager, unsavedStateManager, WEB_LOGS_DATA_VIEW_ID } from './session_storage'; const toggleViewButtons = [ { @@ -65,6 +54,8 @@ const toggleViewButtons = [ }, ]; +const CONTROL_GROUP_EMBEDDABLE_ID = 'CONTROL_GROUP_EMBEDDABLE_ID'; + export const ReactControlExample = ({ core, dataViews: dataViewsService, @@ -97,9 +88,6 @@ export const ReactControlExample = ({ const viewMode$ = useMemo(() => { return new BehaviorSubject('edit'); }, []); - const saveNotification$ = useMemo(() => { - return new Subject(); - }, []); const reload$ = useMemo(() => { return new Subject(); }, []); @@ -114,9 +102,11 @@ export const ReactControlExample = ({ const [dataViewNotFound, setDataViewNotFound] = useState(false); const [isResetting, setIsResetting] = useState(false); - const dashboardApi = useMemo(() => { + const parentApi = useMemo(() => { const query$ = new BehaviorSubject(undefined); - const children$ = new BehaviorSubject<{ [key: string]: unknown }>({}); + const unsavedSavedControlGroupState = unsavedStateManager.get(); + const lastSavedControlGroupState = savedStateManager.get(); + const lastSavedControlGroupState$ = new BehaviorSubject(lastSavedControlGroupState); return { dataLoading$, @@ -126,29 +116,44 @@ export const ReactControlExample = ({ query$, timeRange$, timeslice$, - children$, - publishFilters: (newFilters: Filter[] | undefined) => filters$.next(newFilters), - setChild: (child: HasUniqueId) => - children$.next({ ...children$.getValue(), [child.uuid]: child }), - removePanel: () => {}, - replacePanel: () => { - return Promise.resolve(''); + reload$, + getSerializedStateForChild: (childId: string) => { + if (childId === CONTROL_GROUP_EMBEDDABLE_ID) { + return unsavedSavedControlGroupState + ? unsavedSavedControlGroupState + : lastSavedControlGroupState; + } + + return { + rawState: {}, + references: [], + }; }, - getPanelCount: () => { - return 2; + lastSavedStateForChild$: (childId: string) => { + return childId === CONTROL_GROUP_EMBEDDABLE_ID + ? lastSavedControlGroupState$ + : of(undefined); }, - addNewPanel: () => { - return Promise.resolve(undefined); + getLastSavedStateForChild: (childId: string) => { + return childId === CONTROL_GROUP_EMBEDDABLE_ID + ? lastSavedControlGroupState$.value + : { + rawState: {}, + references: [], + }; + }, + setLastSavedControlGroupState: ( + savedState: SerializedPanelState + ) => { + lastSavedControlGroupState$.next(savedState); }, - saveNotification$, - reload$, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const subscription = combineCompatibleChildrenApis( - dashboardApi, + parentApi, 'dataLoading$', apiPublishesDataLoading, undefined, @@ -163,7 +168,7 @@ export const ReactControlExample = ({ return () => { subscription.unsubscribe(); }; - }, [dashboardApi, dataLoading$]); + }, [parentApi, dataLoading$]); useEffect(() => { let ignore = false; @@ -244,25 +249,20 @@ export const ReactControlExample = ({ }; }, [controlGroupFilters$, filters$, unifiedSearchFilters$]); - const [unsavedChanges, setUnsavedChanges] = useState(undefined); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); useEffect(() => { if (!controlGroupApi) { return; } - const subscription = controlGroupApi.unsavedChanges$.subscribe((nextUnsavedChanges) => { - if (!nextUnsavedChanges) { - clearControlGroupRuntimeState(); - setUnsavedChanges(undefined); + const subscription = controlGroupApi.hasUnsavedChanges$.subscribe((nextHasUnsavedChanges) => { + if (!nextHasUnsavedChanges) { + unsavedStateManager.clear(); + setHasUnsavedChanges(false); 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, ' ')); + unsavedStateManager.set(controlGroupApi.serializeState()); + setHasUnsavedChanges(true); }); return () => { @@ -283,8 +283,8 @@ export const ReactControlExample = ({ color="accent" size="s" onClick={() => { - clearControlGroupSerializedState(); - clearControlGroupRuntimeState(); + savedStateManager.clear(); + unsavedStateManager.clear(); window.location.reload(); }} > @@ -346,12 +346,10 @@ export const ReactControlExample = ({ }} /> - {unsavedChanges !== undefined && viewMode === 'edit' && ( + {hasUnsavedChanges && viewMode === 'edit' && ( <> - {unsavedChanges}}> - Unsaved changes - + Unsaved changes @@ -371,11 +369,15 @@ export const ReactControlExample = ({ { - if (controlGroupApi) { - saveNotification$.next(); - setControlGroupSerializedState(controlGroupApi.serializeState()); + if (!controlGroupApi) { + return; } + const savedState = controlGroupApi.serializeState(); + parentApi.setLastSavedControlGroupState(savedState); + savedStateManager.set(savedState); + unsavedStateManager.clear(); }} > Save @@ -400,37 +402,23 @@ export const ReactControlExample = ({ }} /> {hasControls && } - { - dashboardApi?.setChild(api); setControlGroupApi(api as ControlGroupApi); }} hidePanelChrome={true} - type={CONTROL_GROUP_TYPE} - getParentApi={() => ({ - ...dashboardApi, - getSerializedStateForChild: getControlGroupSerializedState, - getRuntimeStateForChild: getControlGroupRuntimeState, - })} + getParentApi={() => parentApi} panelProps={{ hideLoader: true }} - key={`control_group`} /> {isControlGroupInitialized && (
- ({ - ...dashboardApi, - getSerializedStateForChild: () => ({ - rawState: {}, - references: [], - }), - })} + parentApi} hidePanelChrome={false} - onApiAvailable={(api) => { - dashboardApi?.setChild(api); - }} />
)} 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 deleted file mode 100644 index c5975a65842ba..0000000000000 --- a/examples/controls_example/public/app/react_control_example/runtime_control_group_state.ts +++ /dev/null @@ -1,26 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { ControlGroupRuntimeState } from '@kbn/controls-plugin/common'; - -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/session_storage.ts similarity index 51% rename from examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts rename to examples/controls_example/public/app/react_control_example/session_storage.ts index c9c7609d6ad1d..2484649fe635e 100644 --- a/examples/controls_example/public/app/react_control_example/serialized_control_group_state.ts +++ b/examples/controls_example/public/app/react_control_example/session_storage.ts @@ -15,80 +15,84 @@ import { TIME_SLIDER_CONTROL, } from '@kbn/controls-plugin/common'; -const SERIALIZED_STATE_SESSION_STORAGE_KEY = - 'kibana.examples.controls.reactControlExample.controlGroupSerializedState'; +const SAVED_STATE_SESSION_STORAGE_KEY = + 'kibana.examples.controls.reactControlExample.controlGroupSavedState'; +const UNSAVED_STATE_SESSION_STORAGE_KEY = + 'kibana.examples.controls.reactControlExample.controlGroupUnsavedSavedState'; 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 const savedStateManager = { + clear: () => sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY), + set: (serializedState: SerializedPanelState) => + sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)), + get: () => { + const serializedStateJSON = sessionStorage.getItem(SAVED_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)); -} +export const unsavedStateManager = { + clear: () => sessionStorage.removeItem(UNSAVED_STATE_SESSION_STORAGE_KEY), + set: (serializedState: SerializedPanelState) => + sessionStorage.setItem(UNSAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)), + get: () => { + const serializedStateJSON = sessionStorage.getItem(UNSAVED_STATE_SESSION_STORAGE_KEY); + return serializedStateJSON ? JSON.parse(serializedStateJSON) : undefined; + }, +}; const optionsListId = 'optionsList1'; -const searchControlId = 'searchControl1'; const rangeSliderControlId = 'rangeSliderControl1'; const timesliderControlId = 'timesliderControl1'; -const controlGroupPanels = { - [rangeSliderControlId]: { +const controls = [ + { + id: rangeSliderControlId, type: RANGE_SLIDER_CONTROL, order: 1, grow: true, width: 'medium', - explicitInput: { - id: rangeSliderControlId, + controlConfig: { fieldName: 'bytes', title: 'Bytes', - grow: true, - width: 'medium', enhancements: {}, }, }, - [timesliderControlId]: { + { + id: timesliderControlId, type: TIME_SLIDER_CONTROL, order: 4, grow: true, width: 'medium', - explicitInput: { - id: timesliderControlId, - title: 'Time slider', - enhancements: {}, - }, + controlConfig: {}, }, - [optionsListId]: { + { + id: optionsListId, type: OPTIONS_LIST_CONTROL, order: 2, grow: true, width: 'medium', - explicitInput: { - id: searchControlId, + controlConfig: { fieldName: 'agent.keyword', title: 'Agent', - grow: true, - width: 'medium', - enhancements: {}, }, }, -}; +]; const initialSerializedControlGroupState = { rawState: { - controlStyle: 'oneLine', + labelPosition: 'oneLine', chainingSystem: 'HIERARCHICAL', - showApplySelections: false, - panelsJSON: JSON.stringify(controlGroupPanels), - ignoreParentSettingsJSON: - '{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}', - } as object, + autoApplySelections: true, + controls, + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + } as ControlGroupSerializedState, references: [ { name: `controlGroup_${rangeSliderControlId}:rangeSliderDataView`, 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 2b6cb42c71dd1..62255ba6ca719 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 @@ -17,7 +17,7 @@ import { EuiSuperDatePicker, } from '@elastic/eui'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { getPageApi } from '../page_api'; import { AddButton } from './add_button'; @@ -36,9 +36,9 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio }; }, [cleanUp]); - const [dataLoading, panels, timeRange] = useBatchedPublishingSubjects( + const [dataLoading, layout, timeRange] = useBatchedPublishingSubjects( pageApi.dataLoading$, - componentApi.panels$, + componentApi.layout$, pageApi.timeRange$ ); @@ -53,7 +53,7 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio

New embeddable state is provided to the page by calling{' '} pageApi.addNewPanel. The page provides new embeddable state to the - embeddable with pageApi.getRuntimeStateForChild. + embeddable with pageApi.getSerializedStateForChild.

This example uses session storage to persist saved state and unsaved changes while a @@ -95,17 +95,17 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio - {panels.map(({ id, type }) => { + {layout.map(({ id, type }) => { return (

- pageApi} 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 7e288491610e2..e2c4b16e92380 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,8 +14,8 @@ import { PublishesUnsavedChanges } from '@kbn/presentation-publishing'; interface Props { onSave: () => Promise; - resetUnsavedChanges: () => void; - unsavedChanges$: PublishesUnsavedChanges['unsavedChanges$']; + resetUnsavedChanges: PublishesUnsavedChanges['resetUnsavedChanges']; + hasUnsavedChanges$: PublishesUnsavedChanges['hasUnsavedChanges$']; } export function TopNav(props: Props) { @@ -23,14 +23,14 @@ export function TopNav(props: Props) { const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); useEffect(() => { - const subscription = props.unsavedChanges$.subscribe((unsavedChanges) => { - setHasUnsavedChanges(unsavedChanges !== undefined); + const subscription = props.hasUnsavedChanges$.subscribe((nextHasUnsavedChanges) => { + setHasUnsavedChanges(nextHasUnsavedChanges); }); return () => { subscription.unsubscribe(); }; - }, [props.unsavedChanges$]); + }, [props.hasUnsavedChanges$]); return ( 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 ce0a9f20ceba7..dcd093f7e9708 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 @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject, Subject, combineLatest, map, merge } from 'rxjs'; +import { BehaviorSubject, Subject, combineLatest, map, merge, tap } from 'rxjs'; import { v4 as generateId } from 'uuid'; import { TimeRange } from '@kbn/es-query'; import { @@ -19,54 +19,72 @@ import { isEqual, omit } from 'lodash'; import { PublishesDataLoading, PublishingSubject, + SerializedPanelState, ViewMode, apiHasSerializableState, apiPublishesDataLoading, apiPublishesUnsavedChanges, } from '@kbn/presentation-publishing'; -import { DEFAULT_STATE, lastSavedStateSessionStorage } from './session_storage/last_saved_state'; +import { lastSavedStateSessionStorage } from './session_storage/last_saved_state'; import { unsavedChangesSessionStorage } from './session_storage/unsaved_changes'; -import { LastSavedState, PageApi, UnsavedChanges } from './types'; +import { PageApi, PageState } from './types'; -export function getPageApi() { - const initialUnsavedChanges = unsavedChangesSessionStorage.load(); - const initialSavedState = lastSavedStateSessionStorage.load(); - let newPanels: Record = {}; - const lastSavedState$ = new BehaviorSubject< - LastSavedState & { panels: Array<{ id: string; type: string }> } - >({ - ...initialSavedState, - panels: initialSavedState.panelsState.map(({ id, type }) => { - return { id, type }; - }), +function deserializePanels(panels: PageState['panels']) { + const layout: Array<{ id: string; type: string }> = []; + const childState: { [uuid: string]: SerializedPanelState | undefined } = {}; + panels.forEach(({ id, type, serializedState }) => { + layout.push({ id, type }); + childState[id] = serializedState; }); + return { layout, childState }; +} + +export function getPageApi() { + const initialUnsavedState = unsavedChangesSessionStorage.load(); + const initialState = lastSavedStateSessionStorage.load(); + const lastSavedState$ = new BehaviorSubject(initialState); const children$ = new BehaviorSubject<{ [key: string]: unknown }>({}); - const dataLoading$ = new BehaviorSubject(false); - const panels$ = new BehaviorSubject>( - initialUnsavedChanges.panels ?? lastSavedState$.value.panels - ); - const timeRange$ = new BehaviorSubject( - initialUnsavedChanges.timeRange ?? initialSavedState.timeRange + const { layout: initialLayout, childState: initialChildState } = deserializePanels( + initialUnsavedState?.panels ?? lastSavedState$.value.panels ); + const layout$ = new BehaviorSubject>(initialLayout); + let currentChildState = initialChildState; // childState is the source of truth for the state of each panel. + const dataLoading$ = new BehaviorSubject(false); + const timeRange$ = new BehaviorSubject( + initialUnsavedState?.timeRange ?? initialState.timeRange + ); const reload$ = new Subject(); - const saveNotification$ = new Subject(); + function serializePage() { + return { + timeRange: timeRange$.value, + panels: layout$.value.map((layout) => ({ + ...layout, + serializedState: currentChildState[layout.id], + })), + }; + } + + function getLastSavedStateForChild(childId: string) { + const panel = lastSavedState$.value.panels.find(({ id }) => id === childId); + return panel?.serializedState; + } - function untilChildLoaded(childId: string): unknown | Promise { + async function getChildApi(childId: string): Promise { if (children$.value[childId]) { return children$.value[childId]; } return new Promise((resolve) => { - const subscription = merge(children$, panels$).subscribe(() => { + const subscription = merge(children$, layout$).subscribe(() => { if (children$.value[childId]) { subscription.unsubscribe(); resolve(children$.value[childId]); return; } - const panelExists = panels$.value.some(({ id }) => id === childId); + const panelExists = layout$.value.some(({ id }) => id === childId); if (!panelExists) { // panel removed before finished loading. subscription.unsubscribe(); @@ -92,49 +110,57 @@ export function getPageApi() { dataLoading$.next(isAtLeastOneChildLoading); }); - // One could use `initializeUnsavedChanges` to set up unsaved changes observable. - // Instead, decided to manually setup unsaved changes observable - // since only timeRange and panels array need to be monitored. - const timeRangeUnsavedChanges$ = combineLatest([timeRange$, lastSavedState$]).pipe( + const hasTimeRangeChanges$ = combineLatest([timeRange$, lastSavedState$]).pipe( map(([currentTimeRange, lastSavedState]) => { - const hasChanges = !isEqual(currentTimeRange, lastSavedState.timeRange); - return hasChanges ? { timeRange: currentTimeRange } : undefined; + return !isEqual(currentTimeRange, lastSavedState.timeRange); }) ); - const panelsUnsavedChanges$ = combineLatest([panels$, lastSavedState$]).pipe( - map(([currentPanels, lastSavedState]) => { - const hasChanges = !isEqual(currentPanels, lastSavedState.panels); - return hasChanges ? { panels: currentPanels } : undefined; + const hasLayoutChanges$ = combineLatest([ + layout$, + lastSavedState$.pipe(map((lastSavedState) => deserializePanels(lastSavedState.panels).layout)), + ]).pipe( + map(([currentLayout, lastSavedLayout]) => { + return !isEqual(currentLayout, lastSavedLayout); }) ); - const unsavedChanges$ = combineLatest([ - timeRangeUnsavedChanges$, - panelsUnsavedChanges$, - childrenUnsavedChanges$(children$), - ]).pipe( - map(([timeRangeUnsavedChanges, panelsChanges, childrenUnsavedChanges]) => { - const nextUnsavedChanges: UnsavedChanges = {}; - if (timeRangeUnsavedChanges) { - nextUnsavedChanges.timeRange = timeRangeUnsavedChanges.timeRange; - } - if (panelsChanges) { - nextUnsavedChanges.panels = panelsChanges.panels; - } - if (childrenUnsavedChanges) { - nextUnsavedChanges.panelUnsavedChanges = childrenUnsavedChanges; + const hasPanelChanges$ = childrenUnsavedChanges$(children$).pipe( + tap((childrenWithChanges) => { + // propagate the latest serialized state back to currentChildState. + for (const { uuid, hasUnsavedChanges } of childrenWithChanges) { + const childApi = children$.value[uuid]; + if (hasUnsavedChanges && apiHasSerializableState(childApi)) { + currentChildState[uuid] = childApi.serializeState(); + } } - return Object.keys(nextUnsavedChanges).length ? nextUnsavedChanges : undefined; + }), + map((childrenWithChanges) => { + return childrenWithChanges.some(({ hasUnsavedChanges }) => hasUnsavedChanges); + }) + ); + + const hasUnsavedChanges$ = combineLatest([ + hasTimeRangeChanges$, + hasLayoutChanges$, + hasPanelChanges$, + ]).pipe( + map(([hasTimeRangeChanges, hasLayoutChanges, hasPanelChanges]) => { + return hasTimeRangeChanges || hasLayoutChanges || hasPanelChanges; }) ); - const unsavedChangesSubscription = unsavedChanges$.subscribe((nextUnsavedChanges) => { - unsavedChangesSessionStorage.save(nextUnsavedChanges ?? {}); + const hasUnsavedChangesSubscription = hasUnsavedChanges$.subscribe((hasUnsavedChanges) => { + if (!hasUnsavedChanges) { + unsavedChangesSessionStorage.clear(); + return; + } + + unsavedChangesSessionStorage.save(serializePage()); }); return { cleanUp: () => { childrenDataLoadingSubscripiton.unsubscribe(); - unsavedChangesSubscription.unsubscribe(); + hasUnsavedChangesSubscription.unsubscribe(); }, /** * api's needed by component that should not be shared with children @@ -144,36 +170,14 @@ export function getPageApi() { reload$.next(); }, onSave: async () => { - const panelsState: LastSavedState['panelsState'] = []; - panels$.value.forEach(({ id, type }) => { - try { - const childApi = children$.value[id]; - if (apiHasSerializableState(childApi)) { - panelsState.push({ - id, - type, - panelState: childApi.serializeState(), - }); - } - } catch (error) { - // Unable to serialize panel state, just ignore since this is an example - } - }); - - const savedState = { - timeRange: timeRange$.value ?? DEFAULT_STATE.timeRange, - panelsState, - }; - lastSavedState$.next({ - ...savedState, - panels: panelsState.map(({ id, type }) => { - return { id, type }; - }), - }); - lastSavedStateSessionStorage.save(savedState); - saveNotification$.next(); + const serializedPage = serializePage(); + // simulate save await + await new Promise((resolve) => setTimeout(resolve, 1000)); + lastSavedState$.next(serializedPage); + lastSavedStateSessionStorage.save(serializedPage); + unsavedChangesSessionStorage.clear(); }, - panels$, + layout$, setChild: (id: string, api: unknown) => { children$.next({ ...children$.value, @@ -185,20 +189,21 @@ export function getPageApi() { }, }, pageApi: { - addNewPanel: async ({ panelType, initialState }: PanelPackage) => { + addNewPanel: async ({ panelType, serializedState }: PanelPackage) => { const id = generateId(); - panels$.next([...panels$.value, { id, type: panelType }]); - newPanels[id] = initialState ?? {}; - return await untilChildLoaded(id); + layout$.next([...layout$.value, { id, type: panelType }]); + currentChildState[id] = serializedState; + return await getChildApi(id); }, canRemovePanels: () => true, + getChildApi, children$, dataLoading$, executionContext: { type: 'presentationContainerEmbeddableExample', }, getPanelCount: () => { - return panels$.value.length; + return layout$.value.length; }, replacePanel: async (idToRemove: string, newPanel: PanelPackage) => { // TODO remove method from interface? It should not be required @@ -206,53 +211,41 @@ export function getPageApi() { }, reload$: reload$ as unknown as PublishingSubject, removePanel: (id: string) => { - panels$.next(panels$.value.filter(({ id: panelId }) => panelId !== id)); + layout$.next(layout$.value.filter(({ id: panelId }) => panelId !== id)); children$.next(omit(children$.value, id)); + delete currentChildState[id]; }, - saveNotification$, viewMode$: new BehaviorSubject('edit'), - /** - * return last saved embeddable state - */ - getSerializedStateForChild: (childId: string) => { - const panel = initialSavedState.panelsState.find(({ id }) => { - return id === childId; - }); - return panel ? panel.panelState : undefined; - }, - /** - * return previous session's unsaved changes for embeddable - */ - getRuntimeStateForChild: (childId: string) => { - return newPanels[childId] ?? initialUnsavedChanges.panelUnsavedChanges?.[childId]; - }, + getSerializedStateForChild: (childId: string) => currentChildState[childId], + lastSavedStateForChild$: (panelId: string) => + lastSavedState$.pipe(map(() => getLastSavedStateForChild(panelId))), + getLastSavedStateForChild, resetUnsavedChanges: () => { - timeRange$.next(lastSavedState$.value.timeRange); - panels$.next(lastSavedState$.value.panels); - lastSavedState$.value.panels.forEach(({ id }) => { - const childApi = children$.value[id]; - if (apiPublishesUnsavedChanges(childApi)) { - childApi.resetUnsavedChanges(); - } - }); - const nextPanelIds = lastSavedState$.value.panels.map(({ id }) => id); - const children = { ...children$.value }; - let modifiedChildren = false; - Object.keys(children).forEach((controlId) => { - if (!nextPanelIds.includes(controlId)) { - // remove children that no longer exist after reset - delete children[controlId]; - modifiedChildren = true; + 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 (modifiedChildren) { - children$.next(children); } - newPanels = {}; - return true; + if (childrenModified) children$.next(currentChildren); }, timeRange$, - unsavedChanges$: unsavedChanges$ as PublishingSubject, + hasUnsavedChanges$, } as PageApi, }; } diff --git a/examples/embeddable_examples/public/app/presentation_container_example/session_storage/last_saved_state.ts b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/last_saved_state.ts index a921eaede5c68..0ac6ec2d6954f 100644 --- a/examples/embeddable_examples/public/app/presentation_container_example/session_storage/last_saved_state.ts +++ b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/last_saved_state.ts @@ -7,28 +7,28 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { LastSavedState } from '../types'; +import { PageState } from '../types'; const SAVED_STATE_SESSION_STORAGE_KEY = 'kibana.examples.embeddables.presentationContainerExample.savedState'; -export const DEFAULT_STATE: LastSavedState = { +export const DEFAULT_STATE: PageState = { timeRange: { from: 'now-15m', to: 'now', }, - panelsState: [], + panels: [], }; export const lastSavedStateSessionStorage = { clear: () => { sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY); }, - load: (): LastSavedState => { + load: (): PageState => { const savedState = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY); return savedState ? JSON.parse(savedState) : { ...DEFAULT_STATE }; }, - save: (state: LastSavedState) => { + save: (state: PageState) => { sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(state)); }, }; diff --git a/examples/embeddable_examples/public/app/presentation_container_example/session_storage/unsaved_changes.ts b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/unsaved_changes.ts index ccc35471a9944..e726877232502 100644 --- a/examples/embeddable_examples/public/app/presentation_container_example/session_storage/unsaved_changes.ts +++ b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/unsaved_changes.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { UnsavedChanges } from '../types'; +import { PageState } from '../types'; const UNSAVED_CHANGES_SESSION_STORAGE_KEY = 'kibana.examples.embeddables.presentationContainerExample.unsavedChanges'; @@ -16,11 +16,11 @@ export const unsavedChangesSessionStorage = { clear: () => { sessionStorage.removeItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY); }, - load: (): UnsavedChanges => { + load: (): PageState | undefined => { const unsavedChanges = sessionStorage.getItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY); - return unsavedChanges ? JSON.parse(unsavedChanges) : {}; + return unsavedChanges ? JSON.parse(unsavedChanges) : undefined; }, - save: (unsavedChanges: UnsavedChanges) => { + save: (unsavedChanges: PageState) => { sessionStorage.setItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY, JSON.stringify(unsavedChanges)); }, }; diff --git a/examples/embeddable_examples/public/app/presentation_container_example/types.ts b/examples/embeddable_examples/public/app/presentation_container_example/types.ts index bcd3c0b9fcb52..a6ee196fb899b 100644 --- a/examples/embeddable_examples/public/app/presentation_container_example/types.ts +++ b/examples/embeddable_examples/public/app/presentation_container_example/types.ts @@ -10,10 +10,9 @@ import { TimeRange } from '@kbn/es-query'; import { CanAddNewPanel, + HasLastSavedChildState, HasSerializedChildState, - HasRuntimeChildState, PresentationContainer, - HasSaveNotification, } from '@kbn/presentation-containers'; import { HasExecutionContext, @@ -28,22 +27,15 @@ import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/p export type PageApi = PresentationContainer & CanAddNewPanel & HasExecutionContext & - HasSaveNotification & + HasLastSavedChildState & HasSerializedChildState & - HasRuntimeChildState & PublishesDataLoading & PublishesViewMode & PublishesReload & PublishesTimeRange & PublishesUnsavedChanges; -export interface LastSavedState { +export interface PageState { timeRange: TimeRange; - panelsState: Array<{ id: string; type: string; panelState: SerializedPanelState }>; -} - -export interface UnsavedChanges { - timeRange?: TimeRange; - panels?: Array<{ id: string; type: string }>; - panelUnsavedChanges?: Record; + panels: Array<{ id: string; type: string; serializedState: SerializedPanelState | undefined }>; } diff --git a/examples/embeddable_examples/public/app/render_examples.tsx b/examples/embeddable_examples/public/app/render_examples.tsx index 8dcf6a128d4cd..96dccd976c0b4 100644 --- a/examples/embeddable_examples/public/app/render_examples.tsx +++ b/examples/embeddable_examples/public/app/render_examples.tsx @@ -9,7 +9,7 @@ import React, { useMemo, useState } from 'react'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { EuiCodeBlock, EuiFlexGroup, @@ -24,7 +24,7 @@ import { BehaviorSubject, Subject } from 'rxjs'; import { TimeRange } from '@kbn/es-query'; import { useBatchedOptionalPublishingSubjects } from '@kbn/presentation-publishing'; import { SearchEmbeddableRenderer } from '../react_embeddables/search/search_embeddable_renderer'; -import { SEARCH_EMBEDDABLE_ID } from '../react_embeddables/search/constants'; +import { SEARCH_EMBEDDABLE_TYPE } from '../react_embeddables/search/constants'; import type { SearchApi, SearchSerializedState } from '../react_embeddables/search/types'; export const RenderExamples = () => { @@ -74,13 +74,13 @@ export const RenderExamples = () => {

- Use ReactEmbeddableRenderer to render embeddables. + Use EmbeddableRenderer to render embeddables.

- {` - type={SEARCH_EMBEDDABLE_ID} + {` + type={SEARCH_EMBEDDABLE_TYPE} getParentApi={() => parentApi} onApiAvailable={(newApi) => { setApi(newApi); @@ -99,9 +99,9 @@ export const RenderExamples = () => { - + key={hidePanelChrome ? 'hideChrome' : 'showChrome'} - type={SEARCH_EMBEDDABLE_ID} + type={SEARCH_EMBEDDABLE_TYPE} getParentApi={() => parentApi} onApiAvailable={(newApi) => { setApi(newApi); @@ -112,7 +112,7 @@ export const RenderExamples = () => { -

To avoid leaking embeddable details, wrap ReactEmbeddableRenderer in a component.

+

To avoid leaking embeddable details, wrap EmbeddableRenderer in a component.

diff --git a/examples/embeddable_examples/public/app/state_management_example/last_saved_state.ts b/examples/embeddable_examples/public/app/state_management_example/last_saved_state.ts deleted file mode 100644 index 1b8ba4e0f9209..0000000000000 --- a/examples/embeddable_examples/public/app/state_management_example/last_saved_state.ts +++ /dev/null @@ -1,27 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { SerializedPanelState } from '@kbn/presentation-publishing'; -import { BookSerializedState } from '../../react_embeddables/saved_book/types'; - -const SAVED_STATE_SESSION_STORAGE_KEY = - 'kibana.examples.embeddables.stateManagementExample.savedState'; - -export const lastSavedStateSessionStorage = { - clear: () => { - sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY); - }, - load: (): SerializedPanelState | undefined => { - const savedState = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY); - return savedState ? JSON.parse(savedState) : undefined; - }, - save: (state: SerializedPanelState) => { - sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(state)); - }, -}; diff --git a/examples/embeddable_examples/public/app/state_management_example/session_storage.ts b/examples/embeddable_examples/public/app/state_management_example/session_storage.ts new file mode 100644 index 0000000000000..77e01823d61ba --- /dev/null +++ b/examples/embeddable_examples/public/app/state_management_example/session_storage.ts @@ -0,0 +1,37 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SerializedPanelState } from '@kbn/presentation-publishing'; +import { BookSerializedState } from '../../react_embeddables/saved_book/types'; + +const SAVED_STATE_SESSION_STORAGE_KEY = + 'kibana.examples.embeddables.stateManagementExample.savedState'; +const UNSAVED_STATE_SESSION_STORAGE_KEY = + 'kibana.examples.embeddables.stateManagementExample.unsavedSavedState'; +export const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247'; + +export const savedStateManager = { + clear: () => sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY), + set: (serializedState: SerializedPanelState) => + sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)), + get: () => { + const serializedStateJSON = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY); + return serializedStateJSON ? JSON.parse(serializedStateJSON) : undefined; + }, +}; + +export const unsavedStateManager = { + clear: () => sessionStorage.removeItem(UNSAVED_STATE_SESSION_STORAGE_KEY), + set: (serializedState: SerializedPanelState) => + sessionStorage.setItem(UNSAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)), + get: () => { + const serializedStateJSON = sessionStorage.getItem(UNSAVED_STATE_SESSION_STORAGE_KEY); + return serializedStateJSON ? JSON.parse(serializedStateJSON) : undefined; + }, +}; 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 8676b467ae221..85b650addab1c 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 @@ -18,34 +18,66 @@ import { EuiSpacer, } from '@elastic/eui'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; -import { ViewMode } from '@kbn/presentation-publishing'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { SerializedPanelState, ViewMode } from '@kbn/presentation-publishing'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { BehaviorSubject, of } from 'rxjs'; import { SAVED_BOOK_ID } from '../../react_embeddables/saved_book/constants'; -import { - BookApi, - BookRuntimeState, - BookSerializedState, -} from '../../react_embeddables/saved_book/types'; -import { lastSavedStateSessionStorage } from './last_saved_state'; -import { unsavedChangesSessionStorage } from './unsaved_changes'; +import { BookApi, BookSerializedState } from '../../react_embeddables/saved_book/types'; +import { savedStateManager, unsavedStateManager } from './session_storage'; -export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStart }) => { - const saveNotification$ = useMemo(() => { - return new Subject(); - }, []); +const BOOK_EMBEDDABLE_ID = 'BOOK_EMBEDDABLE_ID'; +export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStart }) => { const [bookApi, setBookApi] = useState(); - const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const parentApi = useMemo(() => { + const unsavedSavedBookState = unsavedStateManager.get(); + const lastSavedbookState = savedStateManager.get(); + const lastSavedBookState$ = new BehaviorSubject(lastSavedbookState); + + return { + viewMode$: new BehaviorSubject('edit'), + getSerializedStateForChild: (childId: string) => { + if (childId === BOOK_EMBEDDABLE_ID) { + return unsavedSavedBookState ? unsavedSavedBookState : lastSavedbookState; + } + + return { + rawState: {}, + references: [], + }; + }, + lastSavedStateForChild$: (childId: string) => { + return childId === BOOK_EMBEDDABLE_ID ? lastSavedBookState$ : of(undefined); + }, + getLastSavedStateForChild: (childId: string) => { + return childId === BOOK_EMBEDDABLE_ID + ? lastSavedBookState$.value + : { + rawState: {}, + references: [], + }; + }, + setLastSavedBookState: (savedState: SerializedPanelState) => { + lastSavedBookState$.next(savedState); + }, + }; + }, []); + useEffect(() => { - if (!bookApi || !bookApi.unsavedChanges$) { + if (!bookApi) { return; } - const subscription = bookApi.unsavedChanges$.subscribe((unsavedChanges) => { - setHasUnsavedChanges(unsavedChanges !== undefined); - unsavedChangesSessionStorage.save(unsavedChanges ?? {}); + const subscription = bookApi.hasUnsavedChanges$.subscribe((nextHasUnsavedChanges) => { + if (!nextHasUnsavedChanges) { + unsavedStateManager.clear(); + setHasUnsavedChanges(false); + return; + } + + unsavedStateManager.set(bookApi.serializeState()); + setHasUnsavedChanges(true); }); return () => { @@ -58,38 +90,38 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar

Each embeddable manages its own state. The page is only responsible for persisting and - providing the last persisted state to the embeddable. + providing the last saved state or last unsaved state to the embeddable.

- The page renders the embeddable with ReactEmbeddableRenderer component. - On mount, ReactEmbeddableRenderer component calls{' '} - pageApi.getSerializedStateForChild to get the last saved state. - ReactEmbeddableRenderer component then calls{' '} - pageApi.getRuntimeStateForChild to get the last session's unsaved - changes. ReactEmbeddableRenderer merges last saved state with unsaved changes and passes - the merged state to the embeddable factory. ReactEmbeddableRender passes the embeddableApi - to the page by calling onApiAvailable. + The page renders the embeddable with EmbeddableRenderer component. + EmbeddableRender passes the embeddableApi to the page by calling{' '} + onApiAvailable.

- The page subscribes to embeddableApi.unsavedChanges to receive embeddable + The page subscribes to embeddableApi.hasUnsavedChanges to by notified of unsaved changes. The page persists unsaved changes in session storage. The page provides - unsaved changes to the embeddable with pageApi.getRuntimeStateForChild. + unsaved changes to the embeddable with pageApi.getSerializedStateForChild + .

The page gets embeddable state by calling embeddableApi.serializeState. - The page persists embeddable state in session storage. The page provides last saved state - to the embeddable with pageApi.getSerializedStateForChild. + The page persists embeddable state in session storage. +

+ +

+ The page provides unsaved state or last saved state to the embeddable with{' '} + pageApi.getSerializedStateForChild.

{ - lastSavedStateSessionStorage.clear(); - unsavedChangesSessionStorage.clear(); + savedStateManager.clear(); + unsavedStateManager.clear(); window.location.reload(); }} > @@ -108,9 +140,9 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar { - bookApi?.resetUnsavedChanges?.(); + bookApi?.resetUnsavedChanges(); }} > Reset @@ -120,18 +152,16 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar )} { if (!bookApi) { return; } - setIsSaving(true); - const bookSerializedState = bookApi.serializeState(); - lastSavedStateSessionStorage.save(bookSerializedState); - saveNotification$.next(); // signals embeddable unsaved change tracking to update last saved state - setHasUnsavedChanges(false); - setIsSaving(false); + const savedState = bookApi.serializeState(); + parentApi.setLastSavedBookState(savedState); + savedStateManager.set(savedState); + unsavedStateManager.clear(); }} > Save @@ -141,26 +171,10 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar - + type={SAVED_BOOK_ID} - getParentApi={() => { - return { - /** - * return last saved embeddable state - */ - getSerializedStateForChild: (childId: string) => { - return lastSavedStateSessionStorage.load(); - }, - /** - * return previous session's unsaved changes for embeddable - */ - getRuntimeStateForChild: (childId: string) => { - return unsavedChangesSessionStorage.load(); - }, - saveNotification$, - viewMode$: new BehaviorSubject('edit'), - }; - }} + maybeId={BOOK_EMBEDDABLE_ID} + getParentApi={() => parentApi} onApiAvailable={(api) => { setBookApi(api); }} diff --git a/examples/embeddable_examples/public/app/state_management_example/unsaved_changes.ts b/examples/embeddable_examples/public/app/state_management_example/unsaved_changes.ts deleted file mode 100644 index 7106872db59e1..0000000000000 --- a/examples/embeddable_examples/public/app/state_management_example/unsaved_changes.ts +++ /dev/null @@ -1,26 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { BookRuntimeState } from '../../react_embeddables/saved_book/types'; - -const UNSAVED_CHANGES_SESSION_STORAGE_KEY = - 'kibana.examples.embeddables.stateManagementExample.unsavedChanges'; - -export const unsavedChangesSessionStorage = { - clear: () => { - sessionStorage.removeItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY); - }, - load: (): Partial | undefined => { - const unsavedChanges = sessionStorage.getItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY); - return unsavedChanges ? JSON.parse(unsavedChanges) : undefined; - }, - save: (unsavedChanges: Partial) => { - sessionStorage.setItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY, JSON.stringify(unsavedChanges)); - }, -}; diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index baded045f80a8..3032cad199948 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -55,7 +55,7 @@ export class EmbeddableExamplesPlugin implements Plugin { const { getFieldListFactory } = await import( - './react_embeddables/field_list/field_list_react_embeddable' + './react_embeddables/field_list/field_list_embeddable' ); const [coreStart, deps] = await startServicesPromise; return getFieldListFactory(coreStart, deps); diff --git a/examples/embeddable_examples/public/react_embeddables/data_table/data_table_queries.ts b/examples/embeddable_examples/public/react_embeddables/data_table/data_table_queries.ts index 4688882db3515..6b8e49428cea3 100644 --- a/examples/embeddable_examples/public/react_embeddables/data_table/data_table_queries.ts +++ b/examples/embeddable_examples/public/react_embeddables/data_table/data_table_queries.ts @@ -16,7 +16,7 @@ import { listenForCompatibleApi } from '@kbn/presentation-containers'; import { apiPublishesDataViews, fetch$ } from '@kbn/presentation-publishing'; import { BehaviorSubject, combineLatest, lastValueFrom, map, Subscription, switchMap } from 'rxjs'; import { StartDeps } from '../../plugin'; -import { apiPublishesSelectedFields } from '../field_list/publishes_selected_fields'; +import { apiPublishesSelectedFields } from './publishes_selected_fields'; import { DataTableApi } from './types'; export const initializeDataTableQueries = async ( diff --git a/examples/embeddable_examples/public/react_embeddables/data_table/data_table_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/data_table/data_table_react_embeddable.tsx index b0d7435f3cd0f..b7da300fe9b8b 100644 --- a/examples/embeddable_examples/public/react_embeddables/data_table/data_table_react_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/data_table/data_table_react_embeddable.tsx @@ -11,37 +11,39 @@ import { EuiScreenReaderOnly } from '@elastic/eui'; import { css } from '@emotion/react'; import { CellActionsProvider } from '@kbn/cell-actions'; import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { - initializeTimeRange, + initializeTimeRangeManager, initializeTitleManager, + timeRangeComparators, + titleComparators, useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { DataLoadingState, UnifiedDataTable, UnifiedDataTableProps } from '@kbn/unified-data-table'; import React, { useEffect } from 'react'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, merge } from 'rxjs'; import { StartDeps } from '../../plugin'; import { DATA_TABLE_ID } from './constants'; import { initializeDataTableQueries } from './data_table_queries'; -import { DataTableApi, DataTableRuntimeState, DataTableSerializedState } from './types'; +import { DataTableApi, DataTableSerializedState } from './types'; export const getDataTableFactory = ( core: CoreStart, services: StartDeps -): ReactEmbeddableFactory => ({ +): EmbeddableFactory => ({ type: DATA_TABLE_ID, - deserializeState: (state) => { - return state.rawState as DataTableSerializedState; - }, - buildEmbeddable: async (state, buildApi, uuid, parentApi) => { - const storage = new Storage(localStorage); - const timeRange = initializeTimeRange(state); + buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + const state = initialState.rawState; + const timeRangeManager = initializeTimeRangeManager(state); const dataLoading$ = new BehaviorSubject(true); const titleManager = initializeTitleManager(state); + + const storage = new Storage(localStorage); const allServices: UnifiedDataTableProps['services'] = { ...services, storage, @@ -50,19 +52,40 @@ export const getDataTableFactory = ( toastNotifications: core.notifications.toasts, }; - const api = buildApi( - { - ...timeRange.api, - ...titleManager.api, - dataLoading$, - serializeState: () => { - return { - rawState: { ...titleManager.serialize(), ...timeRange.serialize() }, - }; + const serializeState = () => { + return { + rawState: { + ...titleManager.getLatestState(), + ...timeRangeManager.getLatestState(), }, + }; + }; + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState, + anyStateChange$: merge(titleManager.anyStateChange$, timeRangeManager.anyStateChange$), + getComparators: () => { + return { + ...titleComparators, + ...timeRangeComparators, + }; }, - { ...titleManager.comparators, ...timeRange.comparators } - ); + onReset: (lastSaved) => { + const lastSavedState = lastSaved?.rawState; + timeRangeManager.reinitializeState(lastSavedState); + titleManager.reinitializeState(lastSavedState); + }, + }); + + const api = finalizeApi({ + ...timeRangeManager.api, + ...titleManager.api, + ...unsavedChangesApi, + dataLoading$, + serializeState, + }); const queryService = await initializeDataTableQueries(services, api, dataLoading$); diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/publishes_selected_fields.ts b/examples/embeddable_examples/public/react_embeddables/data_table/publishes_selected_fields.ts similarity index 100% rename from examples/embeddable_examples/public/react_embeddables/field_list/publishes_selected_fields.ts rename to examples/embeddable_examples/public/react_embeddables/data_table/publishes_selected_fields.ts diff --git a/examples/embeddable_examples/public/react_embeddables/data_table/types.ts b/examples/embeddable_examples/public/react_embeddables/data_table/types.ts index 95c71f0fc2d3b..57c6f53b53069 100644 --- a/examples/embeddable_examples/public/react_embeddables/data_table/types.ts +++ b/examples/embeddable_examples/public/react_embeddables/data_table/types.ts @@ -16,6 +16,4 @@ import { export type DataTableSerializedState = SerializedTitles & SerializedTimeRange; -export type DataTableRuntimeState = DataTableSerializedState; - export type DataTableApi = DefaultEmbeddableApi & PublishesDataLoading; diff --git a/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx b/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx index 1ce98f9b68824..b3b2dc48062ee 100644 --- a/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx +++ b/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx @@ -36,7 +36,7 @@ export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => { embeddable.addNewPanel( { panelType: EUI_MARKDOWN_ID, - initialState: { content: '# hello world!' }, + serializedState: { rawState: { content: '# hello world!' } }, }, true ); diff --git a/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx index 4cd1a47e50fa2..997e714a26c0a 100644 --- a/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx @@ -9,75 +9,98 @@ import { EuiMarkdownEditor, EuiMarkdownFormat, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { + StateComparators, + WithAllKeys, getViewModeSubject, + initializeStateManager, initializeTitleManager, + titleComparators, useStateFromPublishingSubject, } from '@kbn/presentation-publishing'; import React from 'react'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, map, merge } from 'rxjs'; import { EUI_MARKDOWN_ID } from './constants'; -import { - MarkdownEditorApi, - MarkdownEditorRuntimeState, - MarkdownEditorSerializedState, -} from './types'; +import { MarkdownEditorApi, MarkdownEditorSerializedState, MarkdownEditorState } from './types'; + +const defaultMarkdownState: WithAllKeys = { + content: '', +}; + +const markdownComparators: StateComparators = { content: 'referenceEquality' }; -export const markdownEmbeddableFactory: ReactEmbeddableFactory< +export const markdownEmbeddableFactory: EmbeddableFactory< MarkdownEditorSerializedState, - MarkdownEditorRuntimeState, MarkdownEditorApi > = { type: EUI_MARKDOWN_ID, - deserializeState: (state) => state.rawState, - /** - * The buildEmbeddable function is async so you can async import the component or load a saved - * object here. The loading will be handed gracefully by the Presentation Container. - */ - buildEmbeddable: async (state, buildApi) => { + buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { /** - * initialize state (source of truth) + * Initialize state managers. */ - const titleManager = initializeTitleManager(state); - const content$ = new BehaviorSubject(state.content); + const titleManager = initializeTitleManager(initialState.rawState); + const markdownStateManager = initializeStateManager( + initialState.rawState, + defaultMarkdownState + ); /** - * Register the API for this embeddable. This API will be published into the imperative handle - * of the React component. Methods on this API will be exposed to siblings, to registered actions - * and to the parent api. + * if this embeddable had a difference between its runtime and serialized state, we could define and run a + * "deserializeState" function here. If this embeddable could be by reference, we could load the saved object + * in the deserializeState function. */ - const api = buildApi( - { - ...titleManager.api, - serializeState: () => { - return { - rawState: { - ...titleManager.serialize(), - content: content$.getValue(), - }, - }; + + function serializeState() { + return { + rawState: { + ...titleManager.getLatestState(), + ...markdownStateManager.getLatestState(), }, + // references: if this embeddable had any references - this is where we would extract them. + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState, + anyStateChange$: merge( + titleManager.anyStateChange$, + markdownStateManager.anyStateChange$ + ).pipe(map(() => undefined)), + getComparators: () => { + /** + * comparators are provided in a callback to allow embeddables to change how their state is compared based + * on the values of other state. For instance, if a saved object ID is present (by reference), the embeddable + * may want to skip comparison of certain state. + */ + return { ...titleComparators, ...markdownComparators }; }, + onReset: (lastSaved) => { + /** + * if this embeddable had a difference between its runtime and serialized state, we could run the 'deserializeState' + * function here before resetting. onReset can be async so to support a potential async deserialize function. + */ - /** - * Provide state comparators. Each comparator is 3 element tuple: - * 1) current value (publishing subject) - * 2) setter, allowing parent to reset value - * 3) optional comparator which provides logic to diff lasted stored value and current value - */ - { - content: [content$, (value) => content$.next(value)], - ...titleManager.comparators, - } - ); + titleManager.reinitializeState(lastSaved?.rawState); + markdownStateManager.reinitializeState(lastSaved?.rawState); + }, + }); + + const api = finalizeApi({ + ...unsavedChangesApi, + ...titleManager.api, + serializeState, + }); return { api, Component: () => { // get state for rendering - const content = useStateFromPublishingSubject(content$); + const content = useStateFromPublishingSubject(markdownStateManager.api.content$); const viewMode = useStateFromPublishingSubject( getViewModeSubject(api) ?? new BehaviorSubject('view') ); @@ -89,7 +112,7 @@ export const markdownEmbeddableFactory: ReactEmbeddableFactory< width: 100%; `} value={content ?? ''} - onChange={(value) => content$.next(value)} + onChange={(value) => markdownStateManager.api.setContent(value)} aria-label={i18n.translate('embeddableExamples.euiMarkdownEditor.embeddableAriaLabel', { defaultMessage: 'Dashboard markdown editor', })} diff --git a/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts b/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts index 67bd70427fbff..f39720c23bd7a 100644 --- a/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts +++ b/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts @@ -8,12 +8,20 @@ */ import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; -import { SerializedTitles } from '@kbn/presentation-publishing'; +import { PublishesUnsavedChanges, SerializedTitles } from '@kbn/presentation-publishing'; -export type MarkdownEditorSerializedState = SerializedTitles & { +/** + * The markdown editor's own state. Every embeddable type should separate out its own self-managed state, from state + * supplied by other common managers. + */ +export interface MarkdownEditorState { content: string; -}; +} -export type MarkdownEditorRuntimeState = MarkdownEditorSerializedState; +/** + * Markdown serialized state includes all state that the parent should provide to this embeddable. + */ +export type MarkdownEditorSerializedState = SerializedTitles & MarkdownEditorState; -export type MarkdownEditorApi = DefaultEmbeddableApi; +export type MarkdownEditorApi = DefaultEmbeddableApi & + PublishesUnsavedChanges; diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx b/examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx index 6d33ba60a6a82..f0e70fb7d7596 100644 --- a/examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx +++ b/examples/embeddable_examples/public/react_embeddables/field_list/create_field_list_action.tsx @@ -14,7 +14,7 @@ import { IncompatibleActionError, ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plug import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin'; import { embeddableExamplesGrouping } from '../embeddable_examples_grouping'; import { ADD_FIELD_LIST_ACTION_ID, FIELD_LIST_ID } from './constants'; -import { FieldListSerializedStateState } from './types'; +import { FieldListSerializedState } from './types'; export const registerCreateFieldListAction = (uiActions: UiActionsPublicStart) => { uiActions.registerAction({ @@ -26,7 +26,7 @@ export const registerCreateFieldListAction = (uiActions: UiActionsPublicStart) = }, execute: async ({ embeddable }) => { if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError(); - embeddable.addNewPanel({ + embeddable.addNewPanel({ panelType: FIELD_LIST_ID, }); }, 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 new file mode 100644 index 0000000000000..ba61bace28599 --- /dev/null +++ b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_embeddable.tsx @@ -0,0 +1,240 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { Reference } from '@kbn/content-management-utils'; +import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { + DATA_VIEW_SAVED_OBJECT_TYPE, + type DataViewsPublicPluginStart, +} from '@kbn/data-views-plugin/public'; +import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { + type SerializedPanelState, + type WithAllKeys, + initializeStateManager, + initializeTitleManager, + titleComparators, + useBatchedPublishingSubjects, +} from '@kbn/presentation-publishing'; +import { LazyDataViewPicker, withSuspense } from '@kbn/presentation-util-plugin/public'; +import { + UnifiedFieldListSidebarContainer, + type UnifiedFieldListSidebarContainerProps, +} from '@kbn/unified-field-list'; +import { cloneDeep } from 'lodash'; +import React, { useEffect } from 'react'; +import { merge, skip, Subscription, switchMap } from 'rxjs'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; +import { FIELD_LIST_DATA_VIEW_REF_NAME, FIELD_LIST_ID } from './constants'; +import { FieldListApi, Services, FieldListSerializedState, FieldListRuntimeState } from './types'; + +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + +const defaultFieldListState: WithAllKeys = { + dataViewId: undefined, + selectedFieldNames: undefined, + dataViews: undefined, +}; + +const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => { + return { + originatingApp: '', + localStorageKeyPrefix: 'examples', + timeRangeUpdatesType: 'timefilter', + compressed: true, + showSidebarToggleButton: false, + disablePopularFields: true, + }; +}; + +const deserializeState = async ( + dataViews: DataViewsPublicPluginStart, + serializedState?: SerializedPanelState +): Promise => { + const state = serializedState?.rawState ? cloneDeep(serializedState?.rawState) : {}; + // inject the reference + const dataViewIdRef = (serializedState?.references ?? []).find( + (ref) => ref.name === FIELD_LIST_DATA_VIEW_REF_NAME + ); + // if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this) + if (dataViewIdRef && state && !state.dataViewId) { + state.dataViewId = dataViewIdRef?.id; + } + + const [allDataViews, defaultDataViewId] = await Promise.all([ + dataViews.getIdsWithTitle(), + dataViews.getDefaultId(), + ]); + if (!defaultDataViewId || allDataViews.length === 0) { + throw new Error( + i18n.translate('embeddableExamples.unifiedFieldList.noDefaultDataViewErrorMessage', { + defaultMessage: 'The field list must be used with at least one Data View present', + }) + ); + } + const initialDataViewId = state.dataViewId ?? defaultDataViewId; + const initialDataView = await dataViews.get(initialDataViewId); + return { + dataViewId: initialDataViewId, + selectedFieldNames: state.selectedFieldNames ?? [], + dataViews: [initialDataView], + }; +}; + +export const getFieldListFactory = ( + core: CoreStart, + { dataViews, data, charts, fieldFormats }: Services +) => { + const fieldListEmbeddableFactory: EmbeddableFactory = { + type: FIELD_LIST_ID, + buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + const state = await deserializeState(dataViews, initialState); + const allDataViews = await dataViews.getIdsWithTitle(); + const subscriptions = new Subscription(); + const titleManager = initializeTitleManager(initialState?.rawState ?? {}); + const fieldListStateManager = initializeStateManager(state, defaultFieldListState); + + // Whenever the data view changes, we want to update the data views and reset the selectedFields in the field list state manager. + subscriptions.add( + fieldListStateManager.api.dataViewId$ + .pipe( + skip(1), + switchMap((dataViewId) => + dataViewId ? dataViews.get(dataViewId) : dataViews.getDefaultDataView() + ) + ) + .subscribe((nextSelectedDataView) => { + fieldListStateManager.api.setDataViews( + nextSelectedDataView ? [nextSelectedDataView] : undefined + ); + fieldListStateManager.api.setSelectedFieldNames([]); + }) + ); + + function serializeState() { + const { dataViewId, selectedFieldNames } = fieldListStateManager.getLatestState(); + const references: Reference[] = dataViewId + ? [ + { + type: DATA_VIEW_SAVED_OBJECT_TYPE, + name: FIELD_LIST_DATA_VIEW_REF_NAME, + id: dataViewId, + }, + ] + : []; + return { + rawState: { + ...titleManager.getLatestState(), + // here we skip serializing the dataViewId, because the reference contains that information. + selectedFieldNames, + }, + references, + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState, + anyStateChange$: merge(titleManager.anyStateChange$, fieldListStateManager.anyStateChange$), + getComparators: () => ({ + ...titleComparators, + selectedFieldNames: (a, b) => { + return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? ''); + }, + }), + onReset: async (lastSaved) => { + const lastState = await deserializeState(dataViews, lastSaved); + fieldListStateManager.reinitializeState(lastState); + titleManager.reinitializeState(lastSaved?.rawState); + }, + }); + + const api = finalizeApi({ + ...titleManager.api, + ...unsavedChangesApi, + serializeState, + }); + + return { + api, + Component: () => { + const [selectedFieldNames, renderDataViews] = useBatchedPublishingSubjects( + fieldListStateManager.api.selectedFieldNames$, + fieldListStateManager.api.dataViews$ + ); + const { euiTheme } = useEuiTheme(); + + const selectedDataView = renderDataViews?.[0]; + + // On destroy + useEffect(() => { + return () => { + subscriptions.unsubscribe(); + }; + }, []); + + return ( + + + + fieldListStateManager.api.setDataViewId(nextSelection) + } + trigger={{ + label: + selectedDataView?.getName() ?? + i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', { + defaultMessage: 'Please select a data view', + }), + }} + /> + + + {selectedDataView ? ( + + fieldListStateManager.api.setSelectedFieldNames([ + ...(selectedFieldNames ?? []), + field.name, + ]) + } + onRemoveFieldFromWorkspace={(field) => { + fieldListStateManager.api.setSelectedFieldNames( + (selectedFieldNames ?? []).filter((name) => name !== field.name) + ); + }} + /> + ) : null} + + + ); + }, + }; + }, + }; + return fieldListEmbeddableFactory; +}; diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx deleted file mode 100644 index c2af34ce5f87d..0000000000000 --- a/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx +++ /dev/null @@ -1,217 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import type { Reference } from '@kbn/content-management-utils'; -import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { DataView } from '@kbn/data-views-plugin/common'; -import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/public'; -import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; -import { i18n } from '@kbn/i18n'; -import { initializeTitleManager, useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; -import { LazyDataViewPicker, withSuspense } from '@kbn/presentation-util-plugin/public'; -import { - UnifiedFieldListSidebarContainer, - type UnifiedFieldListSidebarContainerProps, -} from '@kbn/unified-field-list'; -import { cloneDeep } from 'lodash'; -import React, { useEffect } from 'react'; -import { BehaviorSubject, skip, Subscription, switchMap } from 'rxjs'; -import { FIELD_LIST_DATA_VIEW_REF_NAME, FIELD_LIST_ID } from './constants'; -import { - FieldListApi, - Services, - FieldListSerializedStateState, - FieldListRuntimeState, -} from './types'; - -const DataViewPicker = withSuspense(LazyDataViewPicker, null); - -const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => { - return { - originatingApp: '', - localStorageKeyPrefix: 'examples', - timeRangeUpdatesType: 'timefilter', - compressed: true, - showSidebarToggleButton: false, - disablePopularFields: true, - }; -}; - -export const getFieldListFactory = ( - core: CoreStart, - { dataViews, data, charts, fieldFormats }: Services -) => { - const fieldListEmbeddableFactory: ReactEmbeddableFactory< - FieldListSerializedStateState, - FieldListRuntimeState, - FieldListApi - > = { - type: FIELD_LIST_ID, - deserializeState: (state) => { - const serializedState = cloneDeep(state.rawState); - // inject the reference - const dataViewIdRef = state.references?.find( - (ref) => ref.name === FIELD_LIST_DATA_VIEW_REF_NAME - ); - // if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this) - if (dataViewIdRef && serializedState && !serializedState.dataViewId) { - serializedState.dataViewId = dataViewIdRef?.id; - } - return serializedState; - }, - buildEmbeddable: async (initialState, buildApi) => { - const subscriptions = new Subscription(); - const titleManager = initializeTitleManager(initialState); - - // set up data views - const [allDataViews, defaultDataViewId] = await Promise.all([ - dataViews.getIdsWithTitle(), - dataViews.getDefaultId(), - ]); - if (!defaultDataViewId || allDataViews.length === 0) { - throw new Error( - i18n.translate('embeddableExamples.unifiedFieldList.noDefaultDataViewErrorMessage', { - defaultMessage: 'The field list must be used with at least one Data View present', - }) - ); - } - const initialDataViewId = initialState.dataViewId ?? defaultDataViewId; - const initialDataView = await dataViews.get(initialDataViewId); - const selectedDataViewId$ = new BehaviorSubject(initialDataViewId); - const dataViews$ = new BehaviorSubject([initialDataView]); - const selectedFieldNames$ = new BehaviorSubject( - initialState.selectedFieldNames - ); - - subscriptions.add( - selectedDataViewId$ - .pipe( - skip(1), - switchMap((dataViewId) => dataViews.get(dataViewId ?? defaultDataViewId)) - ) - .subscribe((nextSelectedDataView) => { - dataViews$.next([nextSelectedDataView]); - selectedFieldNames$.next([]); - }) - ); - - const api = buildApi( - { - ...titleManager.api, - dataViews$, - selectedFields: selectedFieldNames$, - serializeState: () => { - const dataViewId = selectedDataViewId$.getValue(); - const references: Reference[] = dataViewId - ? [ - { - type: DATA_VIEW_SAVED_OBJECT_TYPE, - name: FIELD_LIST_DATA_VIEW_REF_NAME, - id: dataViewId, - }, - ] - : []; - return { - rawState: { - ...titleManager.serialize(), - // here we skip serializing the dataViewId, because the reference contains that information. - selectedFieldNames: selectedFieldNames$.getValue(), - }, - references, - }; - }, - }, - { - ...titleManager.comparators, - dataViewId: [selectedDataViewId$, (value) => selectedDataViewId$.next(value)], - selectedFieldNames: [ - selectedFieldNames$, - (value) => selectedFieldNames$.next(value), - (a, b) => { - return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? ''); - }, - ], - } - ); - - return { - api, - Component: () => { - const [renderDataViews, selectedFieldNames] = useBatchedPublishingSubjects( - dataViews$, - selectedFieldNames$ - ); - const { euiTheme } = useEuiTheme(); - - const selectedDataView = renderDataViews?.[0]; - - // On destroy - useEffect(() => { - return () => { - subscriptions.unsubscribe(); - }; - }, []); - - return ( - - - { - selectedDataViewId$.next(nextSelection); - }} - trigger={{ - label: - selectedDataView?.getName() ?? - i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', { - defaultMessage: 'Please select a data view', - }), - }} - /> - - - {selectedDataView ? ( - - selectedFieldNames$.next([ - ...(selectedFieldNames$.getValue() ?? []), - field.name, - ]) - } - onRemoveFieldFromWorkspace={(field) => { - selectedFieldNames$.next( - (selectedFieldNames$.getValue() ?? []).filter((name) => name !== field.name) - ); - }} - /> - ) : null} - - - ); - }, - }; - }, - }; - return fieldListEmbeddableFactory; -}; diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/register_field_list_embeddable.ts b/examples/embeddable_examples/public/react_embeddables/field_list/register_field_list_embeddable.ts index 156742ad05f0a..5dd3f3712a886 100644 --- a/examples/embeddable_examples/public/react_embeddables/field_list/register_field_list_embeddable.ts +++ b/examples/embeddable_examples/public/react_embeddables/field_list/register_field_list_embeddable.ts @@ -9,9 +9,8 @@ import { DashboardStart, PanelPlacementStrategy } from '@kbn/dashboard-plugin/public'; import { FIELD_LIST_ID } from './constants'; -import { FieldListSerializedStateState } from './types'; -const getPanelPlacementSetting = (serializedState?: FieldListSerializedStateState) => { +const getPanelPlacementSetting = () => { // Consider using the serialized state to determine the width, height, and strategy return { width: 12, diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/types.ts b/examples/embeddable_examples/public/react_embeddables/field_list/types.ts index f11d07ea0242c..aeca09ce877e2 100644 --- a/examples/embeddable_examples/public/react_embeddables/field_list/types.ts +++ b/examples/embeddable_examples/public/react_embeddables/field_list/types.ts @@ -7,27 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ChartsPluginStart } from '@kbn/charts-plugin/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; -import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import { PublishesDataViews, SerializedTitles } from '@kbn/presentation-publishing'; -import { PublishesSelectedFields } from './publishes_selected_fields'; +import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import type { PublishesUnsavedChanges, SerializedTitles } from '@kbn/presentation-publishing'; -export type FieldListSerializedStateState = SerializedTitles & { +export interface FieldListState { dataViewId?: string; selectedFieldNames?: string[]; +} + +export type FieldListRuntimeState = FieldListState & { + dataViews?: DataView[]; }; -export type FieldListRuntimeState = FieldListSerializedStateState; +export type FieldListSerializedState = SerializedTitles & FieldListState; -export type FieldListApi = DefaultEmbeddableApi< - FieldListSerializedStateState, - FieldListSerializedStateState -> & - PublishesSelectedFields & - PublishesDataViews; +export type FieldListApi = DefaultEmbeddableApi & PublishesUnsavedChanges; export interface Services { dataViews: DataViewsPublicPluginStart; diff --git a/examples/embeddable_examples/public/react_embeddables/register_saved_object_example.ts b/examples/embeddable_examples/public/react_embeddables/register_saved_object_example.ts index 15082ef701693..3da19ec1de19c 100644 --- a/examples/embeddable_examples/public/react_embeddables/register_saved_object_example.ts +++ b/examples/embeddable_examples/public/react_embeddables/register_saved_object_example.ts @@ -18,7 +18,9 @@ export const registerMyEmbeddableSavedObject = (embeddableSetup: EmbeddableSetup onAdd: (container, savedObject) => { container.addNewPanel({ panelType: MY_EMBEDDABLE_TYPE, - initialState: savedObject.attributes, + serializedState: { + rawState: savedObject.attributes, + }, }); }, savedObjectType: MY_SAVED_OBJECT_TYPE, diff --git a/examples/embeddable_examples/public/react_embeddables/saved_book/book_state.ts b/examples/embeddable_examples/public/react_embeddables/saved_book/book_state.ts index 3c24a8f35dcd3..14542046528ba 100644 --- a/examples/embeddable_examples/public/react_embeddables/saved_book/book_state.ts +++ b/examples/embeddable_examples/public/react_embeddables/saved_book/book_state.ts @@ -7,40 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject } from 'rxjs'; -import { BookAttributes, BookAttributesManager } from './types'; +import { WithAllKeys } from '@kbn/presentation-publishing'; +import { BookAttributes } from './types'; -export const defaultBookAttributes: BookAttributes = { +export const defaultBookAttributes: WithAllKeys = { bookTitle: 'Pillars of the earth', authorName: 'Ken follett', numberOfPages: 973, bookSynopsis: 'A spellbinding epic set in 12th-century England, The Pillars of the Earth tells the story of the struggle to build the greatest Gothic cathedral the world has known.', }; - -export const stateManagerFromAttributes = (attributes: BookAttributes): BookAttributesManager => { - const bookTitle = new BehaviorSubject(attributes.bookTitle); - const authorName = new BehaviorSubject(attributes.authorName); - const numberOfPages = new BehaviorSubject(attributes.numberOfPages); - const bookSynopsis = new BehaviorSubject(attributes.bookSynopsis); - - return { - bookTitle, - authorName, - numberOfPages, - bookSynopsis, - comparators: { - bookTitle: [bookTitle, (val) => bookTitle.next(val)], - authorName: [authorName, (val) => authorName.next(val)], - numberOfPages: [numberOfPages, (val) => numberOfPages.next(val)], - bookSynopsis: [bookSynopsis, (val) => bookSynopsis.next(val)], - }, - }; -}; - -export const serializeBookAttributes = (stateManager: BookAttributesManager): BookAttributes => ({ - bookTitle: stateManager.bookTitle.value, - authorName: stateManager.authorName.value, - numberOfPages: stateManager.numberOfPages.value, - bookSynopsis: stateManager.bookSynopsis.value, -}); diff --git a/examples/embeddable_examples/public/react_embeddables/saved_book/create_saved_book_action.tsx b/examples/embeddable_examples/public/react_embeddables/saved_book/create_saved_book_action.tsx index 09f0e30f4a6ec..98acf57012fcb 100644 --- a/examples/embeddable_examples/public/react_embeddables/saved_book/create_saved_book_action.tsx +++ b/examples/embeddable_examples/public/react_embeddables/saved_book/create_saved_book_action.tsx @@ -10,18 +10,14 @@ import { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { apiCanAddNewPanel } from '@kbn/presentation-containers'; -import { EmbeddableApiContext } from '@kbn/presentation-publishing'; +import { EmbeddableApiContext, initializeStateManager } from '@kbn/presentation-publishing'; import { ADD_PANEL_TRIGGER, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin'; import { embeddableExamplesGrouping } from '../embeddable_examples_grouping'; -import { - defaultBookAttributes, - serializeBookAttributes, - stateManagerFromAttributes, -} from './book_state'; +import { defaultBookAttributes } from './book_state'; import { ADD_SAVED_BOOK_ACTION_ID, SAVED_BOOK_ID } from './constants'; import { openSavedBookEditor } from './saved_book_editor'; -import { BookRuntimeState } from './types'; +import { BookAttributes, BookSerializedState } from './types'; export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, core: CoreStart) => { uiActions.registerAction({ @@ -33,7 +29,10 @@ export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, c }, execute: async ({ embeddable }) => { if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError(); - const newPanelStateManager = stateManagerFromAttributes(defaultBookAttributes); + const newPanelStateManager = initializeStateManager( + defaultBookAttributes, + defaultBookAttributes + ); const { savedBookId } = await openSavedBookEditor({ attributesManager: newPanelStateManager, @@ -42,14 +41,14 @@ export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, c core, }); - const bookAttributes = serializeBookAttributes(newPanelStateManager); - const initialState: BookRuntimeState = savedBookId - ? { savedBookId, ...bookAttributes } - : { ...bookAttributes }; + const bookAttributes = newPanelStateManager.getLatestState(); + const initialState: BookSerializedState = savedBookId + ? { savedBookId } + : { attributes: bookAttributes }; - embeddable.addNewPanel({ + embeddable.addNewPanel({ panelType: SAVED_BOOK_ID, - initialState, + serializedState: { rawState: initialState }, }); }, getDisplayName: () => diff --git a/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_editor.tsx b/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_editor.tsx index b83c84610c7c6..000716250efbc 100644 --- a/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_editor.tsx +++ b/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_editor.tsx @@ -26,11 +26,11 @@ import { CoreStart } from '@kbn/core-lifecycle-browser'; import { OverlayRef } from '@kbn/core-mount-utils-browser'; import { i18n } from '@kbn/i18n'; import { tracksOverlays } from '@kbn/presentation-containers'; -import { apiHasUniqueId, useBatchedOptionalPublishingSubjects } from '@kbn/presentation-publishing'; +import { apiHasUniqueId, useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { toMountPoint } from '@kbn/react-kibana-mount'; import React, { useState } from 'react'; -import { serializeBookAttributes } from './book_state'; -import { BookApi, BookAttributesManager } from './types'; +import { StateManager } from '@kbn/presentation-publishing/state_manager/types'; +import { BookApi, BookAttributes } from './types'; import { saveBookAttributes } from './saved_book_library'; export const openSavedBookEditor = ({ @@ -40,7 +40,7 @@ export const openSavedBookEditor = ({ parent, api, }: { - attributesManager: BookAttributesManager; + attributesManager: StateManager; isCreate: boolean; core: CoreStart; parent?: unknown; @@ -52,7 +52,7 @@ export const openSavedBookEditor = ({ overlayRef.close(); }; - const initialState = serializeBookAttributes(attributesManager); + const initialState = attributesManager.getLatestState(); const overlay = core.overlays.openFlyout( toMountPoint( { // set the state back to the initial state and reject - attributesManager.authorName.next(initialState.authorName); - attributesManager.bookSynopsis.next(initialState.bookSynopsis); - attributesManager.bookTitle.next(initialState.bookTitle); - attributesManager.numberOfPages.next(initialState.numberOfPages); + attributesManager.reinitializeState(initialState); closeOverlay(overlay); }} onSubmit={async (addToLibrary: boolean) => { const savedBookId = addToLibrary - ? await saveBookAttributes( - api?.getSavedBookId(), - serializeBookAttributes(attributesManager) - ) + ? await saveBookAttributes(api?.getSavedBookId(), attributesManager.getLatestState()) : undefined; closeOverlay(overlay); @@ -104,17 +98,17 @@ export const SavedBookEditor = ({ onCancel, api, }: { - attributesManager: BookAttributesManager; + attributesManager: StateManager; isCreate: boolean; onSubmit: (addToLibrary: boolean) => Promise; onCancel: () => void; api?: BookApi; }) => { - const [authorName, synopsis, bookTitle, numberOfPages] = useBatchedOptionalPublishingSubjects( - attributesManager.authorName, - attributesManager.bookSynopsis, - attributesManager.bookTitle, - attributesManager.numberOfPages + const [authorName, synopsis, bookTitle, numberOfPages] = useBatchedPublishingSubjects( + attributesManager.api.authorName$, + attributesManager.api.bookSynopsis$, + attributesManager.api.bookTitle$, + attributesManager.api.numberOfPages$ ); const [addToLibrary, setAddToLibrary] = useState(Boolean(api?.getSavedBookId())); const [saving, setSaving] = useState(false); @@ -143,7 +137,7 @@ export const SavedBookEditor = ({ attributesManager.authorName.next(e.target.value)} + onChange={(e) => attributesManager.api.setAuthorName(e.target.value)} /> attributesManager.bookTitle.next(e.target.value)} + onChange={(e) => attributesManager.api.setBookTitle(e.target.value)} /> attributesManager.numberOfPages.next(+e.target.value)} + onChange={(e) => attributesManager.api.setNumberOfPages(+e.target.value)} /> attributesManager.bookSynopsis.next(e.target.value)} + onChange={(e) => attributesManager.api.setBookSynopsis(e.target.value)} /> diff --git a/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx index 52b20b09a95ad..42c2f16c9db76 100644 --- a/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx @@ -18,19 +18,23 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { apiHasParentApi, - getUnchangingComparator, initializeTitleManager, SerializedTitles, SerializedPanelState, useBatchedPublishingSubjects, + initializeStateManager, + titleComparators, + StateComparators, } from '@kbn/presentation-publishing'; import React from 'react'; import { PresentationContainer, apiIsPresentationContainer } from '@kbn/presentation-containers'; -import { serializeBookAttributes, stateManagerFromAttributes } from './book_state'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; +import { merge } from 'rxjs'; +import { defaultBookAttributes } from './book_state'; import { SAVED_BOOK_ID } from './constants'; import { openSavedBookEditor } from './saved_book_editor'; import { loadBookAttributes, saveBookAttributes } from './saved_book_library'; @@ -49,40 +53,50 @@ const bookSerializedStateIsByReference = ( return Boolean(state && (state as BookByReferenceSerializedState).savedBookId); }; -export const getSavedBookEmbeddableFactory = (core: CoreStart) => { - const savedBookEmbeddableFactory: ReactEmbeddableFactory< - BookSerializedState, - BookRuntimeState, - BookApi - > = { - type: SAVED_BOOK_ID, - deserializeState: async (serializedState) => { - // panel state is always stored with the parent. - const titlesState: SerializedTitles = { - title: serializedState.rawState.title, - hidePanelTitles: serializedState.rawState.hidePanelTitles, - description: serializedState.rawState.description, - }; +const bookAttributeComparators: StateComparators = { + bookTitle: 'referenceEquality', + authorName: 'referenceEquality', + bookSynopsis: 'referenceEquality', + numberOfPages: 'referenceEquality', +}; - const savedBookId = bookSerializedStateIsByReference(serializedState.rawState) - ? serializedState.rawState.savedBookId - : undefined; +const deserializeState = async ( + serializedState: SerializedPanelState +): Promise => { + // panel state is always stored with the parent. + const titlesState: SerializedTitles = { + title: serializedState.rawState.title, + hidePanelTitles: serializedState.rawState.hidePanelTitles, + description: serializedState.rawState.description, + }; - const attributes: BookAttributes = bookSerializedStateIsByReference(serializedState.rawState) - ? await loadBookAttributes(serializedState.rawState.savedBookId)! - : serializedState.rawState.attributes; + const savedBookId = bookSerializedStateIsByReference(serializedState.rawState) + ? serializedState.rawState.savedBookId + : undefined; - // Combine the serialized state from the parent with the state from the - // external store to build runtime state. - return { - ...titlesState, - ...attributes, - savedBookId, - }; - }, - buildEmbeddable: async (state, buildApi) => { - const titleManager = initializeTitleManager(state); - const bookAttributesManager = stateManagerFromAttributes(state); + const attributes: BookAttributes = bookSerializedStateIsByReference(serializedState.rawState) + ? await loadBookAttributes(serializedState.rawState.savedBookId)! + : serializedState.rawState.attributes; + + // Combine the serialized state from the parent with the state from the + // external store to build runtime state. + return { + ...titlesState, + ...attributes, + savedBookId, + }; +}; + +export const getSavedBookEmbeddableFactory = (core: CoreStart) => { + const savedBookEmbeddableFactory: EmbeddableFactory = { + type: SAVED_BOOK_ID, + buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + const state = await deserializeState(initialState); + const titleManager = initializeTitleManager(initialState.rawState); + const bookAttributesManager = initializeStateManager( + state, + defaultBookAttributes + ); const isByReference = Boolean(state.savedBookId); const serializeBook = (byReference: boolean, newId?: string) => { @@ -90,74 +104,83 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => { // if this book is currently by reference, we serialize the reference only. const bookByReferenceState: BookByReferenceSerializedState = { savedBookId: newId ?? state.savedBookId!, - ...titleManager.serialize(), + ...titleManager.getLatestState(), }; return { rawState: bookByReferenceState }; } // if this book is currently by value, we serialize the entire state. const bookByValueState: BookByValueSerializedState = { - attributes: serializeBookAttributes(bookAttributesManager), - ...titleManager.serialize(), + ...titleManager.getLatestState(), + attributes: bookAttributesManager.getLatestState(), }; return { rawState: bookByValueState }; }; - const api = buildApi( - { - ...titleManager.api, - onEdit: async () => { - openSavedBookEditor({ - attributesManager: bookAttributesManager, - parent: api.parentApi, - isCreate: false, - core, - api, - }).then((result) => { - const nextIsByReference = Boolean(result.savedBookId); - - // if the by reference state has changed during this edit, reinitialize the panel. - if ( - nextIsByReference !== isByReference && - apiIsPresentationContainer(api.parentApi) - ) { - api.parentApi.replacePanel(api.uuid, { - serializedState: serializeBook(nextIsByReference, result.savedBookId), - panelType: api.type, - }); - } - }); - }, - isEditingEnabled: () => true, - getTypeDisplayName: () => - i18n.translate('embeddableExamples.savedbook.editBook.displayName', { - defaultMessage: 'book', - }), - serializeState: () => serializeBook(isByReference), - - // library transforms - getSavedBookId: () => state.savedBookId, - saveToLibrary: async (newTitle: string) => { - bookAttributesManager.bookTitle.next(newTitle); - const newId = await saveBookAttributes( - undefined, - serializeBookAttributes(bookAttributesManager) - ); - return newId; - }, - checkForDuplicateTitle: async (title) => {}, - getSerializedStateByValue: () => - serializeBook(false) as SerializedPanelState, - getSerializedStateByReference: (newId) => - serializeBook(true, newId) as SerializedPanelState, - canLinkToLibrary: async () => !isByReference, - canUnlinkFromLibrary: async () => isByReference, + const serializeState = () => serializeBook(isByReference); + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState, + anyStateChange$: merge(titleManager.anyStateChange$, bookAttributesManager.anyStateChange$), + getComparators: () => { + return { + ...titleComparators, + ...bookAttributeComparators, + savedBookId: 'skip', // saved book id will not change over the lifetime of the embeddable. + }; }, - { - savedBookId: getUnchangingComparator(), // saved book id will not change over the lifetime of the embeddable. - ...bookAttributesManager.comparators, - ...titleManager.comparators, - } - ); + onReset: async (lastSaved) => { + const lastRuntimeState = lastSaved ? await deserializeState(lastSaved) : {}; + titleManager.reinitializeState(lastRuntimeState); + bookAttributesManager.reinitializeState(lastRuntimeState); + }, + }); + + const api = finalizeApi({ + ...unsavedChangesApi, + ...titleManager.api, + onEdit: async () => { + openSavedBookEditor({ + attributesManager: bookAttributesManager, + parent: api.parentApi, + isCreate: false, + core, + api, + }).then((result) => { + const nextIsByReference = Boolean(result.savedBookId); + + // if the by reference state has changed during this edit, reinitialize the panel. + if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) { + api.parentApi.replacePanel(api.uuid, { + serializedState: serializeBook(nextIsByReference, result.savedBookId), + panelType: api.type, + }); + } + }); + }, + isEditingEnabled: () => true, + getTypeDisplayName: () => + i18n.translate('embeddableExamples.savedbook.editBook.displayName', { + defaultMessage: 'book', + }), + serializeState, + + // library transforms + getSavedBookId: () => state.savedBookId, + saveToLibrary: async (newTitle: string) => { + bookAttributesManager.api.setBookTitle(newTitle); + const newId = await saveBookAttributes(undefined, bookAttributesManager.getLatestState()); + return newId; + }, + checkForDuplicateTitle: async (title) => {}, + getSerializedStateByValue: () => + serializeBook(false) as SerializedPanelState, + getSerializedStateByReference: (newId) => + serializeBook(true, newId) as SerializedPanelState, + canLinkToLibrary: async () => !isByReference, + canUnlinkFromLibrary: async () => isByReference, + }); const showLibraryCallout = apiHasParentApi(api) && @@ -167,10 +190,10 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => { api, Component: () => { const [authorName, numberOfPages, bookTitle, synopsis] = useBatchedPublishingSubjects( - bookAttributesManager.authorName, - bookAttributesManager.numberOfPages, - bookAttributesManager.bookTitle, - bookAttributesManager.bookSynopsis + bookAttributesManager.api.authorName$, + bookAttributesManager.api.numberOfPages$, + bookAttributesManager.api.bookTitle$, + bookAttributesManager.api.bookSynopsis$ ); const { euiTheme } = useEuiTheme(); diff --git a/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts b/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts index ff590a20ac2d4..40cb0a8a07753 100644 --- a/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts +++ b/examples/embeddable_examples/public/react_embeddables/saved_book/types.ts @@ -11,10 +11,9 @@ import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import { HasEditCapabilities, HasLibraryTransforms, + PublishesUnsavedChanges, SerializedTitles, - StateComparators, } from '@kbn/presentation-publishing'; -import { BehaviorSubject } from 'rxjs'; export interface BookAttributes { bookTitle: string; @@ -23,10 +22,6 @@ export interface BookAttributes { bookSynopsis?: string; } -export type BookAttributesManager = { - [key in keyof Required]: BehaviorSubject; -} & { comparators: StateComparators }; - export interface BookByValueSerializedState { attributes: BookAttributes; } @@ -50,7 +45,8 @@ export interface BookRuntimeState Partial, SerializedTitles {} -export type BookApi = DefaultEmbeddableApi & +export type BookApi = DefaultEmbeddableApi & HasEditCapabilities & HasLibraryTransforms & - HasSavedBookId; + HasSavedBookId & + PublishesUnsavedChanges; diff --git a/examples/embeddable_examples/public/react_embeddables/search/constants.ts b/examples/embeddable_examples/public/react_embeddables/search/constants.ts index 2da8629afed71..4c7cae2a1ed30 100644 --- a/examples/embeddable_examples/public/react_embeddables/search/constants.ts +++ b/examples/embeddable_examples/public/react_embeddables/search/constants.ts @@ -7,5 +7,5 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export const SEARCH_EMBEDDABLE_ID = 'searchEmbeddableDemo'; +export const SEARCH_EMBEDDABLE_TYPE = 'search_embeddable'; export const ADD_SEARCH_ACTION_ID = 'create_search_demo'; diff --git a/examples/embeddable_examples/public/react_embeddables/search/register_add_search_panel_action.tsx b/examples/embeddable_examples/public/react_embeddables/search/register_add_search_panel_action.tsx index b49fc44eefdc3..744236e33213b 100644 --- a/examples/embeddable_examples/public/react_embeddables/search/register_add_search_panel_action.tsx +++ b/examples/embeddable_examples/public/react_embeddables/search/register_add_search_panel_action.tsx @@ -15,7 +15,7 @@ import { ADD_PANEL_TRIGGER, } from '@kbn/ui-actions-plugin/public'; import { embeddableExamplesGrouping } from '../embeddable_examples_grouping'; -import { ADD_SEARCH_ACTION_ID, SEARCH_EMBEDDABLE_ID } from './constants'; +import { ADD_SEARCH_ACTION_ID, SEARCH_EMBEDDABLE_TYPE } from './constants'; import { SearchSerializedState } from './types'; export const registerAddSearchPanelAction = (uiActions: UiActionsStart) => { @@ -31,8 +31,7 @@ export const registerAddSearchPanelAction = (uiActions: UiActionsStart) => { if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError(); embeddable.addNewPanel( { - panelType: SEARCH_EMBEDDABLE_ID, - initialState: {}, + panelType: SEARCH_EMBEDDABLE_TYPE, }, true ); diff --git a/examples/embeddable_examples/public/react_embeddables/search/register_search_embeddable.ts b/examples/embeddable_examples/public/react_embeddables/search/register_search_embeddable.ts index 1c47e1eaf90a6..4eee890c4a1a5 100644 --- a/examples/embeddable_examples/public/react_embeddables/search/register_search_embeddable.ts +++ b/examples/embeddable_examples/public/react_embeddables/search/register_search_embeddable.ts @@ -8,11 +8,11 @@ */ import { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; -import { SEARCH_EMBEDDABLE_ID } from './constants'; +import { SEARCH_EMBEDDABLE_TYPE } from './constants'; import { Services } from './types'; export function registerSearchEmbeddable(embeddable: EmbeddableSetup, services: Promise) { - embeddable.registerReactEmbeddableFactory(SEARCH_EMBEDDABLE_ID, async () => { + embeddable.registerReactEmbeddableFactory(SEARCH_EMBEDDABLE_TYPE, async () => { const { getSearchEmbeddableFactory } = await import('./search_react_embeddable'); return getSearchEmbeddableFactory(await services); }); diff --git a/examples/embeddable_examples/public/react_embeddables/search/search_embeddable_renderer.tsx b/examples/embeddable_examples/public/react_embeddables/search/search_embeddable_renderer.tsx index aab2a39e7eb00..3583ba181d040 100644 --- a/examples/embeddable_examples/public/react_embeddables/search/search_embeddable_renderer.tsx +++ b/examples/embeddable_examples/public/react_embeddables/search/search_embeddable_renderer.tsx @@ -9,10 +9,10 @@ import React, { useMemo } from 'react'; import { TimeRange } from '@kbn/es-query'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { useSearchApi } from '@kbn/presentation-publishing'; import type { SearchApi, SearchSerializedState } from './types'; -import { SEARCH_EMBEDDABLE_ID } from './constants'; +import { SEARCH_EMBEDDABLE_TYPE } from './constants'; interface Props { timeRange?: TimeRange; @@ -32,8 +32,8 @@ export function SearchEmbeddableRenderer(props: Props) { const searchApi = useSearchApi({ timeRange: props.timeRange }); return ( - - type={SEARCH_EMBEDDABLE_ID} + + type={SEARCH_EMBEDDABLE_TYPE} getParentApi={() => ({ ...searchApi, getSerializedStateForChild: () => initialState, diff --git a/examples/embeddable_examples/public/react_embeddables/search/search_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/search/search_react_embeddable.tsx index afddde85e5367..92d6dacf3f579 100644 --- a/examples/embeddable_examples/public/react_embeddables/search/search_react_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/search/search_react_embeddable.tsx @@ -10,25 +10,26 @@ import { EuiBadge, EuiStat, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { DataView } from '@kbn/data-views-plugin/common'; -import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { fetch$, - initializeTimeRange, + initializeTimeRangeManager, + timeRangeComparators, useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; import React, { useEffect } from 'react'; import { BehaviorSubject, switchMap, tap } from 'rxjs'; -import { SEARCH_EMBEDDABLE_ID } from './constants'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; +import { SEARCH_EMBEDDABLE_TYPE } from './constants'; import { getCount } from './get_count'; -import { SearchApi, Services, SearchSerializedState, SearchRuntimeState } from './types'; +import { SearchApi, Services, SearchSerializedState } from './types'; export const getSearchEmbeddableFactory = (services: Services) => { - const factory: ReactEmbeddableFactory = { - type: SEARCH_EMBEDDABLE_ID, - deserializeState: (state) => state.rawState, - buildEmbeddable: async (state, buildApi, uuid, parentApi) => { - const timeRange = initializeTimeRange(state); + const factory: EmbeddableFactory = { + type: SEARCH_EMBEDDABLE_TYPE, + buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + const timeRangeManager = initializeTimeRangeManager(initialState.rawState); const defaultDataView = await services.dataViews.getDefaultDataView(); const dataViews$ = new BehaviorSubject( defaultDataView ? [defaultDataView] : undefined @@ -46,25 +47,46 @@ export const getSearchEmbeddableFactory = (services: Services) => { ); } - const api = buildApi( - { - ...timeRange.api, - blockingError$, - dataViews$, - dataLoading$, - serializeState: () => { - return { - rawState: { - ...timeRange.serialize(), - }, - references: [], - }; + function serializeState() { + return { + rawState: { + ...timeRangeManager.getLatestState(), }, + // references: if this embeddable had any references - this is where we would extract them. + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState, + anyStateChange$: timeRangeManager.anyStateChange$, + getComparators: () => { + /** + * comparators are provided in a callback to allow embeddables to change how their state is compared based + * on the values of other state. For instance, if a saved object ID is present (by reference), the embeddable + * may want to skip comparison of certain state. + */ + return timeRangeComparators; }, - { - ...timeRange.comparators, - } - ); + onReset: (lastSaved) => { + /** + * if this embeddable had a difference between its runtime and serialized state, we could run the 'deserializeState' + * function here before resetting. onReset can be async so to support a potential async deserialize function. + */ + + timeRangeManager.reinitializeState(lastSaved?.rawState); + }, + }); + + const api = finalizeApi({ + blockingError$, + dataViews$, + dataLoading$, + ...unsavedChangesApi, + ...timeRangeManager.api, + serializeState, + }); const count$ = new BehaviorSubject(0); let prevRequestAbortController: AbortController | undefined; diff --git a/examples/embeddable_examples/public/react_embeddables/search/types.ts b/examples/embeddable_examples/public/react_embeddables/search/types.ts index 8c83c5ef768df..936476bde038a 100644 --- a/examples/embeddable_examples/public/react_embeddables/search/types.ts +++ b/examples/embeddable_examples/public/react_embeddables/search/types.ts @@ -21,8 +21,6 @@ import { export type SearchSerializedState = SerializedTimeRange; -export type SearchRuntimeState = SearchSerializedState; - export type SearchApi = DefaultEmbeddableApi & PublishesDataViews & PublishesDataLoading & diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 57a4bbb1f5773..0b74ba9a98db1 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -30,7 +30,7 @@ import { css } from '@emotion/react'; import { AppMountParameters } from '@kbn/core-application-browser'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { AddEmbeddableButton } from '@kbn/embeddable-examples-plugin/public'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { GridLayout, GridLayoutData, GridSettings } from '@kbn/grid-layout'; import { i18n } from '@kbn/i18n'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; @@ -118,7 +118,7 @@ export const GridExample = ({ const currentPanels = mockDashboardApi.panels$.getValue(); return ( - true, + getChildApi: () => { + throw new Error('getChildApi implemenation not provided'); + }, }; // only run onMount // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx b/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx index a97b30e7bd248..02e4e9722c7c6 100644 --- a/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx +++ b/examples/portable_dashboards_example/public/dashboard_with_controls_example.tsx @@ -29,7 +29,6 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView .addNewPanel( { panelType: FILTER_DEBUGGER_EMBEDDABLE_ID, - initialState: {}, }, true ) diff --git a/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx b/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx index 9f40df13c19b1..2c98d3bacfaa3 100644 --- a/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx +++ b/examples/portable_dashboards_example/public/filter_debugger_embeddable.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { css } from '@emotion/react'; -import { DefaultEmbeddableApi, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { DefaultEmbeddableApi, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { PublishesUnifiedSearch, useStateFromPublishingSubject, @@ -19,23 +19,17 @@ import { FILTER_DEBUGGER_EMBEDDABLE_ID } from './constants'; export type Api = DefaultEmbeddableApi<{}>; -export const factory: ReactEmbeddableFactory<{}, {}, Api> = { +export const factory: EmbeddableFactory<{}, Api> = { type: FILTER_DEBUGGER_EMBEDDABLE_ID, - deserializeState: () => { - return {}; - }, - buildEmbeddable: async (state, buildApi, uuid, parentApi) => { - const api = buildApi( - { - serializeState: () => { - return { - rawState: {}, - references: [], - }; - }, + buildEmbeddable: async ({ finalizeApi, parentApi }) => { + const api = finalizeApi({ + serializeState: () => { + return { + rawState: {}, + references: [], + }; }, - {} - ); + }); return { api, diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f7c9dadcbf478..60efad6fa3c53 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -90,7 +90,7 @@ pageLoadAssetSize: lens: 76079 licenseManagement: 41817 licensing: 29004 - links: 8200 + links: 9000 lists: 22900 logsDataAccess: 16759 logsShared: 281060 diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.test.tsx b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.test.tsx index cfe5442f5473a..022199a49ef14 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.test.tsx +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.test.tsx @@ -41,7 +41,7 @@ const controlGroupMock = getControlGroupMock(); const updateControlGroupInputMock = (newState: ControlGroupRuntimeState) => { act(() => { - controlGroupMock.snapshotRuntimeState.mockReturnValue(newState); + controlGroupMock.getInput.mockReturnValue(newState); controlGroupFilterStateMock$.next(newState); }); }; diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.tsx b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.tsx index b7d11652a800a..f22d61f628c6b 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.tsx +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/filter_group.tsx @@ -342,7 +342,7 @@ export const FilterGroup = (props: PropsWithChildren) => { const upsertPersistableControls = useCallback(async () => { if (!controlGroup) return; - const currentPanels = getFilterItemObjListFromControlState(controlGroup.snapshotRuntimeState()); + const currentPanels = getFilterItemObjListFromControlState(controlGroup.getInput()); const reorderedControls = reorderControlsWithDefaultControls({ controls: currentPanels, diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/hooks/use_control_group_sync_to_local_storage.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/hooks/use_control_group_sync_to_local_storage.ts index d320c5406849f..6d1f919441aaf 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/hooks/use_control_group_sync_to_local_storage.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/hooks/use_control_group_sync_to_local_storage.ts @@ -9,7 +9,7 @@ import type { ControlGroupRuntimeState } from '@kbn/controls-plugin/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; interface UseControlGroupSyncToLocalStorageArgs { @@ -41,9 +41,9 @@ export const useControlGroupSyncToLocalStorage: UseControlGroupSyncToLocalStorag } }, [shouldSync, controlGroupState, storageKey]); - const getStoredControlGroupState = () => { + const getStoredControlGroupState = useCallback(() => { return (storage.current.get(storageKey) as ControlGroupRuntimeState) ?? undefined; - }; + }, [storageKey]); return { controlGroupState, diff --git a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/mocks/control_group.ts b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/mocks/control_group.ts index 119c096a3b5d0..ee9baf48d2616 100644 --- a/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/mocks/control_group.ts +++ b/src/platform/packages/shared/kbn-alerts-ui-shared/src/alert_filter_controls/mocks/control_group.ts @@ -25,6 +25,6 @@ export const getControlGroupMock = () => { openAddDataControlFlyout: jest.fn(), filters$: controlGroupFilterOutputMock$, setChainingSystem: jest.fn(), - snapshotRuntimeState: jest.fn(), + getInput: jest.fn(), }; }; diff --git a/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx b/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx index ecd3a4f18aa9a..beec37f32ccfc 100644 --- a/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx +++ b/src/platform/packages/shared/kbn-saved-search-component/src/components/saved_search.tsx @@ -8,11 +8,10 @@ */ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; import type { SearchEmbeddableSerializedState, - SearchEmbeddableRuntimeState, SearchEmbeddableApi, } from '@kbn/discover-plugin/public'; import { SerializedPanelState } from '@kbn/presentation-publishing'; @@ -200,11 +199,7 @@ const SavedSearchComponentTable: React.FC< ); return ( - + maybeId={undefined} type={SEARCH_EMBEDDABLE_TYPE} getParentApi={() => parentApi} diff --git a/src/platform/packages/shared/presentation/presentation_containers/index.ts b/src/platform/packages/shared/presentation/presentation_containers/index.ts index 4ddbe46329f4d..7fb0a712ea457 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/index.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/index.ts @@ -8,18 +8,9 @@ */ export { apiCanAddNewPanel, type CanAddNewPanel } from './interfaces/can_add_new_panel'; -export { - apiHasRuntimeChildState, - apiHasSerializedChildState, - type HasRuntimeChildState, - type HasSerializedChildState, -} from './interfaces/child_state'; +export { apiHasSerializedChildState, 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, -} from './interfaces/has_save_notification'; export { apiCanDuplicatePanels, apiCanExpandPanels, @@ -30,6 +21,10 @@ export { canTrackContentfulRender, type TrackContentfulRender, } from './interfaces/performance_trackers'; +export { + type HasLastSavedChildState, + apiHasLastSavedChildState, +} from './interfaces/last_saved_child_state'; export { apiIsPresentationContainer, combineCompatibleChildrenApis, diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/can_add_new_panel.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/can_add_new_panel.ts index f64294ca93484..687def8eadeab 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/can_add_new_panel.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/can_add_new_panel.ts @@ -13,8 +13,8 @@ import { PanelPackage } from './presentation_container'; * This API can add a new panel as a child. */ export interface CanAddNewPanel { - addNewPanel: ( - panel: PanelPackage, + addNewPanel: ( + panel: PanelPackage, displaySuccessMessage?: boolean ) => Promise; } diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts index 4245b48904314..b3109a81dfcbe 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts @@ -15,23 +15,8 @@ export interface HasSerializedChildState SerializedPanelState | undefined; } -/** - * @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state. - */ -export interface HasRuntimeChildState { - getRuntimeStateForChild: (childId: string) => Partial | undefined; -} - export const apiHasSerializedChildState = ( api: unknown ): api is HasSerializedChildState => { return Boolean(api && (api as HasSerializedChildState).getSerializedStateForChild); }; -/** - * @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state. - */ -export const apiHasRuntimeChildState = ( - api: unknown -): api is HasRuntimeChildState => { - return Boolean(api && (api as HasRuntimeChildState).getRuntimeStateForChild); -}; diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/has_save_notification.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/has_save_notification.ts deleted file mode 100644 index 21dc7f3aa9798..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/has_save_notification.ts +++ /dev/null @@ -1,18 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { Subject } from 'rxjs'; - -export interface HasSaveNotification { - saveNotification$: Subject; // a notification that state has been saved -} - -export const apiHasSaveNotification = (api: unknown): api is HasSaveNotification => { - return Boolean(api && (api as HasSaveNotification).saveNotification$); -}; diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/last_saved_child_state.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/last_saved_child_state.ts new file mode 100644 index 0000000000000..fc4622ebc12a0 --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/last_saved_child_state.ts @@ -0,0 +1,28 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { SerializedPanelState } from '@kbn/presentation-publishing'; +import { Observable } from 'rxjs'; + +export interface HasLastSavedChildState { + lastSavedStateForChild$: ( + childId: string + ) => Observable | undefined>; + getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined; +} + +export const apiHasLastSavedChildState = ( + api: unknown +): api is HasLastSavedChildState => { + return Boolean( + api && + (api as HasLastSavedChildState).lastSavedStateForChild$ && + (api as HasLastSavedChildState).getLastSavedStateForChild + ); +}; diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts index 7b76260aad188..fb2d5805a850d 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts @@ -16,24 +16,16 @@ import { import { BehaviorSubject, combineLatest, isObservable, map, Observable, of, switchMap } from 'rxjs'; import { apiCanAddNewPanel, CanAddNewPanel } from './can_add_new_panel'; -export interface PanelPackage< - SerializedStateType extends object = object, - RuntimeStateType extends object = object -> { +export interface PanelPackage { panelType: string; /** * The serialized state of this panel. */ serializedState?: SerializedPanelState; - - /** - * The runtime state of this panel. @deprecated Use `serializedState` instead. - */ - initialState?: RuntimeStateType; } -export interface PresentationContainer extends CanAddNewPanel { +export interface PresentationContainer extends CanAddNewPanel { /** * Removes a panel from the container. */ @@ -57,12 +49,19 @@ export interface PresentationContainer extends CanAddNewPanel { */ getPanelCount: () => number; + /** + * Gets a child API for the given ID. This is asynchronous and should await for the + * child API to be available. It is best practice to retrieve a child API using this method + */ + getChildApi: (uuid: string) => Promise; + /** * A publishing subject containing the child APIs of the container. Note that * children are created asynchronously. This means that the children$ observable might - * contain fewer children than the actual number of panels in the container. + * contain fewer children than the actual number of panels in the container. Use getChildApi + * to retrieve the child API for a specific panel. */ - children$: PublishingSubject<{ [key: string]: unknown }>; + children$: PublishingSubject<{ [key: string]: ApiType }>; } export const apiIsPresentationContainer = (api: unknown | null): api is PresentationContainer => { diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.test.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.test.ts index ef44deb51f43b..0be5d2d3384e0 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.test.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.test.ts @@ -13,20 +13,22 @@ import { waitFor } from '@testing-library/react'; describe('childrenUnsavedChanges$', () => { const child1Api = { - unsavedChanges$: new BehaviorSubject(undefined), - resetUnsavedChanges: () => true, + uuid: 'child1', + hasUnsavedChanges$: new BehaviorSubject(false), + resetUnsavedChanges: () => undefined, }; const child2Api = { - unsavedChanges$: new BehaviorSubject(undefined), - resetUnsavedChanges: () => true, + uuid: 'child2', + hasUnsavedChanges$: new BehaviorSubject(false), + resetUnsavedChanges: () => undefined, }; const children$ = new BehaviorSubject<{ [key: string]: unknown }>({}); const onFireMock = jest.fn(); beforeEach(() => { onFireMock.mockReset(); - child1Api.unsavedChanges$.next(undefined); - child2Api.unsavedChanges$.next(undefined); + child1Api.hasUnsavedChanges$.next(false); + child2Api.hasUnsavedChanges$.next(false); children$.next({ child1: child1Api, child2: child2Api, @@ -40,7 +42,18 @@ describe('childrenUnsavedChanges$', () => { () => { expect(onFireMock).toHaveBeenCalledTimes(1); const childUnsavedChanges = onFireMock.mock.calls[0][0]; - expect(childUnsavedChanges).toBeUndefined(); + expect(childUnsavedChanges).toMatchInlineSnapshot(` + Array [ + Object { + "hasUnsavedChanges": false, + "uuid": "child1", + }, + Object { + "hasUnsavedChanges": false, + "uuid": "child2", + }, + ] + `); }, { interval: DEBOUNCE_TIME + 1, @@ -61,19 +74,24 @@ describe('childrenUnsavedChanges$', () => { } ); - child1Api.unsavedChanges$.next({ - key1: 'modified value', - }); + child1Api.hasUnsavedChanges$.next(true); await waitFor( () => { expect(onFireMock).toHaveBeenCalledTimes(2); const childUnsavedChanges = onFireMock.mock.calls[1][0]; - expect(childUnsavedChanges).toEqual({ - child1: { - key1: 'modified value', - }, - }); + expect(childUnsavedChanges).toMatchInlineSnapshot(` + Array [ + Object { + "hasUnsavedChanges": true, + "uuid": "child1", + }, + Object { + "hasUnsavedChanges": false, + "uuid": "child2", + }, + ] + `); }, { interval: DEBOUNCE_TIME + 1, @@ -98,8 +116,9 @@ describe('childrenUnsavedChanges$', () => { children$.next({ ...children$.value, child3: { - unsavedChanges$: new BehaviorSubject({ key1: 'modified value' }), - resetUnsavedChanges: () => true, + uuid: 'child3', + hasUnsavedChanges$: new BehaviorSubject(true), + resetUnsavedChanges: () => undefined, }, }); @@ -107,11 +126,22 @@ describe('childrenUnsavedChanges$', () => { () => { expect(onFireMock).toHaveBeenCalledTimes(2); const childUnsavedChanges = onFireMock.mock.calls[1][0]; - expect(childUnsavedChanges).toEqual({ - child3: { - key1: 'modified value', - }, - }); + expect(childUnsavedChanges).toMatchInlineSnapshot(` + Array [ + Object { + "hasUnsavedChanges": false, + "uuid": "child1", + }, + Object { + "hasUnsavedChanges": false, + "uuid": "child2", + }, + Object { + "hasUnsavedChanges": true, + "uuid": "child3", + }, + ] + `); }, { interval: DEBOUNCE_TIME + 1, diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts index 2e1fdd53c622c..c0a498a06bd66 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts @@ -9,15 +9,22 @@ 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'; +import { + apiHasUniqueId, + apiPublishesUnsavedChanges, + HasUniqueId, + PublishesUnsavedChanges, + PublishingSubject, +} from '@kbn/presentation-publishing'; export const DEBOUNCE_TIME = 100; /** * Create an observable stream of unsaved changes from all react embeddable children */ -export function childrenUnsavedChanges$(children$: PresentationContainer['children$']) { +export function childrenUnsavedChanges$( + children$: PublishingSubject<{ [key: string]: Api }> +) { return children$.pipe( map((children) => Object.keys(children)), distinctUntilChanged(deepEqual), @@ -25,27 +32,20 @@ export function childrenUnsavedChanges$(children$: PresentationContainer['childr // 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]>; + const childrenThatPublishUnsavedChanges = Object.values(children$.value).filter( + (child) => apiPublishesUnsavedChanges(child) && apiHasUniqueId(child) + ) as Array; return childrenThatPublishUnsavedChanges.length === 0 ? of([]) : combineLatest( - childrenThatPublishUnsavedChanges.map(([childId, child]) => - child.unsavedChanges$.pipe(map((unsavedChanges) => ({ childId, unsavedChanges }))) + childrenThatPublishUnsavedChanges.map((child) => + child.hasUnsavedChanges$.pipe( + map((hasUnsavedChanges) => ({ uuid: child.uuid, hasUnsavedChanges })) + ) ) ); }), - 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; - }) + debounceTime(DEBOUNCE_TIME) ); } diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts deleted file mode 100644 index 5576942dece82..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts +++ /dev/null @@ -1,89 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", 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'; -import { waitFor } from '@testing-library/react'; - -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 waitFor( - () => - expect(api?.unsavedChanges$.value).toEqual({ - key1: 'modified key1 value', - }), - { - interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1, - } - ); - }); - - test('should have no unsaved changes after save', async () => { - key1$.next('modified key1 value'); - await waitFor(() => expect(api?.unsavedChanges$.value).not.toBeUndefined(), { - interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1, - }); - - // trigger save - parentApi.saveNotification$.next(); - - await waitFor(() => expect(api?.unsavedChanges$.value).toBeUndefined(), { - interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1, - }); - }); - - test('should have no unsaved changes after reset', async () => { - key1$.next('modified key1 value'); - await waitFor(() => expect(api?.unsavedChanges$.value).not.toBeUndefined(), { - interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1, - }); - - // trigger reset - api?.resetUnsavedChanges(); - - await waitFor(() => expect(api?.unsavedChanges$.value).toBeUndefined(), { - interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1, - }); - }); -}); diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts index 84ec84ef601c2..44833d994f75b 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts @@ -8,111 +8,56 @@ */ import { - BehaviorSubject, - combineLatest, - combineLatestWith, - debounceTime, - map, - Subscription, -} from 'rxjs'; -import { - getInitialValuesFromComparators, PublishesUnsavedChanges, - PublishingSubject, - runComparators, + SerializedPanelState, StateComparators, - HasSnapshottableState, + areComparatorsEqual, } from '@kbn/presentation-publishing'; -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()); - }) - ); +import { MaybePromise } from '@kbn/utility-types'; +import { Observable, combineLatestWith, debounceTime, map, of } from 'rxjs'; +import { apiHasLastSavedChildState } from '../last_saved_child_state'; + +const UNSAVED_CHANGES_DEBOUNCE = 100; + +export const initializeUnsavedChanges = ({ + uuid, + onReset, + parentApi, + getComparators, + defaultState, + serializeState, + anyStateChange$, +}: { + uuid: string; + parentApi: unknown; + anyStateChange$: Observable; + serializeState: () => SerializedPanelState; + getComparators: () => StateComparators; + defaultState?: Partial; + onReset: (lastSavedPanelState?: SerializedPanelState) => MaybePromise; +}): PublishesUnsavedChanges => { + if (!apiHasLastSavedChildState(parentApi)) { + return { + hasUnsavedChanges$: of(false), + resetUnsavedChanges: () => Promise.resolve(), + }; } - 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( - 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) - ); - }) + const hasUnsavedChanges$ = anyStateChange$.pipe( + combineLatestWith( + parentApi.lastSavedStateForChild$(uuid).pipe(map((panelState) => panelState?.rawState)) + ), + debounceTime(UNSAVED_CHANGES_DEBOUNCE), + map(([, lastSavedState]) => { + const currentState = serializeState().rawState; + return !areComparatorsEqual(getComparators(), lastSavedState, currentState, defaultState); + }) ); - return { - api: { - unsavedChanges$, - resetUnsavedChanges: () => { - const lastSaved = lastSavedState$.getValue(); - - // Do not reset to undefined or empty last saved state - // Temporary fix for https://github.com/elastic/kibana/issues/201627 - // TODO remove when architecture fix resolves issue. - if (comparatorKeys.length && (!lastSaved || Object.keys(lastSaved).length === 0)) { - return false; - } - - 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]); - } - return true; - }, - snapshotRuntimeState, - } as PublishesUnsavedChanges & HasSnapshottableState, - cleanup: () => { - subscriptions.forEach((subscription) => subscription.unsubscribe()); - }, + const resetUnsavedChanges = async () => { + const lastSavedState = parentApi.getLastSavedStateForChild(uuid); + await onReset(lastSavedState); }; + + return { hasUnsavedChanges$, resetUnsavedChanges }; }; diff --git a/src/platform/packages/shared/presentation/presentation_containers/mocks.ts b/src/platform/packages/shared/presentation/presentation_containers/mocks.ts index 91e056449f9ad..d08a0f2375912 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/mocks.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/mocks.ts @@ -15,6 +15,7 @@ export const getMockPresentationContainer = (): PresentationContainer => { removePanel: jest.fn(), addNewPanel: jest.fn(), replacePanel: jest.fn(), + getChildApi: jest.fn(), getPanelCount: jest.fn(), children$: new BehaviorSubject<{ [key: string]: unknown }>({}), }; diff --git a/src/platform/packages/shared/presentation/presentation_containers/tsconfig.json b/src/platform/packages/shared/presentation/presentation_containers/tsconfig.json index e138a22926a89..a3d192d23ce7c 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/tsconfig.json +++ b/src/platform/packages/shared/presentation/presentation_containers/tsconfig.json @@ -9,5 +9,6 @@ "kbn_references": [ "@kbn/presentation-publishing", "@kbn/core-mount-utils-browser", + "@kbn/utility-types", ] } diff --git a/src/platform/packages/shared/presentation/presentation_publishing/comparators/fallback_comparator.ts b/src/platform/packages/shared/presentation/presentation_publishing/comparators/fallback_comparator.ts deleted file mode 100644 index d5ce878c99746..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_publishing/comparators/fallback_comparator.ts +++ /dev/null @@ -1,25 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { BehaviorSubject } from 'rxjs'; -import { PublishingSubject } from '../publishing_subject'; -import { ComparatorDefinition } from './types'; - -/** - * Comparators are required for every runtime state key. Occasionally, a comparator may - * actually be optional. In those cases, implementors can fall back to this blank definition - * which will always return 'true'. - */ -export const getUnchangingComparator = < - State extends object, - Key extends keyof State ->(): ComparatorDefinition => { - const subj = new BehaviorSubject(null as never); - return [subj as unknown as PublishingSubject, () => {}, () => true]; -}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/comparators/state_comparators.ts b/src/platform/packages/shared/presentation/presentation_publishing/comparators/state_comparators.ts deleted file mode 100644 index a2185185a3a9e..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_publishing/comparators/state_comparators.ts +++ /dev/null @@ -1,45 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { StateComparators } from './types'; - -const defaultComparator = (a: T, b: T) => a === b; - -export const getInitialValuesFromComparators = ( - comparators: StateComparators, - comparatorKeys: Array -) => { - const initialValues: Partial = {}; - for (const key of comparatorKeys) { - const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject - initialValues[key] = comparatorSubject?.value; - } - return initialValues; -}; - -export const runComparators = ( - comparators: StateComparators, - comparatorKeys: Array, - lastSavedState: StateType | undefined, - latestState: Partial -) => { - if (!lastSavedState || Object.keys(latestState).length === 0) { - // if we have no last saved state, everything is considered a change - return latestState; - } - const latestChanges: Partial = {}; - for (const key of comparatorKeys) { - const customComparator = comparators[key]?.[2]; // 2nd element of the tuple is the custom comparator - const comparator = customComparator ?? defaultComparator; - if (!comparator(lastSavedState?.[key], latestState[key], lastSavedState, latestState)) { - latestChanges[key] = latestState[key]; - } - } - return Object.keys(latestChanges).length > 0 ? latestChanges : undefined; -}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/comparators/types.ts b/src/platform/packages/shared/presentation/presentation_publishing/comparators/types.ts deleted file mode 100644 index b250b248c1b50..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_publishing/comparators/types.ts +++ /dev/null @@ -1,27 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { PublishingSubject } from '../publishing_subject'; - -export type ComparatorFunction = ( - last: StateType[KeyType] | undefined, - current: StateType[KeyType] | undefined, - lastState?: Partial, - currentState?: Partial -) => boolean; - -export type ComparatorDefinition = [ - PublishingSubject, - (value: StateType[KeyType]) => void, - ComparatorFunction? -]; - -export type StateComparators = { - [KeyType in keyof Required]: ComparatorDefinition; -}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/index.ts b/src/platform/packages/shared/presentation/presentation_publishing/index.ts index c3e842b84a85b..9411865b8e76b 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/index.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/index.ts @@ -10,13 +10,14 @@ export { isEmbeddableApiContext, type EmbeddableApiContext } from './embeddable_api_context'; export { - getInitialValuesFromComparators, - getUnchangingComparator, - runComparators, - type ComparatorDefinition, type ComparatorFunction, type StateComparators, -} from './comparators'; + type WithAllKeys, + runComparator, + areComparatorsEqual, + diffComparators, + initializeStateManager, +} from './state_manager'; export { apiCanAccessViewMode, getInheritedViewMode, @@ -29,9 +30,10 @@ export { } from './interfaces/can_lock_hover_actions'; export { fetch$, useFetchContext, type FetchContext } from './interfaces/fetch/fetch'; export { - initializeTimeRange, + initializeTimeRangeManager, + timeRangeComparators, type SerializedTimeRange, -} from './interfaces/fetch/initialize_time_range'; +} from './interfaces/fetch/time_range_manager'; export { apiPublishesReload, type PublishesReload } from './interfaces/fetch/publishes_reload'; export { apiPublishesFilters, @@ -73,9 +75,7 @@ export { export { apiHasParentApi, type HasParentApi } from './interfaces/has_parent_api'; export { apiHasSerializableState, - apiHasSnapshottableState, type HasSerializableState, - type HasSnapshottableState, type SerializedPanelState, } from './interfaces/has_serializable_state'; export { @@ -146,6 +146,7 @@ export { export { initializeTitleManager, stateHasTitles, + titleComparators, type TitlesApi, type SerializedTitles, } from './interfaces/titles/title_manager'; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/initialize_time_range.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/initialize_time_range.ts deleted file mode 100644 index ec1b942c4e6a6..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/initialize_time_range.ts +++ /dev/null @@ -1,44 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { BehaviorSubject } from 'rxjs'; -import fastIsEqual from 'fast-deep-equal'; -import { TimeRange } from '@kbn/es-query'; -import { StateComparators } from '../../comparators'; -import { PublishesWritableTimeRange } from './publishes_unified_search'; - -export interface SerializedTimeRange { - timeRange?: TimeRange | undefined; -} - -export const initializeTimeRange = ( - rawState: SerializedTimeRange -): { - serialize: () => SerializedTimeRange; - api: PublishesWritableTimeRange; - comparators: StateComparators; -} => { - const timeRange$ = new BehaviorSubject(rawState.timeRange); - function setTimeRange(nextTimeRange: TimeRange | undefined) { - timeRange$.next(nextTimeRange); - } - - return { - serialize: () => ({ - timeRange: timeRange$.value, - }), - comparators: { - timeRange: [timeRange$, setTimeRange, fastIsEqual], - } as StateComparators, - api: { - timeRange$, - setTimeRange, - }, - }; -}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/time_range_manager.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/time_range_manager.ts new file mode 100644 index 0000000000000..ad7a8dd290398 --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/fetch/time_range_manager.ts @@ -0,0 +1,29 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { TimeRange } from '@kbn/es-query'; +import { StateManager } from '../../state_manager/types'; +import { StateComparators, WithAllKeys, initializeStateManager } from '../../state_manager'; + +export interface SerializedTimeRange { + timeRange?: TimeRange | undefined; +} + +const defaultTimeRangeState: WithAllKeys = { + timeRange: undefined, +}; + +export const timeRangeComparators: StateComparators = { + timeRange: 'deepEquality', +}; + +export const initializeTimeRangeManager = ( + initialTimeRangeState: SerializedTimeRange +): StateManager => + initializeStateManager(initialTimeRangeState, defaultTimeRangeState); 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 f6144d78ae19f..44d269d7c7e05 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 @@ -29,21 +29,3 @@ export interface HasSerializableState { export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => { return Boolean((api as HasSerializableState)?.serializeState); }; - -/** - * @deprecated use HasSerializableState instead - */ -export interface HasSnapshottableState { - /** - * Serializes all runtime state exactly as it appears. This can be used - * to rehydrate a component's state without needing to serialize then deserialize it. - */ - snapshotRuntimeState: () => RuntimeState; -} - -/** - * @deprecated use apiHasSerializableState instead - */ -export const apiHasSnapshottableState = (api: unknown | null): api is HasSnapshottableState => { - return Boolean((api as HasSnapshottableState)?.snapshotRuntimeState); -}; 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 919cc8c00ce80..170e231b5357e 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,17 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { PublishingSubject } from '../publishing_subject'; +import { MaybePromise } from '@kbn/utility-types'; +import { Observable } from 'rxjs'; -export interface PublishesUnsavedChanges { - unsavedChanges$: PublishingSubject | undefined>; - resetUnsavedChanges: () => boolean; +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).unsavedChanges$ && + (api as PublishesUnsavedChanges).hasUnsavedChanges$ && (api as PublishesUnsavedChanges).resetUnsavedChanges ); }; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.test.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.test.ts index 53f53ed379d48..bdbd1b3e92637 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.test.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.test.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { initializeTitleManager, SerializedTitles } from './title_manager'; +import { ComparatorFunction } from '../../state_manager'; +import { initializeTitleManager, SerializedTitles, titleComparators } from './title_manager'; describe('titles api', () => { const rawState: SerializedTitles = { @@ -20,7 +21,7 @@ describe('titles api', () => { const { api } = initializeTitleManager(rawState); expect(api.title$.value).toBe(rawState.title); expect(api.description$.value).toBe(rawState.description); - expect(api.hideTitle$.value).toBe(rawState.hidePanelTitles); + expect(api.hidePanelTitles$.value).toBe(rawState.hidePanelTitles); }); it('should update publishing subject values when set functions are called', () => { @@ -28,18 +29,18 @@ describe('titles api', () => { api.setTitle('even cooler title'); api.setDescription('super uncool description'); - api.setHideTitle(true); + api.setHidePanelTitles(true); expect(api.title$.value).toEqual('even cooler title'); expect(api.description$.value).toEqual('super uncool description'); - expect(api.hideTitle$.value).toBe(true); + expect(api.hidePanelTitles$.value).toBe(true); }); it('should correctly serialize current state', () => { const titleManager = initializeTitleManager(rawState); titleManager.api.setTitle('UH OH, A TITLE'); - const serializedTitles = titleManager.serialize(); + const serializedTitles = titleManager.getLatestState(); expect(serializedTitles).toMatchInlineSnapshot(` Object { "description": "less cool description", @@ -49,19 +50,13 @@ describe('titles api', () => { `); }); - it('should return the correct set of comparators', () => { - const { comparators } = initializeTitleManager(rawState); - - expect(comparators.title).toBeDefined(); - expect(comparators.description).toBeDefined(); - expect(comparators.hidePanelTitles).toBeDefined(); - }); - it('should correctly compare hidePanelTitles with custom comparator', () => { - const { comparators } = initializeTitleManager(rawState); - - expect(comparators.hidePanelTitles![2]!(true, false)).toBe(false); - expect(comparators.hidePanelTitles![2]!(undefined, false)).toBe(true); - expect(comparators.hidePanelTitles![2]!(true, undefined)).toBe(false); + const comparator = titleComparators.hidePanelTitles as ComparatorFunction< + SerializedTitles, + 'hidePanelTitles' + >; + expect(comparator(true, false)).toBe(false); + expect(comparator(undefined, false)).toBe(true); + expect(comparator(true, undefined)).toBe(false); }); }); diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.ts index 71ea93456cea8..fb1f78543b6a4 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/title_manager.ts @@ -7,10 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject } from 'rxjs'; -import { StateComparators } from '../../comparators'; +import { WithAllKeys } from '../../state_manager'; +import { initializeStateManager } from '../../state_manager/state_manager'; +import { StateComparators, StateManager } from '../../state_manager/types'; import { PublishesWritableDescription } from './publishes_description'; -import { PublishesWritableTitle } from './publishes_title'; +import { PublishesTitle, PublishesWritableTitle } from './publishes_title'; export interface SerializedTitles { title?: string; @@ -18,6 +19,18 @@ export interface SerializedTitles { hidePanelTitles?: boolean; } +const defaultTitlesState: WithAllKeys = { + title: undefined, + description: undefined, + hidePanelTitles: undefined, +}; + +export const titleComparators: StateComparators = { + title: 'referenceEquality', + description: 'referenceEquality', + hidePanelTitles: (a, b) => Boolean(a) === Boolean(b), +}; + export const stateHasTitles = (state: unknown): state is SerializedTitles => { return ( (state as SerializedTitles)?.title !== undefined || @@ -29,44 +42,23 @@ export const stateHasTitles = (state: unknown): state is SerializedTitles => { export interface TitlesApi extends PublishesWritableTitle, PublishesWritableDescription {} export const initializeTitleManager = ( - rawState: SerializedTitles -): { - api: TitlesApi; - comparators: StateComparators; - serialize: () => SerializedTitles; -} => { - const title$ = new BehaviorSubject(rawState.title); - const description$ = new BehaviorSubject(rawState.description); - const hideTitle$ = new BehaviorSubject(rawState.hidePanelTitles); - - const setTitle = (value: string | undefined) => { - if (value !== title$.value) title$.next(value); - }; - const setHideTitle = (value: boolean | undefined) => { - if (value !== hideTitle$.value) hideTitle$.next(value); - }; - const setDescription = (value: string | undefined) => { - if (value !== description$.value) description$.next(value); + initialTitlesState: SerializedTitles +): StateManager & { + api: { + hideTitle$: PublishesTitle['hideTitle$']; + setHideTitle: PublishesWritableTitle['setHideTitle']; }; - +} => { + const stateManager = initializeStateManager(initialTitlesState, defaultTitlesState); return { + ...stateManager, api: { - title$, - hideTitle$, - setTitle, - setHideTitle, - description$, - setDescription, + ...stateManager.api, + // SerializedTitles defines hideTitles as hidePanelTitles + // This state is persisted and this naming conflict will be resolved TBD + // add named APIs that match interface names as a work-around + hideTitle$: stateManager.api.hidePanelTitles$, + setHideTitle: stateManager.api.setHidePanelTitles, }, - comparators: { - title: [title$, setTitle], - description: [description$, setDescription], - hidePanelTitles: [hideTitle$, setHideTitle, (a, b) => Boolean(a) === Boolean(b)], - } as StateComparators, - serialize: () => ({ - title: title$.value, - hidePanelTitles: hideTitle$.value, - description: description$.value, - }), }; }; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/comparators/index.ts b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/index.ts similarity index 65% rename from src/platform/packages/shared/presentation/presentation_publishing/comparators/index.ts rename to src/platform/packages/shared/presentation/presentation_publishing/state_manager/index.ts index 1b58fdad9ba2f..f761df5312d8a 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/comparators/index.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/index.ts @@ -7,6 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export type { ComparatorFunction, ComparatorDefinition, StateComparators } from './types'; -export { getInitialValuesFromComparators, runComparators } from './state_comparators'; -export { getUnchangingComparator } from './fallback_comparator'; +export { areComparatorsEqual, diffComparators, runComparator } from './state_comparators'; +export { initializeStateManager } from './state_manager'; +export type { ComparatorFunction, StateComparators, WithAllKeys } from './types'; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_comparators.ts b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_comparators.ts new file mode 100644 index 0000000000000..13ba9355a7a7a --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_comparators.ts @@ -0,0 +1,71 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import deepEqual from 'fast-deep-equal'; +import { StateComparators } from './types'; + +const referenceEquality = (a: T, b: T) => a === b; +const deepEquality = (a: T, b: T) => deepEqual(a, b); + +export const runComparator = ( + comparator: StateComparators[keyof StateType], + lastSavedState?: StateType, + latestState?: StateType, + lastSavedValue?: StateType[keyof StateType], + latestValue?: StateType[keyof StateType] +): boolean => { + if (comparator === 'skip') return true; + if (comparator === 'deepEquality') return deepEquality(lastSavedValue, latestValue); + if (comparator === 'referenceEquality') return referenceEquality(lastSavedValue, latestValue); + if (typeof comparator === 'function') { + return comparator(lastSavedValue, latestValue, lastSavedState, latestState); + } + throw new Error(`Comparator ${comparator} is not a valid comparator.`); +}; + +/** + * Run all comparators, and return an object containing only the keys that are not equal, set to the value of the latest state + */ +export const diffComparators = ( + comparators: StateComparators, + lastSavedState?: StateType, + latestState?: StateType +): Partial => { + return Object.keys(comparators).reduce((acc, key) => { + const comparator = comparators[key as keyof StateType]; + const lastSavedValue = lastSavedState?.[key as keyof StateType]; + const currentValue = latestState?.[key as keyof StateType]; + + if (!runComparator(comparator, lastSavedState, latestState, lastSavedValue, currentValue)) { + acc[key as keyof StateType] = currentValue; + } + + return acc; + }, {} as Partial); +}; + +/** + * Run comparators until at least one returns false + */ +export const areComparatorsEqual = ( + comparators: StateComparators, + lastSavedState?: StateType, + currentState?: StateType, + defaultState?: Partial +): boolean => { + return Object.keys(comparators).every((key) => { + const comparator = comparators[key as keyof StateType]; + const lastSavedValue = + lastSavedState?.[key as keyof StateType] ?? defaultState?.[key as keyof StateType]; + const currentValue = + currentState?.[key as keyof StateType] ?? defaultState?.[key as keyof StateType]; + + return runComparator(comparator, lastSavedState, currentState, lastSavedValue, currentValue); + }); +}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_manager.ts b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_manager.ts new file mode 100644 index 0000000000000..7684c226f2be8 --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_manager.ts @@ -0,0 +1,89 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { BehaviorSubject, map, merge } from 'rxjs'; +import { StateManager, WithAllKeys } from './types'; + +type SubjectOf = BehaviorSubject[keyof StateType]>; + +interface UnstructuredSettersAndSubjects { + [key: string]: SubjectOf | ((value: StateType[keyof StateType]) => void); +} + +type KeyToSubjectMap = { + [Key in keyof StateType]?: SubjectOf; +}; + +/** + * Initializes a composable state manager instance for a given state type. + * @param initialState - The initial state of the state manager. + * @param defaultState - The default state of the state manager. Every key in this state must be present, for optional keys specify undefined explicly. + * @param customComparators - Custom comparators for each key in the state. If not provided, defaults to reference equality. + */ +export const initializeStateManager = ( + initialState: StateType, + defaultState: WithAllKeys +): StateManager => { + const allState = { ...defaultState, ...initialState }; + const allSubjects: Array> = []; + const keyToSubjectMap: KeyToSubjectMap = {}; + + /** + * Build the API for this state type. We loop through default state because it is guaranteed to + * have all keys and we use it to build the API with a setter and a subject for each key. + */ + const api: StateManager['api'] = ( + Object.keys(defaultState) as Array + ).reduce((acc, key) => { + const subject = new BehaviorSubject(allState[key]); + const setter = (value: StateType[typeof key]) => { + subject.next(value); + }; + + const capitalizedKey = (key as string).charAt(0).toUpperCase() + (key as string).slice(1); + acc[`set${capitalizedKey}`] = setter; + acc[`${key as string}$`] = subject; + + allSubjects.push(subject); + keyToSubjectMap[key] = subject; + return acc; + }, {} as UnstructuredSettersAndSubjects) as StateManager['api']; + + /** + * Gets the latest state of this state manager. + */ + const getLatestState: StateManager['getLatestState'] = () => { + return Object.keys(defaultState).reduce((acc, key) => { + acc[key as keyof StateType] = keyToSubjectMap[key as keyof StateType]!.getValue(); + return acc; + }, {} as StateType); + }; + + /** + * Reinitializes the state of this state manager. Takes a partial state object that may be undefined. + * + * This method resets ALL keys in this state, if a key is not present in the new state, it will be set to the default value. + */ + const reinitializeState = (newState?: Partial) => { + for (const [key, subject] of Object.entries>( + keyToSubjectMap as { [key: string]: SubjectOf } + )) { + subject.next(newState?.[key as keyof StateType] ?? defaultState[key as keyof StateType]); + } + }; + + // SERIALIZED STATE ONLY TODO: Remember that the state manager DOES NOT contain comparators, because it's meant for Runtime state, and comparators should be written against serialized state. + + return { + api, + getLatestState, + reinitializeState, + anyStateChange$: merge(...allSubjects).pipe(map(() => undefined)), + }; +}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/state_manager/types.ts b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/types.ts new file mode 100644 index 0000000000000..f95c166954e14 --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/types.ts @@ -0,0 +1,53 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Observable } from 'rxjs'; +import { PublishingSubject } from '../publishing_subject'; + +export type WithAllKeys = { [Key in keyof Required]: T[Key] }; + +export type ComparatorFunction = ( + last: StateType[KeyType] | undefined, + current: StateType[KeyType] | undefined, + lastState?: Partial, + currentState?: Partial +) => boolean; + +/** + * A type that maps each key in a state type to a definition of how it should be compared. If a custom + * comparator is provided, return true if the values are equal, false otherwise. + */ +export type StateComparators = { + [KeyType in keyof Required]: + | 'referenceEquality' + | 'deepEquality' + | 'skip' + | ComparatorFunction; +}; + +export type CustomComparators = { + [KeyType in keyof StateType]?: ComparatorFunction; +}; + +type SubjectsOf = { + [KeyType in keyof Required as `${string & KeyType}$`]: PublishingSubject; +}; + +type SettersOf = { + [KeyType in keyof Required as `set${Capitalize}`]: ( + value: T[KeyType] + ) => void; +}; + +export interface StateManager { + getLatestState: () => WithAllKeys; + reinitializeState: (newState?: Partial) => void; + api: SettersOf & SubjectsOf; + anyStateChange$: Observable; +} diff --git a/src/platform/packages/shared/presentation/presentation_publishing/tsconfig.json b/src/platform/packages/shared/presentation/presentation_publishing/tsconfig.json index 44a58006f60c4..76cf0d29ae0a6 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/tsconfig.json +++ b/src/platform/packages/shared/presentation/presentation_publishing/tsconfig.json @@ -11,6 +11,7 @@ "@kbn/data-views-plugin", "@kbn/expressions-plugin", "@kbn/core-execution-context-common", - "@kbn/content-management-utils" + "@kbn/content-management-utils", + "@kbn/utility-types" ] } diff --git a/src/platform/plugins/private/image_embeddable/public/actions/create_image_action.ts b/src/platform/plugins/private/image_embeddable/public/actions/create_image_action.ts index 62e542697dd74..f18e0a6ac0eae 100644 --- a/src/platform/plugins/private/image_embeddable/public/actions/create_image_action.ts +++ b/src/platform/plugins/private/image_embeddable/public/actions/create_image_action.ts @@ -42,7 +42,7 @@ export const registerCreateImageAction = () => { canAddNewPanelParent.addNewPanel({ panelType: IMAGE_EMBEDDABLE_TYPE, - initialState: { imageConfig }, + serializedState: { rawState: { imageConfig } }, }); } catch { // swallow the rejection, since this just means the user closed without saving diff --git a/src/platform/plugins/private/image_embeddable/public/image_embeddable/get_image_embeddable_factory.tsx b/src/platform/plugins/private/image_embeddable/public/image_embeddable/get_image_embeddable_factory.tsx index 20759fb76c24e..a3256ccd0afee 100644 --- a/src/platform/plugins/private/image_embeddable/public/image_embeddable/get_image_embeddable_factory.tsx +++ b/src/platform/plugins/private/image_embeddable/public/image_embeddable/get_image_embeddable_factory.tsx @@ -8,14 +8,13 @@ */ import React, { useEffect, useMemo } from 'react'; -import deepEqual from 'react-fast-compare'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, map, merge } from 'rxjs'; import { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; -import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; -import { PresentationContainer } from '@kbn/presentation-containers'; -import { getUnchangingComparator, initializeTitleManager } from '@kbn/presentation-publishing'; +import { PresentationContainer, initializeUnsavedChanges } from '@kbn/presentation-containers'; +import { initializeTitleManager, titleComparators } from '@kbn/presentation-publishing'; import { IMAGE_CLICK_TRIGGER } from '../actions'; import { openImageEditor } from '../components/image_editor/open_image_editor'; @@ -30,72 +29,82 @@ export const getImageEmbeddableFactory = ({ }: { embeddableEnhanced?: EmbeddableEnhancedPluginStart; }) => { - const imageEmbeddableFactory: ReactEmbeddableFactory< - ImageEmbeddableSerializedState, + const imageEmbeddableFactory: EmbeddableFactory< ImageEmbeddableSerializedState, ImageEmbeddableApi > = { type: IMAGE_EMBEDDABLE_TYPE, - deserializeState: (state) => state.rawState, - buildEmbeddable: async (initialState, buildApi, uuid) => { - const titleManager = initializeTitleManager(initialState); + buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => { + const titleManager = initializeTitleManager(initialState.rawState); - const dynamicActionsApi = embeddableEnhanced?.initializeReactEmbeddableDynamicActions( + const dynamicActionsManager = embeddableEnhanced?.initializeEmbeddableDynamicActions( uuid, () => titleManager.api.title$.getValue(), - initialState + initialState.rawState ); // if it is provided, start the dynamic actions manager - const maybeStopDynamicActions = dynamicActionsApi?.startDynamicActions(); + const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); const filesClient = filesService.filesClientFactory.asUnscoped(); - const imageConfig$ = new BehaviorSubject(initialState.imageConfig); + const imageConfig$ = new BehaviorSubject(initialState.rawState.imageConfig); const dataLoading$ = new BehaviorSubject(true); - const embeddable = buildApi( - { - ...titleManager.api, - ...(dynamicActionsApi?.dynamicActionsApi ?? {}), - dataLoading$, - supportedTriggers: () => [IMAGE_CLICK_TRIGGER], - onEdit: async () => { - try { - const newImageConfig = await openImageEditor({ - parentApi: embeddable.parentApi as PresentationContainer, - initialImageConfig: imageConfig$.getValue(), - }); - imageConfig$.next(newImageConfig); - } catch { - // swallow the rejection, since this just means the user closed without saving - } - }, - isEditingEnabled: () => true, - getTypeDisplayName: () => - i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName.edit', { - defaultMessage: 'image', - }), - serializeState: () => { - return { - rawState: { - ...titleManager.serialize(), - ...(dynamicActionsApi?.serializeDynamicActions() ?? {}), - imageConfig: imageConfig$.getValue(), - }, - }; + function serializeState() { + return { + rawState: { + ...titleManager.getLatestState(), + ...(dynamicActionsManager?.getLatestState() ?? {}), + imageConfig: imageConfig$.getValue(), }, + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState, + anyStateChange$: merge( + titleManager.anyStateChange$, + imageConfig$.pipe(map(() => undefined)) + ), + getComparators: () => { + return { + ...(dynamicActionsManager?.comparators ?? { enhancements: 'skip' }), + ...titleComparators, + imageConfig: 'deepEquality', + }; + }, + onReset: (lastSaved) => { + titleManager.reinitializeState(lastSaved?.rawState); + dynamicActionsManager?.reinitializeState(lastSaved?.rawState ?? {}); + if (lastSaved) imageConfig$.next(lastSaved.rawState.imageConfig); }, - { - ...titleManager.comparators, - ...(dynamicActionsApi?.dynamicActionsComparator ?? { - enhancements: getUnchangingComparator(), + }); + + const embeddable = finalizeApi({ + ...titleManager.api, + ...(dynamicActionsManager?.api ?? {}), + ...unsavedChangesApi, + dataLoading$, + supportedTriggers: () => [IMAGE_CLICK_TRIGGER], + onEdit: async () => { + try { + const newImageConfig = await openImageEditor({ + parentApi: embeddable.parentApi as PresentationContainer, + initialImageConfig: imageConfig$.getValue(), + }); + imageConfig$.next(newImageConfig); + } catch { + // swallow the rejection, since this just means the user closed without saving + } + }, + isEditingEnabled: () => true, + getTypeDisplayName: () => + i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName.edit', { + defaultMessage: 'image', }), - imageConfig: [ - imageConfig$, - (value) => imageConfig$.next(value), - (a, b) => deepEqual(a, b), - ], - } - ); + serializeState, + }); return { api: embeddable, Component: () => { diff --git a/src/platform/plugins/private/links/public/actions/add_links_panel_action.ts b/src/platform/plugins/private/links/public/actions/add_links_panel_action.ts index 95c280d14e544..80d7a911c0913 100644 --- a/src/platform/plugins/private/links/public/actions/add_links_panel_action.ts +++ b/src/platform/plugins/private/links/public/actions/add_links_panel_action.ts @@ -17,10 +17,11 @@ import { apiPublishesTitle, apiPublishesSavedObjectId, } from '@kbn/presentation-publishing'; -import type { LinksParentApi } from '../types'; +import type { LinksParentApi, LinksSerializedState } from '../types'; import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common'; import { ADD_LINKS_PANEL_ACTION_ID } from './constants'; import { openEditorFlyout } from '../editor/open_editor_flyout'; +import { serializeLinksAttributes } from '../lib/serialize_attributes'; export const isParentApiCompatible = (parentApi: unknown): parentApi is LinksParentApi => apiIsPresentationContainer(parentApi) && @@ -42,9 +43,29 @@ export const addLinksPanelAction: ActionDefinition = { }); if (!runtimeState) return; - await embeddable.addNewPanel({ + function serializeState() { + if (!runtimeState) return; + + if (runtimeState.savedObjectId !== undefined) { + return { + rawState: { + savedObjectId: runtimeState.savedObjectId, + }, + }; + } + + const { attributes, references } = serializeLinksAttributes(runtimeState); + return { + rawState: { + attributes, + }, + references, + }; + } + + await embeddable.addNewPanel({ panelType: CONTENT_ID, - initialState: runtimeState, + serializedState: serializeState(), }); }, grouping: [ADD_PANEL_ANNOTATION_GROUP], diff --git a/src/platform/plugins/private/links/public/content_management/save_to_library.tsx b/src/platform/plugins/private/links/public/content_management/save_to_library.tsx index 930d909f522ec..c58f2ce5db4aa 100644 --- a/src/platform/plugins/private/links/public/content_management/save_to_library.tsx +++ b/src/platform/plugins/private/links/public/content_management/save_to_library.tsx @@ -71,8 +71,8 @@ export const runSaveToLibrary = async ( }); resolve({ ...newState, - defaultPanelTitle: newTitle, - defaultPanelDescription: newDescription, + defaultTitle: newTitle, + defaultDescription: newDescription, savedObjectId: id, }); return { id }; diff --git a/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx b/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx index 389de45ad7e20..cdf7098b2e1dd 100644 --- a/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx +++ b/src/platform/plugins/private/links/public/embeddable/links_embeddable.test.tsx @@ -12,17 +12,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { setStubKibanaServices } from '@kbn/presentation-panel-plugin/public/mocks'; import { EuiThemeProvider } from '@elastic/eui'; -import { getLinksEmbeddableFactory } from './links_embeddable'; +import { deserializeState, getLinksEmbeddableFactory } from './links_embeddable'; import { Link } from '../../common/content_management'; import { CONTENT_ID } from '../../common'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; -import { - LinksApi, - LinksParentApi, - LinksRuntimeState, - LinksSerializedState, - ResolvedLink, -} from '../types'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { LinksApi, LinksParentApi, LinksSerializedState, ResolvedLink } from '../types'; import { linksClient } from '../content_management'; import { getMockLinksParentApi } from '../mocks'; @@ -148,7 +142,7 @@ const renderEmbeddable = ( ) => { return render( - + type={CONTENT_ID} onApiAvailable={jest.fn()} getParentApi={jest.fn().mockReturnValue(parent)} @@ -178,8 +172,8 @@ describe('getLinksEmbeddableFactory', () => { } as LinksSerializedState; const expectedRuntimeState = { - defaultPanelTitle: 'links 001', - defaultPanelDescription: 'some links', + defaultTitle: 'links 001', + defaultDescription: 'some links', layout: 'vertical', links: getResolvedLinks(), description: 'just a few links', @@ -195,7 +189,7 @@ describe('getLinksEmbeddableFactory', () => { }); test('deserializeState', async () => { - const deserializedState = await factory.deserializeState({ + const deserializedState = await deserializeState({ rawState, references: [], // no references passed because the panel is by reference }); @@ -266,8 +260,8 @@ describe('getLinksEmbeddableFactory', () => { } as LinksSerializedState; const expectedRuntimeState = { - defaultPanelTitle: undefined, - defaultPanelDescription: undefined, + defaultTitle: undefined, + defaultDescription: undefined, layout: 'horizontal', links: getResolvedLinks(), description: 'just a few links', @@ -283,7 +277,7 @@ describe('getLinksEmbeddableFactory', () => { }); test('deserializeState', async () => { - const deserializedState = await factory.deserializeState({ + const deserializedState = await deserializeState({ rawState, references, }); diff --git a/src/platform/plugins/private/links/public/embeddable/links_embeddable.tsx b/src/platform/plugins/private/links/public/embeddable/links_embeddable.tsx index 3a52da790a92c..55f863a630174 100644 --- a/src/platform/plugins/private/links/public/embeddable/links_embeddable.tsx +++ b/src/platform/plugins/private/links/public/embeddable/links_embeddable.tsx @@ -8,24 +8,26 @@ */ import React, { createContext, useMemo } from 'react'; -import { cloneDeep } from 'lodash'; -import { BehaviorSubject } from 'rxjs'; +import { cloneDeep, isUndefined, omitBy } from 'lodash'; +import { BehaviorSubject, merge } from 'rxjs'; +import deepEqual from 'fast-deep-equal'; import { EuiListGroup, EuiPanel, UseEuiTheme } from '@elastic/eui'; -import { PanelIncompatibleError, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { PanelIncompatibleError, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { SerializedTitles, initializeTitleManager, SerializedPanelState, useBatchedOptionalPublishingSubjects, + initializeStateManager, + titleComparators, } from '@kbn/presentation-publishing'; import { css } from '@emotion/react'; -import { apiIsPresentationContainer } from '@kbn/presentation-containers'; +import { apiIsPresentationContainer, initializeUnsavedChanges } from '@kbn/presentation-containers'; import { CONTENT_ID, DASHBOARD_LINK_TYPE, - LinksLayoutType, LINKS_HORIZONTAL_LAYOUT, LINKS_VERTICAL_LAYOUT, } from '../../common/content_management'; @@ -38,7 +40,6 @@ import { LinksParentApi, LinksRuntimeState, LinksSerializedState, - ResolvedLink, } from '../types'; import { DISPLAY_NAME } from '../../common'; import { injectReferences } from '../../common/persistable_state'; @@ -54,172 +55,212 @@ import { isParentApiCompatible } from '../actions/add_links_panel_action'; export const LinksContext = createContext(null); +export async function deserializeState( + serializedState: SerializedPanelState +) { + // Clone the state to avoid an object not extensible error when injecting references + const state = cloneDeep(serializedState.rawState); + const { title, description, hidePanelTitles } = serializedState.rawState; + + if (linksSerializeStateIsByReference(state)) { + const linksSavedObject = await linksClient.get(state.savedObjectId); + const runtimeState = await deserializeLinksSavedObject(linksSavedObject.item); + return { + ...runtimeState, + title, + description, + hidePanelTitles, + }; + } + + const { attributes: attributesWithInjectedIds } = injectReferences({ + attributes: state.attributes, + references: serializedState.references ?? [], + }); + + const resolvedLinks = await resolveLinks(attributesWithInjectedIds.links ?? []); + + return { + title, + description, + hidePanelTitles, + links: resolvedLinks, + layout: attributesWithInjectedIds.layout, + defaultTitle: attributesWithInjectedIds.title, + defaultDescription: attributesWithInjectedIds.description, + }; +} + export const getLinksEmbeddableFactory = () => { - const linksEmbeddableFactory: ReactEmbeddableFactory< - LinksSerializedState, - LinksRuntimeState, - LinksApi - > = { + const linksEmbeddableFactory: EmbeddableFactory = { type: CONTENT_ID, - deserializeState: async (serializedState) => { - // Clone the state to avoid an object not extensible error when injecting references - const state = cloneDeep(serializedState.rawState); - const { title, description, hidePanelTitles } = serializedState.rawState; + buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => { + const titleManager = initializeTitleManager(initialState.rawState); + const savedObjectId = linksSerializeStateIsByReference(initialState.rawState) + ? initialState.rawState.savedObjectId + : undefined; + const isByReference = savedObjectId !== undefined; - if (linksSerializeStateIsByReference(state)) { - const linksSavedObject = await linksClient.get(state.savedObjectId); - const runtimeState = await deserializeLinksSavedObject(linksSavedObject.item); - return { - ...runtimeState, - title, - description, - hidePanelTitles, - }; - } + const initialRuntimeState = await deserializeState(initialState); - const { attributes: attributesWithInjectedIds } = injectReferences({ - attributes: state.attributes, - references: serializedState.references ?? [], + const blockingError$ = new BehaviorSubject(undefined); + if (!isParentApiCompatible(parentApi)) blockingError$.next(new PanelIncompatibleError()); + + const stateManager = initializeStateManager< + Pick + >(initialRuntimeState, { + defaultDescription: undefined, + defaultTitle: undefined, + layout: undefined, + links: undefined, }); - const resolvedLinks = await resolveLinks(attributesWithInjectedIds.links ?? []); + function serializeByReference(id: string) { + return { + rawState: { + ...titleManager.getLatestState(), + savedObjectId: id, + } as LinksByReferenceSerializedState, + references: [], + }; + } - return { - title, - description, - hidePanelTitles, - links: resolvedLinks, - layout: attributesWithInjectedIds.layout, - defaultPanelTitle: attributesWithInjectedIds.title, - defaultPanelDescription: attributesWithInjectedIds.description, - }; - }, - buildEmbeddable: async (state, buildApi, uuid, parentApi) => { - const blockingError$ = new BehaviorSubject(state.error); - if (!isParentApiCompatible(parentApi)) blockingError$.next(new PanelIncompatibleError()); + function serializeByValue() { + const { attributes, references } = serializeLinksAttributes(stateManager.getLatestState()); + return { + rawState: { + ...titleManager.getLatestState(), + attributes, + } as LinksByValueSerializedState, + references, + }; + } - const links$ = new BehaviorSubject(state.links); - const layout$ = new BehaviorSubject(state.layout); - const defaultTitle$ = new BehaviorSubject(state.defaultPanelTitle); - const defaultDescription$ = new BehaviorSubject( - state.defaultPanelDescription - ); - const savedObjectId$ = new BehaviorSubject(state.savedObjectId); - const isByReference = Boolean(state.savedObjectId); + const serializeState = () => + isByReference ? serializeByReference(savedObjectId) : serializeByValue(); - const titleManager = initializeTitleManager(state); + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState, + anyStateChange$: merge(titleManager.anyStateChange$, stateManager.anyStateChange$), + getComparators: () => { + return { + ...titleComparators, + attributes: isByReference + ? 'skip' + : ( + a?: LinksByValueSerializedState['attributes'], + b?: LinksByValueSerializedState['attributes'] + ) => { + if ( + a?.title !== b?.title || + a?.description !== b?.description || + a?.layout !== b?.layout || + a?.links?.length !== b?.links?.length + ) { + return false; + } - const serializeLinksState = (byReference: boolean, newId?: string) => { - if (byReference) { - const linksByReferenceState: LinksByReferenceSerializedState = { - savedObjectId: newId ?? state.savedObjectId!, - ...titleManager.serialize(), + const hasLinkDifference = (a?.links ?? []).some((linkFromA, index) => { + const linkFromB = b?.links?.[index]; + return !deepEqual( + omitBy(linkFromA, isUndefined), + omitBy(linkFromB, isUndefined) + ); + }); + return !hasLinkDifference; + }, + savedObjectId: 'skip', }; - return { rawState: linksByReferenceState, references: [] }; - } - const runtimeState = api.snapshotRuntimeState(); - const { attributes, references } = serializeLinksAttributes(runtimeState); - const linksByValueState: LinksByValueSerializedState = { - attributes, - ...titleManager.serialize(), - }; - return { rawState: linksByValueState, references }; - }; + }, + onReset: async (lastSaved) => { + titleManager.reinitializeState(lastSaved?.rawState); + if (lastSaved && !isByReference) { + const lastSavedRuntimeState = await deserializeState(lastSaved); + stateManager.reinitializeState(lastSavedRuntimeState); + } + }, + }); - const api = buildApi( - { - ...titleManager.api, - blockingError$, - defaultTitle$, - defaultDescription$, - isEditingEnabled: () => Boolean(blockingError$.value === undefined), - getTypeDisplayName: () => DISPLAY_NAME, - serializeState: () => serializeLinksState(isByReference), - saveToLibrary: async (newTitle: string) => { - defaultTitle$.next(newTitle); - const runtimeState = api.snapshotRuntimeState(); - const { attributes, references } = serializeLinksAttributes(runtimeState); - const { - item: { id }, - } = await linksClient.create({ - data: { - ...attributes, - title: newTitle, - }, - options: { references }, - }); - return id; - }, - getSerializedStateByValue: () => - serializeLinksState(false) as SerializedPanelState, - getSerializedStateByReference: (newId: string) => - serializeLinksState( - true, - newId - ) as SerializedPanelState, - canLinkToLibrary: async () => !isByReference, - canUnlinkFromLibrary: async () => isByReference, - checkForDuplicateTitle: async ( - newTitle: string, - isTitleDuplicateConfirmed: boolean, - onTitleDuplicate: () => void - ) => { - await checkForDuplicateTitle({ + const api = finalizeApi({ + ...titleManager.api, + ...unsavedChangesApi, + blockingError$, + defaultTitle$: stateManager.api.defaultTitle$, + defaultDescription$: stateManager.api.defaultDescription$, + isEditingEnabled: () => Boolean(blockingError$.value === undefined), + getTypeDisplayName: () => DISPLAY_NAME, + serializeState, + saveToLibrary: async (newTitle: string) => { + stateManager.api.setDefaultTitle(newTitle); + const { attributes, references } = serializeLinksAttributes( + stateManager.getLatestState() + ); + const { + item: { id }, + } = await linksClient.create({ + data: { + ...attributes, title: newTitle, - copyOnSave: false, - lastSavedTitle: '', - isTitleDuplicateConfirmed, - onTitleDuplicate, - }); - }, - onEdit: async () => { - const { openEditorFlyout } = await import('../editor/open_editor_flyout'); - const newState = await openEditorFlyout({ - initialState: api.snapshotRuntimeState(), - parentDashboard: parentApi, - }); - if (!newState) return; + }, + options: { references }, + }); + return id; + }, + getSerializedStateByValue: serializeByValue, + getSerializedStateByReference: serializeByReference, + canLinkToLibrary: async () => !isByReference, + canUnlinkFromLibrary: async () => isByReference, + checkForDuplicateTitle: async ( + newTitle: string, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: () => void + ) => { + await checkForDuplicateTitle({ + title: newTitle, + copyOnSave: false, + lastSavedTitle: '', + isTitleDuplicateConfirmed, + onTitleDuplicate, + }); + }, + onEdit: async () => { + const { openEditorFlyout } = await import('../editor/open_editor_flyout'); + const newState = await openEditorFlyout({ + initialState: { + ...stateManager.getLatestState(), + savedObjectId, + }, + parentDashboard: parentApi, + }); + if (!newState) return; - // if the by reference state has changed during this edit, reinitialize the panel. - const nextIsByReference = Boolean(newState?.savedObjectId); - if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) { - const serializedState = serializeLinksState( - nextIsByReference, - newState?.savedObjectId - ); - (serializedState.rawState as SerializedTitles).title = newState.title; + // if the by reference state has changed during this edit, reinitialize the panel. + const nextSavedObjectId = newState?.savedObjectId; + const nextIsByReference = nextSavedObjectId !== undefined; + if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) { + const serializedState = nextIsByReference + ? serializeByReference(nextSavedObjectId) + : serializeByValue(); + (serializedState.rawState as SerializedTitles).title = newState.title; - api.parentApi.replacePanel(api.uuid, { - serializedState, - panelType: api.type, - }); - return; - } - links$.next(newState.links); - layout$.next(newState.layout); - defaultTitle$.next(newState.defaultPanelTitle); - defaultDescription$.next(newState.defaultPanelDescription); - }, + api.parentApi.replacePanel(api.uuid, { + serializedState, + panelType: api.type, + }); + return; + } + + stateManager.reinitializeState(newState); }, - { - ...titleManager.comparators, - links: [links$, (nextLinks?: ResolvedLink[]) => links$.next(nextLinks ?? [])], - layout: [ - layout$, - (nextLayout?: LinksLayoutType) => layout$.next(nextLayout ?? LINKS_VERTICAL_LAYOUT), - ], - error: [blockingError$, (nextError?: Error) => blockingError$.next(nextError)], - defaultPanelDescription: [ - defaultDescription$, - (nextDescription?: string) => defaultDescription$.next(nextDescription), - ], - defaultPanelTitle: [defaultTitle$, (nextTitle?: string) => defaultTitle$.next(nextTitle)], - savedObjectId: [savedObjectId$, (val) => savedObjectId$.next(val)], - } - ); + }); const Component = () => { - const [links, layout] = useBatchedOptionalPublishingSubjects(links$, layout$); + const [links, layout] = useBatchedOptionalPublishingSubjects( + stateManager.api.links$, + stateManager.api.layout$ + ); const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => { if (!links) return {}; diff --git a/src/platform/plugins/private/links/public/lib/deserialize_from_library.ts b/src/platform/plugins/private/links/public/lib/deserialize_from_library.ts index 725227d3a9f74..e7b9acedd6697 100644 --- a/src/platform/plugins/private/links/public/lib/deserialize_from_library.ts +++ b/src/platform/plugins/private/links/public/lib/deserialize_from_library.ts @@ -27,13 +27,13 @@ export const deserializeLinksSavedObject = async ( const links = await resolveLinks(attributes.links ?? []); - const { title: defaultPanelTitle, description: defaultPanelDescription, layout } = attributes; + const { title: defaultTitle, description: defaultDescription, layout } = attributes; return { links, layout, savedObjectId: linksSavedObject.id, - defaultPanelTitle, - defaultPanelDescription, + defaultTitle, + defaultDescription, }; }; diff --git a/src/platform/plugins/private/links/public/lib/serialize_attributes.ts b/src/platform/plugins/private/links/public/lib/serialize_attributes.ts index 12e8167c7b70a..c28c2d9092b77 100644 --- a/src/platform/plugins/private/links/public/lib/serialize_attributes.ts +++ b/src/platform/plugins/private/links/public/lib/serialize_attributes.ts @@ -12,7 +12,7 @@ import { extractReferences } from '../../common/persistable_state'; import { LinksRuntimeState } from '../types'; export const serializeLinksAttributes = ( - state: LinksRuntimeState, + state: Pick, shouldExtractReferences: boolean = true ) => { const linksToSave: Link[] | undefined = state.links @@ -25,8 +25,8 @@ export const serializeLinksAttributes = ( ) as unknown as Link ); const attributes = { - title: state.defaultPanelTitle, - description: state.defaultPanelDescription, + title: state.defaultTitle, + description: state.defaultDescription, layout: state.layout, links: linksToSave, }; diff --git a/src/platform/plugins/private/links/public/plugin.ts b/src/platform/plugins/private/links/public/plugin.ts index 646812da51c80..fa749fc5c0b64 100644 --- a/src/platform/plugins/private/links/public/plugin.ts +++ b/src/platform/plugins/private/links/public/plugin.ts @@ -25,7 +25,8 @@ import { VisualizationsSetup } from '@kbn/visualizations-plugin/public'; import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin'; import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public'; -import { LinksRuntimeState } from './types'; +import { SerializedPanelState } from '@kbn/presentation-publishing'; +import { LinksSerializedState } from './types'; import { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from '../common'; import { LinksCrudTypes } from '../common/content_management'; import { getLinksClient } from './content_management/links_content_management_client'; @@ -64,11 +65,13 @@ export class LinksPlugin plugins.embeddable.registerAddFromLibraryType({ onAdd: async (container, savedObject) => { - const { deserializeLinksSavedObject } = await import('./lib/deserialize_from_library'); - const initialState = await deserializeLinksSavedObject(savedObject); - container.addNewPanel({ + container.addNewPanel({ panelType: CONTENT_ID, - initialState, + serializedState: { + rawState: { + savedObjectId: savedObject.id, + }, + }, }); }, savedObjectType: CONTENT_ID, @@ -142,8 +145,10 @@ export class LinksPlugin plugins.dashboard.registerDashboardPanelPlacementSetting( CONTENT_ID, - async (runtimeState?: LinksRuntimeState) => { - if (!runtimeState) return {}; + async (serializedState?: SerializedPanelState) => { + if (!serializedState) return {}; + const { deserializeState } = await import('./embeddable/links_embeddable'); + const runtimeState = await deserializeState(serializedState); const isHorizontal = runtimeState.layout === 'horizontal'; const width = isHorizontal ? DASHBOARD_GRID_COLUMN_COUNT : 8; const height = isHorizontal ? 4 : (runtimeState.links?.length ?? 1 * 3) + 4; diff --git a/src/platform/plugins/private/links/public/types.ts b/src/platform/plugins/private/links/public/types.ts index 98e6f5e0d17b4..76dfa3cf5a30e 100644 --- a/src/platform/plugins/private/links/public/types.ts +++ b/src/platform/plugins/private/links/public/types.ts @@ -18,7 +18,6 @@ import { SerializedTitles, } from '@kbn/presentation-publishing'; import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; -import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public/plugin'; import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers'; import { LocatorPublic } from '@kbn/share-plugin/common'; import { DASHBOARD_API_TYPE } from '@kbn/dashboard-plugin/public'; @@ -39,7 +38,7 @@ export type LinksParentApi = PresentationContainer & }; export type LinksApi = HasType & - DefaultEmbeddableApi & + DefaultEmbeddableApi & HasEditCapabilities & HasLibraryTransforms; @@ -52,17 +51,15 @@ export interface LinksByValueSerializedState { } export type LinksSerializedState = SerializedTitles & - Partial & (LinksByReferenceSerializedState | LinksByValueSerializedState); export interface LinksRuntimeState extends Partial, SerializedTitles { - error?: Error; links?: ResolvedLink[]; layout?: LinksLayoutType; - defaultPanelTitle?: string; - defaultPanelDescription?: string; + defaultTitle?: string; + defaultDescription?: string; } export type ResolvedLink = Link & { diff --git a/src/platform/plugins/private/links/tsconfig.json b/src/platform/plugins/private/links/tsconfig.json index da7bfcdbd7304..6154d61fa0315 100644 --- a/src/platform/plugins/private/links/tsconfig.json +++ b/src/platform/plugins/private/links/tsconfig.json @@ -40,7 +40,6 @@ "@kbn/presentation-publishing", "@kbn/react-kibana-context-render", "@kbn/presentation-panel-plugin", - "@kbn/embeddable-enhanced-plugin", "@kbn/share-plugin", "@kbn/es-query" ], diff --git a/src/platform/plugins/shared/controls/public/actions/delete_control_action.test.tsx b/src/platform/plugins/shared/controls/public/actions/delete_control_action.test.tsx index 776eb7c969ca0..5679696570c94 100644 --- a/src/platform/plugins/shared/controls/public/actions/delete_control_action.test.tsx +++ b/src/platform/plugins/shared/controls/public/actions/delete_control_action.test.tsx @@ -12,7 +12,7 @@ import { BehaviorSubject } from 'rxjs'; import { ViewMode } from '@kbn/presentation-publishing'; import { getOptionsListControlFactory } from '../controls/data_controls/options_list_control/get_options_list_control_factory'; import { OptionsListControlApi } from '../controls/data_controls/options_list_control/types'; -import { getMockedBuildApi, getMockedControlGroupApi } from '../controls/mocks/control_mocks'; +import { getMockedControlGroupApi, getMockedFinalizeApi } from '../controls/mocks/control_mocks'; import { coreServices } from '../services/kibana_services'; import { DeleteControlAction } from './delete_control_action'; @@ -31,18 +31,18 @@ beforeAll(async () => { const controlFactory = getOptionsListControlFactory(); const uuid = 'testControl'; - const control = await controlFactory.buildControl( - { + const control = await controlFactory.buildControl({ + initialState: { dataViewId: 'test-data-view', title: 'test', fieldName: 'test-field', width: 'medium', grow: false, }, - getMockedBuildApi(uuid, controlFactory, controlGroupApi), + finalizeApi: getMockedFinalizeApi(uuid, controlFactory, controlGroupApi), uuid, - controlGroupApi - ); + controlGroupApi, + }); controlApi = control.api; }); diff --git a/src/platform/plugins/shared/controls/public/actions/edit_control_action.test.tsx b/src/platform/plugins/shared/controls/public/actions/edit_control_action.test.tsx index 668aaca003fbb..e9c8ecc006c4f 100644 --- a/src/platform/plugins/shared/controls/public/actions/edit_control_action.test.tsx +++ b/src/platform/plugins/shared/controls/public/actions/edit_control_action.test.tsx @@ -15,7 +15,7 @@ import type { ViewMode } from '@kbn/presentation-publishing'; import { getOptionsListControlFactory } from '../controls/data_controls/options_list_control/get_options_list_control_factory'; import type { OptionsListControlApi } from '../controls/data_controls/options_list_control/types'; -import { getMockedBuildApi, getMockedControlGroupApi } from '../controls/mocks/control_mocks'; +import { getMockedControlGroupApi, getMockedFinalizeApi } from '../controls/mocks/control_mocks'; import { getTimesliderControlFactory } from '../controls/timeslider_control/get_timeslider_control_factory'; import { dataService } from '../services/kibana_services'; import { EditControlAction } from './edit_control_action'; @@ -43,18 +43,19 @@ beforeAll(async () => { const controlFactory = getOptionsListControlFactory(); const optionsListUuid = 'optionsListControl'; - const optionsListControl = await controlFactory.buildControl( - { + + const optionsListControl = await controlFactory.buildControl({ + initialState: { dataViewId: 'test-data-view', title: 'test', fieldName: 'test-field', width: 'medium', grow: false, }, - getMockedBuildApi(optionsListUuid, controlFactory, controlGroupApi), - optionsListUuid, - controlGroupApi - ); + finalizeApi: getMockedFinalizeApi(optionsListUuid, controlFactory, controlGroupApi), + uuid: optionsListUuid, + controlGroupApi, + }); optionsListApi = optionsListControl.api; }); @@ -63,12 +64,12 @@ describe('Incompatible embeddables', () => { test('Action is incompatible with embeddables that are not editable', async () => { const timeSliderFactory = getTimesliderControlFactory(); const timeSliderUuid = 'timeSliderControl'; - const timeSliderControl = await timeSliderFactory.buildControl( - {}, - getMockedBuildApi(timeSliderUuid, timeSliderFactory, controlGroupApi), - timeSliderUuid, - controlGroupApi - ); + const timeSliderControl = await timeSliderFactory.buildControl({ + initialState: {}, + finalizeApi: getMockedFinalizeApi(timeSliderUuid, timeSliderFactory, controlGroupApi), + uuid: timeSliderUuid, + controlGroupApi, + }); const editControlAction = new EditControlAction(); expect( await editControlAction.isCompatible({ diff --git a/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.test.tsx b/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.test.tsx index a17068228f9a8..42c3819ed0aa3 100644 --- a/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.test.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.test.tsx @@ -13,14 +13,10 @@ import { BehaviorSubject } from 'rxjs'; import { render } from '@testing-library/react'; import { ControlGroupApi } from '../..'; -import { - ControlGroupChainingSystem, - ControlLabelPosition, - DEFAULT_CONTROL_LABEL_POSITION, - ParentIgnoreSettings, -} from '../../../common'; import { DefaultControlApi } from '../../controls/types'; import { ControlGroupEditor } from './control_group_editor'; +import { initializeEditorStateManager } from '../initialize_editor_state_manager'; +import { DEFAULT_CONTROL_LABEL_POSITION } from '../../../common'; describe('render', () => { const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); @@ -31,12 +27,12 @@ describe('render', () => { onCancel: () => {}, onSave: () => {}, onDeleteAll: () => {}, - stateManager: { - chainingSystem: new BehaviorSubject('HIERARCHICAL'), - labelPosition: new BehaviorSubject(DEFAULT_CONTROL_LABEL_POSITION), - autoApplySelections: new BehaviorSubject(true), - ignoreParentSettings: new BehaviorSubject(undefined), - }, + stateManager: initializeEditorStateManager({ + chainingSystem: 'HIERARCHICAL', + autoApplySelections: true, + ignoreParentSettings: undefined, + labelPosition: DEFAULT_CONTROL_LABEL_POSITION, + }), }; beforeEach(() => { diff --git a/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.tsx b/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.tsx index cb21c23bc9ce4..9c83cab3018c8 100644 --- a/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/components/control_group_editor.tsx @@ -27,9 +27,9 @@ import { } from '@elastic/eui'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { StateManager } from '@kbn/presentation-publishing/state_manager/types'; import type { ControlLabelPosition, ParentIgnoreSettings } from '../../../common'; import { CONTROL_LAYOUT_OPTIONS } from '../../controls/data_controls/editor_constants'; -import type { ControlStateManager } from '../../controls/types'; import { ControlGroupStrings } from '../control_group_strings'; import type { ControlGroupApi, ControlGroupEditorState } from '../types'; import { ControlSettingTooltipLabel } from './control_setting_tooltip_label'; @@ -38,7 +38,7 @@ interface Props { onCancel: () => void; onSave: () => void; onDeleteAll: () => void; - stateManager: ControlStateManager; + stateManager: StateManager; api: ControlGroupApi; // controls must always have a parent API } @@ -51,22 +51,22 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager selectedIgnoreParentSettings, ] = useBatchedPublishingSubjects( api.children$, - stateManager.labelPosition, - stateManager.chainingSystem, - stateManager.autoApplySelections, - stateManager.ignoreParentSettings + stateManager.api.labelPosition$, + stateManager.api.chainingSystem$, + stateManager.api.autoApplySelections$, + stateManager.api.ignoreParentSettings$ ); const controlCount = useMemo(() => Object.keys(children).length, [children]); const updateIgnoreSetting = useCallback( (newSettings: Partial) => { - stateManager.ignoreParentSettings.next({ + stateManager.api.setIgnoreParentSettings({ ...(selectedIgnoreParentSettings ?? {}), ...newSettings, }); }, - [stateManager.ignoreParentSettings, selectedIgnoreParentSettings] + [stateManager.api, selectedIgnoreParentSettings] ); return ( @@ -86,7 +86,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager idSelected={selectedLabelPosition} legend={ControlGroupStrings.management.labelPosition.getLabelPositionLegend()} onChange={(newPosition: string) => { - stateManager.labelPosition.next(newPosition as ControlLabelPosition); + stateManager.api.setLabelPosition(newPosition as ControlLabelPosition); }} /> @@ -149,7 +149,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager } checked={selectedChainingSystem === 'HIERARCHICAL'} onChange={(e) => - stateManager.chainingSystem.next(e.target.checked ? 'HIERARCHICAL' : 'NONE') + stateManager.api.setChainingSystem(e.target.checked ? 'HIERARCHICAL' : 'NONE') } /> @@ -163,7 +163,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager /> } checked={selectedAutoApplySelections} - onChange={(e) => stateManager.autoApplySelections.next(e.target.checked)} + onChange={(e) => stateManager.api.setAutoApplySelections(e.target.checked)} /> diff --git a/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx b/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx index 181160fae4d13..009bf5a3a3d39 100644 --- a/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx @@ -7,12 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; +import React, { useEffect, useImperativeHandle, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; -import { initializeUnsavedChanges } from '@kbn/presentation-containers'; -import { StateComparators } from '@kbn/presentation-publishing'; - import type { DefaultControlState } from '../../../common'; import { getControlFactory } from '../../control_factory_registry'; import type { ControlApiRegistration, DefaultControlApi } from '../../controls/types'; @@ -38,8 +35,6 @@ export const ControlRenderer = < onApiAvailable?: (api: ApiType) => void; isControlGroupInitialized: boolean; }) => { - const cleanupFunction = useRef<(() => void) | null>(null); - const [component, setComponent] = useState>( undefined ); @@ -49,33 +44,26 @@ export const ControlRenderer = < let ignore = false; async function buildControl() { - const parentApi = getParentApi(); + const controlGroupApi = getParentApi(); const factory = await getControlFactory(type); - const buildApi = ( - apiRegistration: ControlApiRegistration, - comparators: StateComparators - ): ApiType => { - const unsavedChanges = initializeUnsavedChanges( - parentApi.getLastSavedControlState(uuid) as StateType, - parentApi, - comparators - ); - - cleanupFunction.current = () => unsavedChanges.cleanup(); - + const finalizeApi = (apiRegistration: ControlApiRegistration): ApiType => { return { ...apiRegistration, - ...unsavedChanges.api, uuid, - parentApi, + parentApi: controlGroupApi, type: factory.type, } as unknown as ApiType; }; - const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? { + const { rawState: initialState } = controlGroupApi.getSerializedStateForChild(uuid) ?? { rawState: {}, }; - return await factory.buildControl(initialState as StateType, buildApi, uuid, parentApi); + return await factory.buildControl({ + initialState: initialState as StateType, + finalizeApi, + uuid, + controlGroupApi, + }); } buildControl() @@ -127,12 +115,6 @@ export const ControlRenderer = < [type] ); - useEffect(() => { - return () => { - cleanupFunction.current?.(); - }; - }, []); - return component && isControlGroupInitialized ? ( // @ts-expect-error Component={component} uuid={uuid} /> diff --git a/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.test.tsx b/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.test.tsx index a0ca9b74f222f..c98ca6803b437 100644 --- a/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.test.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.test.tsx @@ -66,7 +66,7 @@ describe('control group renderer', () => { expect(buildControlGroupSpy).toBeCalledTimes(1); act(() => api.updateInput({ autoApplySelections: false })); await waitFor(() => { - expect(buildControlGroupSpy).toBeCalledTimes(2); + expect(buildControlGroupSpy).toBeCalledTimes(1); }); }); diff --git a/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.tsx b/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.tsx index 6a5e2a1794c3f..c4bc8f0e9e9eb 100644 --- a/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.tsx @@ -7,30 +7,27 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { omit } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { BehaviorSubject, Subject, map } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import { useSearchApi, type ViewMode } from '@kbn/presentation-publishing'; import type { ControlGroupApi } from '../..'; import { CONTROL_GROUP_TYPE, - DEFAULT_CONTROL_LABEL_POSITION, type ControlGroupRuntimeState, type ControlGroupSerializedState, - DEFAULT_CONTROL_CHAINING, - DEFAULT_AUTO_APPLY_SELECTIONS, } from '../../../common'; import { type ControlGroupStateBuilder, controlGroupStateBuilder, } from '../utils/control_group_state_builder'; -import { getDefaultControlGroupRuntimeState } from '../utils/initialization_utils'; import type { ControlGroupCreationOptions, ControlGroupRendererApi } from './types'; +import { deserializeControlGroup } from '../utils/serialization_utils'; +import { defaultRuntimeState, serializeRuntimeState } from '../utils/serialize_runtime_state'; export interface ControlGroupRendererProps { onApiAvailable: (api: ControlGroupRendererApi) => void; @@ -56,8 +53,9 @@ export const ControlGroupRenderer = ({ dataLoading, compressed, }: ControlGroupRendererProps) => { + const lastState$Ref = useRef(new BehaviorSubject(serializeRuntimeState({}))); const id = useMemo(() => uuidv4(), []); - const [regenerateId, setRegenerateId] = useState(uuidv4()); + const [isStateLoaded, setIsStateLoaded] = useState(false); const [controlGroup, setControlGroup] = useState(); /** @@ -91,69 +89,39 @@ export const ControlGroupRenderer = ({ const reload$ = useMemo(() => new Subject(), []); - /** - * Control group API set up - */ - const runtimeState$ = useMemo( - () => new BehaviorSubject(getDefaultControlGroupRuntimeState()), - [] - ); - const [serializedState, setSerializedState] = useState(); - - const updateInput = useCallback( - (newState: Partial) => { - runtimeState$.next({ - ...runtimeState$.getValue(), - ...newState, - }); - }, - [runtimeState$] - ); - - /** - * To mimic `input$`, subscribe to unsaved changes and snapshot the runtime state whenever - * something change - */ useEffect(() => { if (!controlGroup) return; - const stateChangeSubscription = controlGroup.unsavedChanges$.subscribe((changes) => { - runtimeState$.next({ ...runtimeState$.getValue(), ...changes }); + const subscription = controlGroup.hasUnsavedChanges$.subscribe((hasUnsavedChanges) => { + if (hasUnsavedChanges) lastState$Ref.current.next(controlGroup.serializeState()); }); return () => { - stateChangeSubscription.unsubscribe(); + subscription.unsubscribe(); }; - }, [controlGroup, runtimeState$]); + }, [controlGroup]); /** * On mount */ useEffect(() => { + if (!getCreationOptions) { + setIsStateLoaded(true); + return; + } + let cancelled = false; - (async () => { - const { initialState, editorConfig } = - (await getCreationOptions?.( - getDefaultControlGroupRuntimeState(), - controlGroupStateBuilder - )) ?? {}; - updateInput({ - ...initialState, - editorConfig, - }); - const state: ControlGroupSerializedState = { - ...omit(initialState, ['initialChildControlState']), - editorConfig, - autoApplySelections: initialState?.autoApplySelections ?? DEFAULT_AUTO_APPLY_SELECTIONS, - labelPosition: initialState?.labelPosition ?? DEFAULT_CONTROL_LABEL_POSITION, - chainingSystem: initialState?.chainingSystem ?? DEFAULT_CONTROL_CHAINING, - controls: Object.entries(initialState?.initialChildControlState ?? {}).map( - ([controlId, value]) => ({ ...value, id: controlId }) - ), - }; - if (!cancelled) { - setSerializedState(state); - } - })(); + getCreationOptions(defaultRuntimeState, controlGroupStateBuilder) + .then(({ initialState, editorConfig }) => { + if (cancelled) return; + const initialRuntimeState = { + ...(initialState ?? defaultRuntimeState), + editorConfig, + } as ControlGroupRuntimeState; + lastState$Ref.current.next(serializeRuntimeState(initialRuntimeState)); + setIsStateLoaded(true); + }) + .catch(); + return () => { cancelled = true; }; @@ -161,9 +129,8 @@ export const ControlGroupRenderer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return !serializedState ? null : ( - - key={regenerateId} // this key forces a re-mount when `updateInput` is called + return !isStateLoaded ? null : ( + maybeId={id} type={CONTROL_GROUP_TYPE} getParentApi={() => ({ @@ -173,12 +140,9 @@ export const ControlGroupRenderer = ({ query$: searchApi.query$, timeRange$: searchApi.timeRange$, unifiedSearchFilters$: searchApi.filters$, - getSerializedStateForChild: () => ({ - rawState: serializedState, - }), - getRuntimeStateForChild: () => { - return runtimeState$.getValue(); - }, + getSerializedStateForChild: () => lastState$Ref.current.value, + lastSavedStateForChild$: () => lastState$Ref.current, + getLastSavedStateForChild: () => lastState$Ref.current.value, compressed: compressed ?? true, })} onApiAvailable={async (controlGroupApi) => { @@ -186,11 +150,17 @@ export const ControlGroupRenderer = ({ const controlGroupRendererApi: ControlGroupRendererApi = { ...controlGroupApi, reload: () => reload$.next(), - updateInput: (newInput) => { - updateInput(newInput); - setRegenerateId(uuidv4()); // force remount + updateInput: (newInput: Partial) => { + lastState$Ref.current.next( + serializeRuntimeState({ + ...lastState$Ref.current.value, + ...newInput, + }) + ); + controlGroupApi.resetUnsavedChanges(); }, - getInput$: () => runtimeState$, + getInput$: () => lastState$Ref.current.pipe(map(deserializeControlGroup)), + getInput: () => deserializeControlGroup(lastState$Ref.current.value), }; setControlGroup(controlGroupRendererApi); onApiAvailable(controlGroupRendererApi); diff --git a/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/types.ts b/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/types.ts index 80457c2c64b14..c4ba39e63f06b 100644 --- a/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/types.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject } from 'rxjs'; +import { Observable } from 'rxjs'; import type { ControlGroupEditorConfig, ControlGroupRuntimeState } from '../../../common'; import type { ControlGroupApi } from '../..'; @@ -18,7 +18,7 @@ export type ControlGroupRendererApi = ControlGroupApi & { * @deprecated * Calling `updateInput` will cause the entire control group to be re-initialized. * - * Therefore, to update the runtime state without `updateInput`, you should add public setters to the + * Therefore, to update state without `updateInput`, you should add public setters to the * relavant API (`ControlGroupApi` or the individual control type APIs) for the state you wish to update * and call those setters instead. */ @@ -29,7 +29,12 @@ export type ControlGroupRendererApi = ControlGroupApi & { * Instead of subscribing to the whole runtime state, it is more efficient to subscribe to the individual * publishing subjects of the control group API. */ - getInput$: () => BehaviorSubject; + getInput$: () => Observable; + + /** + * @deprecated + */ + getInput: () => ControlGroupRuntimeState; }; export interface ControlGroupCreationOptions { diff --git a/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts b/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts index 1331afc4e8f12..32610b00f74c3 100644 --- a/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts +++ b/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts @@ -7,71 +7,113 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { omit } from 'lodash'; -import { combineLatest, map } from 'rxjs'; +import fastIsEqual from 'fast-deep-equal'; +import { combineLatest, combineLatestWith, debounceTime, map, merge, of } from 'rxjs'; import { + apiHasLastSavedChildState, childrenUnsavedChanges$, initializeUnsavedChanges, type PresentationContainer, } from '@kbn/presentation-containers'; import { apiPublishesUnsavedChanges, - type PublishesUnsavedChanges, - type StateComparators, + PublishingSubject, + SerializedPanelState, } from '@kbn/presentation-publishing'; -import type { ControlGroupRuntimeState, ControlPanelsState } from '../../common'; +import { StateManager } from '@kbn/presentation-publishing/state_manager/types'; +import type { ControlGroupSerializedState, ControlPanelsState } from '../../common'; import { apiPublishesAsyncFilters } from '../controls/data_controls/publishes_async_filters'; import { getControlsInOrder, type ControlsInOrder } from './init_controls_manager'; +import { deserializeControlGroup } from './utils/serialization_utils'; +import { ControlGroupEditorState } from './types'; +import { defaultEditorState, editorStateComparators } from './initialize_editor_state_manager'; -export type ControlGroupComparatorState = Pick< - ControlGroupRuntimeState, - 'autoApplySelections' | 'chainingSystem' | 'ignoreParentSettings' | 'labelPosition' -> & { - controlsInOrder: ControlsInOrder; -}; +export function initializeControlGroupUnsavedChanges({ + applySelections, + children$, + controlGroupId, + editorStateManager, + layout$, + parentApi, + resetControlsUnsavedChanges, + serializeControlGroupState, +}: { + applySelections: () => void; + children$: PresentationContainer['children$']; + controlGroupId: string; + editorStateManager: StateManager; + layout$: PublishingSubject; + parentApi: unknown; + resetControlsUnsavedChanges: (lastSavedControlsState: ControlPanelsState) => void; + serializeControlGroupState: () => SerializedPanelState; +}) { + function getLastSavedControlsState() { + if (!apiHasLastSavedChildState(parentApi)) { + return {}; + } + const lastSavedControlGroupState = parentApi.getLastSavedStateForChild(controlGroupId); + return lastSavedControlGroupState + ? deserializeControlGroup(lastSavedControlGroupState).initialChildControlState + : {}; + } -export function initializeControlGroupUnsavedChanges( - applySelections: () => void, - children$: PresentationContainer['children$'], - comparators: StateComparators, - snapshotControlsRuntimeState: () => ControlPanelsState, - resetControlsUnsavedChanges: () => void, - parentApi: unknown, - lastSavedRuntimeState: ControlGroupRuntimeState -) { - const controlGroupUnsavedChanges = initializeUnsavedChanges( - { - autoApplySelections: lastSavedRuntimeState.autoApplySelections, - chainingSystem: lastSavedRuntimeState.chainingSystem, - controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState), - ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings, - labelPosition: lastSavedRuntimeState.labelPosition, - }, + function getLastSavedStateForControl(controlId: string) { + const controlState = getLastSavedControlsState()[controlId]; + return controlState ? { rawState: controlState } : undefined; + } + + const lastSavedControlsState$ = apiHasLastSavedChildState(parentApi) + ? parentApi.lastSavedStateForChild$(controlGroupId).pipe(map(() => getLastSavedControlsState())) + : of({}); + + const controlGroupEditorUnsavedChangesApi = initializeUnsavedChanges({ + uuid: controlGroupId, parentApi, - comparators + serializeState: serializeControlGroupState, + anyStateChange$: merge(editorStateManager.anyStateChange$), + getComparators: () => editorStateComparators, + defaultState: defaultEditorState, + onReset: (lastSaved) => { + editorStateManager.reinitializeState(lastSaved?.rawState); + }, + }); + + const hasLayoutChanges$ = layout$.pipe( + combineLatestWith( + lastSavedControlsState$.pipe(map((controlsState) => getControlsInOrder(controlsState))) + ), + debounceTime(100), + map(([, lastSavedLayout]) => { + const currentLayout = layout$.value; + return !fastIsEqual(currentLayout, lastSavedLayout); + }) + ); + + const hasControlChanges$ = childrenUnsavedChanges$(children$).pipe( + map((childrenWithChanges) => { + return childrenWithChanges.some(({ hasUnsavedChanges }) => hasUnsavedChanges); + }) ); return { api: { - unsavedChanges$: combineLatest([ - controlGroupUnsavedChanges.api.unsavedChanges$, - childrenUnsavedChanges$(children$), + lastSavedStateForChild$: (controlId: string) => + lastSavedControlsState$.pipe(map(() => getLastSavedStateForControl(controlId))), + getLastSavedStateForChild: getLastSavedStateForControl, + hasUnsavedChanges$: combineLatest([ + controlGroupEditorUnsavedChangesApi.hasUnsavedChanges$, + hasControlChanges$, + hasLayoutChanges$, ]).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; + map(([hasUnsavedControlGroupChanges, hasControlChanges, hasLayoutChanges]) => { + return hasUnsavedControlGroupChanges || hasControlChanges || hasLayoutChanges; }) ), - asyncResetUnsavedChanges: async () => { - controlGroupUnsavedChanges.api.resetUnsavedChanges(); - resetControlsUnsavedChanges(); + resetUnsavedChanges: async () => { + controlGroupEditorUnsavedChangesApi.resetUnsavedChanges(); + resetControlsUnsavedChanges(getLastSavedControlsState()); const filtersReadyPromises: Array> = []; Object.values(children$.value).forEach((controlApi) => { @@ -83,12 +125,10 @@ export function initializeControlGroupUnsavedChanges( await Promise.all(filtersReadyPromises); - if (!comparators.autoApplySelections[0].value) { + if (!editorStateManager.api.autoApplySelections$.value) { applySelections(); } }, - } as Pick & { - asyncResetUnsavedChanges: () => Promise; }, }; } diff --git a/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx b/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx index f71ba4845a391..c6d628a3d7ef5 100644 --- a/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx @@ -8,36 +8,21 @@ */ import { DataView } from '@kbn/data-views-plugin/common'; -import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import type { ESQLControlVariable } from '@kbn/esql-types'; import { PublishesESQLVariable, apiPublishesESQLVariable } from '@kbn/esql-types'; import { i18n } from '@kbn/i18n'; -import { - apiHasSaveNotification, - combineCompatibleChildrenApis, -} from '@kbn/presentation-containers'; +import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { PublishesDataViews, apiPublishesDataViews, useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload'; -import fastIsEqual from 'fast-deep-equal'; import React, { useEffect } from 'react'; import { BehaviorSubject } from 'rxjs'; -import type { - ControlGroupChainingSystem, - ControlGroupRuntimeState, - ControlGroupSerializedState, - ControlLabelPosition, - ControlPanelsState, - ParentIgnoreSettings, -} from '../../common'; -import { - CONTROL_GROUP_TYPE, - DEFAULT_CONTROL_CHAINING, - DEFAULT_CONTROL_LABEL_POSITION, -} from '../../common'; +import type { ControlGroupSerializedState } from '../../common'; +import { CONTROL_GROUP_TYPE } from '../../common'; import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor'; import { coreServices, dataViewsService } from '../services/kibana_services'; import { ControlGroup } from './components/control_group'; @@ -48,88 +33,55 @@ import { openEditControlGroupFlyout } from './open_edit_control_group_flyout'; import { initSelectionsManager } from './selections_manager'; import type { ControlGroupApi } from './types'; import { deserializeControlGroup } from './utils/serialization_utils'; +import { initializeEditorStateManager } from './initialize_editor_state_manager'; export const getControlGroupEmbeddableFactory = () => { - const controlGroupEmbeddableFactory: ReactEmbeddableFactory< + const controlGroupEmbeddableFactory: EmbeddableFactory< ControlGroupSerializedState, - ControlGroupRuntimeState, ControlGroupApi > = { type: CONTROL_GROUP_TYPE, - deserializeState: (state) => deserializeControlGroup(state), - buildEmbeddable: async ( - initialRuntimeState, - buildApi, - uuid, - parentApi, - setApi, - lastSavedRuntimeState - ) => { - const { - labelPosition: initialLabelPosition, - chainingSystem, - autoApplySelections, - ignoreParentSettings, - } = initialRuntimeState; + buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => { + const initialRuntimeState = deserializeControlGroup(initialState); + + const editorStateManager = initializeEditorStateManager(initialState?.rawState); - const autoApplySelections$ = new BehaviorSubject(autoApplySelections); const defaultDataViewId = await dataViewsService.getDefaultId(); - const lastSavedControlsState$ = new BehaviorSubject( - lastSavedRuntimeState.initialChildControlState - ); - const controlsManager = initControlsManager( - initialRuntimeState.initialChildControlState, - lastSavedControlsState$ - ); + + const controlsManager = initControlsManager(initialRuntimeState.initialChildControlState); const selectionsManager = initSelectionsManager({ ...controlsManager.api, - autoApplySelections$, + autoApplySelections$: editorStateManager.api.autoApplySelections$, }); const esqlVariables$ = new BehaviorSubject([]); const dataViews$ = new BehaviorSubject(undefined); - const chainingSystem$ = new BehaviorSubject( - chainingSystem ?? DEFAULT_CONTROL_CHAINING - ); - const ignoreParentSettings$ = new BehaviorSubject( - ignoreParentSettings - ); - const labelPosition$ = new BehaviorSubject( - initialLabelPosition ?? DEFAULT_CONTROL_LABEL_POSITION - ); + const allowExpensiveQueries$ = new BehaviorSubject(true); const disabledActionIds$ = new BehaviorSubject(undefined); - const unsavedChanges = initializeControlGroupUnsavedChanges( - selectionsManager.applySelections, - controlsManager.api.children$, - { - ...controlsManager.comparators, - autoApplySelections: [ - autoApplySelections$, - (next: boolean) => autoApplySelections$.next(next), - ], - chainingSystem: [ - chainingSystem$, - (next: ControlGroupChainingSystem) => chainingSystem$.next(next), - (a, b) => (a ?? DEFAULT_CONTROL_CHAINING) === (b ?? DEFAULT_CONTROL_CHAINING), - ], - ignoreParentSettings: [ - ignoreParentSettings$, - (next: ParentIgnoreSettings | undefined) => ignoreParentSettings$.next(next), - fastIsEqual, - ], - labelPosition: [ - labelPosition$, - (next: ControlLabelPosition) => labelPosition$.next(next), - ], - }, - controlsManager.snapshotControlsRuntimeState, - controlsManager.resetControlsUnsavedChanges, + function serializeState() { + const { controls, references } = controlsManager.serializeControls(); + return { + rawState: { + ...editorStateManager.getLatestState(), + controls, + }, + references, + }; + } + + const unsavedChanges = initializeControlGroupUnsavedChanges({ + applySelections: selectionsManager.applySelections, + children$: controlsManager.api.children$, + controlGroupId: uuid, + editorStateManager, + layout$: controlsManager.controlsInOrder$, parentApi, - lastSavedRuntimeState - ); + resetControlsUnsavedChanges: controlsManager.resetControlsUnsavedChanges, + serializeControlGroupState: serializeState, + }); - const api = setApi({ + const api = finalizeApi({ ...controlsManager.api, esqlVariables$, disabledActionIds$, @@ -139,31 +91,21 @@ export const getControlGroupEmbeddableFactory = () => { controlFetch$( chaining$( controlUuid, - chainingSystem$, + editorStateManager.api.chainingSystem$, controlsManager.controlsInOrder$, controlsManager.api.children$ ), - controlGroupFetch$(ignoreParentSettings$, parentApi ? parentApi : {}, onReload) + controlGroupFetch$( + editorStateManager.api.ignoreParentSettings$, + parentApi ? parentApi : {}, + onReload + ) ), - ignoreParentSettings$, - autoApplySelections$, + ignoreParentSettings$: editorStateManager.api.ignoreParentSettings$, + autoApplySelections$: editorStateManager.api.autoApplySelections$, allowExpensiveQueries$, - snapshotRuntimeState: () => { - return { - chainingSystem: chainingSystem$.getValue(), - labelPosition: labelPosition$.getValue(), - autoApplySelections: autoApplySelections$.getValue(), - ignoreParentSettings: ignoreParentSettings$.getValue(), - initialChildControlState: controlsManager.snapshotControlsRuntimeState(), - }; - }, onEdit: async () => { - openEditControlGroupFlyout(api, { - chainingSystem: chainingSystem$, - labelPosition: labelPosition$, - autoApplySelections: autoApplySelections$, - ignoreParentSettings: ignoreParentSettings$, - }); + openEditControlGroupFlyout(api, editorStateManager); }, isEditingEnabled: () => true, openAddDataControlFlyout: (settings) => { @@ -178,36 +120,23 @@ export const getControlGroupEmbeddableFactory = () => { dataViewId: newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined, }, - onSave: ({ type: controlType, state: initialState }) => { + onSave: ({ type: controlType, state: onSaveState }) => { controlsManager.api.addNewPanel({ panelType: controlType, - initialState: settings?.controlStateTransform - ? settings.controlStateTransform(initialState, controlType) - : initialState, + serializedState: { + rawState: settings?.controlStateTransform + ? settings.controlStateTransform(onSaveState, controlType) + : onSaveState, + }, }); settings?.onSave?.(); }, controlGroupApi: api, }); }, - serializeState: () => { - const { controls, references } = controlsManager.serializeControls(); - return { - rawState: { - chainingSystem: chainingSystem$.getValue(), - labelPosition: labelPosition$.getValue(), - autoApplySelections: autoApplySelections$.getValue(), - ignoreParentSettings: ignoreParentSettings$.getValue(), - controls, - }, - references, - }; - }, + serializeState, dataViews$, - labelPosition: labelPosition$, - saveNotification$: apiHasSaveNotification(parentApi) - ? parentApi.saveNotification$ - : undefined, + labelPosition: editorStateManager.api.labelPosition$, reload$: apiPublishesReload(parentApi) ? parentApi.reload$ : undefined, /** Public getters */ @@ -216,13 +145,10 @@ export const getControlGroupEmbeddableFactory = () => { defaultMessage: 'Controls', }), getEditorConfig: () => initialRuntimeState.editorConfig, - getLastSavedControlState: (controlUuid: string) => { - return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {}; - }, /** Public setters */ setDisabledActionIds: (ids) => disabledActionIds$.next(ids), - setChainingSystem: (newChainingSystem) => chainingSystem$.next(newChainingSystem), + setChainingSystem: editorStateManager.api.setChainingSystem, }); /** Subscribe to all children's output data views, combine them, and output them */ @@ -241,26 +167,12 @@ export const getControlGroupEmbeddableFactory = () => { esqlVariables$.next(newESQLVariables); }); - const saveNotificationSubscription = apiHasSaveNotification(parentApi) - ? parentApi.saveNotification$.subscribe(() => { - lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState()); - - if ( - typeof autoApplySelections$.value === 'boolean' && - !autoApplySelections$.value && - selectionsManager.hasUnappliedSelections$.value - ) { - selectionsManager.applySelections(); - } - }) - : undefined; - return { api, Component: () => { const [hasUnappliedSelections, labelPosition] = useBatchedPublishingSubjects( selectionsManager.hasUnappliedSelections$, - labelPosition$ + editorStateManager.api.labelPosition$ ); useEffect(() => { @@ -286,7 +198,6 @@ export const getControlGroupEmbeddableFactory = () => { selectionsManager.cleanup(); childrenDataViewsSubscription.unsubscribe(); childrenESQLVariablesSubscription.unsubscribe(); - saveNotificationSubscription?.unsubscribe(); }; }, []); diff --git a/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.test.ts b/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.test.ts index d88dc5452a0e5..4ada99133cb02 100644 --- a/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.test.ts +++ b/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.test.ts @@ -7,8 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject } from 'rxjs'; -import type { ControlPanelState, ControlPanelsState, DefaultDataControlState } from '../../common'; +import type { ControlPanelState, DefaultDataControlState } from '../../common'; import type { DefaultControlApi } from '../controls/types'; import { getLastUsedDataViewId, initControlsManager } from './init_controls_manager'; @@ -22,13 +21,12 @@ describe('PresentationContainer api', () => { bravo: { type: 'testControl', order: 1 }, charlie: { type: 'testControl', order: 2 }, }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); test('addNewPanel should add control at end of controls', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); const addNewPanelPromise = controlsManager.api.addNewPanel({ panelType: 'testControl', - initialState: {}, + serializedState: { rawState: {} }, }); controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); await addNewPanelPromise; @@ -41,7 +39,7 @@ describe('PresentationContainer api', () => { }); test('removePanel should remove control', () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); controlsManager.api.removePanel('bravo'); expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([ 'alpha', @@ -50,10 +48,10 @@ describe('PresentationContainer api', () => { }); test('replacePanel should replace control', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); const replacePanelPromise = controlsManager.api.replacePanel('bravo', { panelType: 'testControl', - initialState: {}, + serializedState: { rawState: {} }, }); controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); await replacePanelPromise; @@ -66,7 +64,7 @@ describe('PresentationContainer api', () => { describe('untilInitialized', () => { test('should not resolve until all controls are initialized', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); let isDone = false; controlsManager.api.untilInitialized().then(() => { isDone = true; @@ -88,7 +86,7 @@ describe('PresentationContainer api', () => { }); test('should resolve when all control already initialized ', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi); controlsManager.setControlApi('charlie', {} as unknown as DefaultControlApi); @@ -104,40 +102,6 @@ describe('PresentationContainer api', () => { }); }); -describe('snapshotControlsRuntimeState', () => { - const intialControlsState = { - alpha: { type: 'testControl', order: 1 }, - bravo: { type: 'testControl', order: 0 }, - }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - - test('should snapshot runtime state for all controls', async () => { - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); - 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', - }, - }); - }); -}); - describe('getLastUsedDataViewId', () => { test('should return last used data view id', () => { const dataViewId = getLastUsedDataViewId( @@ -175,8 +139,7 @@ describe('resetControlsUnsavedChanges', () => { alpha: { type: 'testControl', order: 0 }, }; // last saved state is empty control group - const lastSavedControlsState$ = new BehaviorSubject({}); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); expect(controlsManager.controlsInOrder$.value).toEqual([ @@ -186,7 +149,8 @@ describe('resetControlsUnsavedChanges', () => { }, ]); - controlsManager.resetControlsUnsavedChanges(); + // last saved state is empty control group + controlsManager.resetControlsUnsavedChanges({}); expect(controlsManager.controlsInOrder$.value).toEqual([]); }); @@ -194,15 +158,14 @@ describe('resetControlsUnsavedChanges', () => { const intialControlsState = { alpha: { type: 'testControl', order: 0 }, }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); // delete control controlsManager.api.removePanel('alpha'); // deleted control should exist on reset - controlsManager.resetControlsUnsavedChanges(); + controlsManager.resetControlsUnsavedChanges(intialControlsState); expect(controlsManager.controlsInOrder$.value).toEqual([ { id: 'alpha', @@ -213,22 +176,14 @@ describe('resetControlsUnsavedChanges', () => { test('should restore controls to last saved state', () => { const intialControlsState = {}; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); // add control controlsManager.api.addNewPanel({ panelType: 'testControl' }); - controlsManager.setControlApi('delta', { - snapshotRuntimeState: () => { - return {}; - }, - } as unknown as DefaultControlApi); - - // simulate save - lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState()); + controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); // saved control should exist on reset - controlsManager.resetControlsUnsavedChanges(); + controlsManager.resetControlsUnsavedChanges({ delta: { type: 'testControl', order: 0 } }); expect(controlsManager.controlsInOrder$.value).toEqual([ { id: 'delta', @@ -243,8 +198,7 @@ describe('resetControlsUnsavedChanges', () => { const intialControlsState = { alpha: { type: 'testControl', order: 0 }, }; - const lastSavedControlsState$ = new BehaviorSubject(intialControlsState); - const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$); + const controlsManager = initControlsManager(intialControlsState); controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi); // add another control @@ -253,7 +207,7 @@ describe('resetControlsUnsavedChanges', () => { expect(Object.keys(controlsManager.api.children$.value).length).toBe(2); // reset to lastSavedControlsState - controlsManager.resetControlsUnsavedChanges(); + controlsManager.resetControlsUnsavedChanges(intialControlsState); // children$ should no longer contain control removed by resetting back to original control baseline expect(Object.keys(controlsManager.api.children$.value).length).toBe(1); }); @@ -261,7 +215,7 @@ describe('resetControlsUnsavedChanges', () => { describe('getNewControlState', () => { test('should contain defaults when there are no existing controls', () => { - const controlsManager = initControlsManager({}, new BehaviorSubject({})); + const controlsManager = initControlsManager({}); expect(controlsManager.getNewControlState()).toEqual({ grow: false, width: 'medium', @@ -279,10 +233,7 @@ describe('getNewControlState', () => { grow: false, } as ControlPanelState & Pick, }; - const controlsManager = initControlsManager( - intialControlsState, - new BehaviorSubject(intialControlsState) - ); + const controlsManager = initControlsManager(intialControlsState); expect(controlsManager.getNewControlState()).toEqual({ grow: false, width: 'medium', @@ -291,13 +242,15 @@ describe('getNewControlState', () => { }); test('should contain values of last added control', () => { - const controlsManager = initControlsManager({}, new BehaviorSubject({})); + const controlsManager = initControlsManager({}); controlsManager.api.addNewPanel({ panelType: 'testControl', - initialState: { - grow: false, - width: 'small', - dataViewId: 'myOtherDataViewId', + serializedState: { + rawState: { + grow: false, + width: 'small', + dataViewId: 'myOtherDataViewId', + }, }, }); expect(controlsManager.getNewControlState()).toEqual({ diff --git a/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts b/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts index cd8c2bea4f907..16f90aa5f660a 100644 --- a/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts +++ b/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import fastIsEqual from 'fast-deep-equal'; import { omit } from 'lodash'; import { v4 as generateId } from 'uuid'; @@ -17,11 +16,7 @@ import type { PanelPackage, PresentationContainer, } from '@kbn/presentation-containers'; -import { - type PublishingSubject, - type StateComparators, - apiHasSnapshottableState, -} from '@kbn/presentation-publishing'; +import type { PublishingSubject } from '@kbn/presentation-publishing'; import { BehaviorSubject, first, merge } from 'rxjs'; import type { ControlGroupSerializedState, @@ -33,7 +28,6 @@ import type { } from '../../common'; import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../common'; import type { DefaultControlApi } from '../controls/types'; -import type { ControlGroupComparatorState } from './control_group_unsaved_changes_api'; import type { ControlGroupApi } from './types'; export type ControlsInOrder = Array<{ id: string; type: string }>; @@ -53,11 +47,7 @@ export function initControlsManager( /** * Composed from last saved controls state and previous sessions's unsaved changes to controls state */ - initialControlsState: ControlPanelsState, - /** - * Observable that publishes last saved controls state only - */ - lastSavedControlsState$: PublishingSubject + initialControlsState: ControlPanelsState ) { const initialControlIds = Object.keys(initialControlsState); const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); @@ -103,17 +93,17 @@ export function initControlsManager( } async function addNewPanel( - { panelType, initialState }: PanelPackage<{}, DefaultControlState>, + { panelType, serializedState }: PanelPackage, index: number ) { - if ((initialState as DefaultDataControlState)?.dataViewId) { - lastUsedDataViewId$.next((initialState as DefaultDataControlState).dataViewId); + if ((serializedState?.rawState as DefaultDataControlState)?.dataViewId) { + lastUsedDataViewId$.next((serializedState!.rawState as DefaultDataControlState).dataViewId); } - if (initialState?.width) { - lastUsedWidth$.next(initialState.width); + if (serializedState?.rawState?.width) { + lastUsedWidth$.next(serializedState.rawState.width); } - if (typeof initialState?.grow === 'boolean') { - lastUsedGrow$.next(initialState.grow); + if (typeof serializedState?.rawState?.grow === 'boolean') { + lastUsedGrow$.next(serializedState.rawState.grow); } const id = generateId(); @@ -123,7 +113,7 @@ export function initControlsManager( type: panelType, }); controlsInOrder$.next(nextControlsInOrder); - currentControlsState[id] = initialState ?? {}; + currentControlsState[id] = serializedState?.rawState ?? {}; return await untilControlLoaded(id); } @@ -185,23 +175,9 @@ export function initControlsManager( 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; - }, - resetControlsUnsavedChanges: () => { + resetControlsUnsavedChanges: (lastSavedControlsState: ControlPanelsState) => { currentControlsState = { - ...lastSavedControlsState$.value, + ...lastSavedControlsState, }; const nextControlsInOrder = getControlsInOrder(currentControlsState as ControlPanelsState); controlsInOrder$.next(nextControlsInOrder); @@ -263,13 +239,6 @@ export function initControlsManager( } as PresentationContainer & HasSerializedChildState & Pick, - comparators: { - controlsInOrder: [ - controlsInOrder$, - (next: ControlsInOrder) => {}, // setter does nothing, controlsInOrder$ reset by resetControlsRuntimeState - fastIsEqual, - ], - } as StateComparators>, }; } diff --git a/src/platform/plugins/shared/controls/public/control_group/utils/initialization_utils.ts b/src/platform/plugins/shared/controls/public/control_group/initialize_editor_state_manager.ts similarity index 56% rename from src/platform/plugins/shared/controls/public/control_group/utils/initialization_utils.ts rename to src/platform/plugins/shared/controls/public/control_group/initialize_editor_state_manager.ts index a35572387e1e1..8f44b9eddc8e0 100644 --- a/src/platform/plugins/shared/controls/public/control_group/utils/initialization_utils.ts +++ b/src/platform/plugins/shared/controls/public/control_group/initialize_editor_state_manager.ts @@ -7,18 +7,29 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { StateComparators, initializeStateManager } from '@kbn/presentation-publishing'; +import { ControlGroupEditorState } from './types'; import { - type ControlGroupRuntimeState, + DEFAULT_AUTO_APPLY_SELECTIONS, DEFAULT_CONTROL_CHAINING, DEFAULT_CONTROL_LABEL_POSITION, - DEFAULT_AUTO_APPLY_SELECTIONS, DEFAULT_IGNORE_PARENT_SETTINGS, -} from '../../../common'; +} from '../../common'; -export const getDefaultControlGroupRuntimeState = (): ControlGroupRuntimeState => ({ - initialChildControlState: {}, - labelPosition: DEFAULT_CONTROL_LABEL_POSITION, - chainingSystem: DEFAULT_CONTROL_CHAINING, +export const defaultEditorState = { autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + chainingSystem: DEFAULT_CONTROL_CHAINING, ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, -}); + labelPosition: DEFAULT_CONTROL_LABEL_POSITION, +}; + +export const editorStateComparators: StateComparators = { + autoApplySelections: 'referenceEquality', + chainingSystem: 'referenceEquality', + ignoreParentSettings: 'deepEquality', + labelPosition: 'referenceEquality', +}; + +export function initializeEditorStateManager(initialState: ControlGroupEditorState) { + return initializeStateManager(initialState, defaultEditorState); +} diff --git a/src/platform/plugins/shared/controls/public/control_group/open_edit_control_group_flyout.tsx b/src/platform/plugins/shared/controls/public/control_group/open_edit_control_group_flyout.tsx index 52a19aef8b1e0..1d1c03ec1a5df 100644 --- a/src/platform/plugins/shared/controls/public/control_group/open_edit_control_group_flyout.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/open_edit_control_group_flyout.tsx @@ -13,31 +13,17 @@ import { tracksOverlays } from '@kbn/presentation-containers'; import { apiHasParentApi } from '@kbn/presentation-publishing'; import { toMountPoint } from '@kbn/react-kibana-mount'; import React from 'react'; -import { BehaviorSubject } from 'rxjs'; -import { ControlStateManager } from '../controls/types'; +import { StateManager } from '@kbn/presentation-publishing/state_manager/types'; import { ControlGroupEditor } from './components/control_group_editor'; import { ControlGroupApi, ControlGroupEditorState } from './types'; import { coreServices } from '../services/kibana_services'; export const openEditControlGroupFlyout = ( controlGroupApi: ControlGroupApi, - stateManager: ControlStateManager + stateManager: StateManager ) => { - /** - * Duplicate all state into a new manager because we do not want to actually apply the changes - * to the control group until the user hits save. - */ - const editorStateManager: ControlStateManager = Object.keys( - stateManager - ).reduce((prev, key) => { - return { - ...prev, - [key as keyof ControlGroupEditorState]: new BehaviorSubject( - stateManager[key as keyof ControlGroupEditorState].getValue() - ), - }; - }, {} as ControlStateManager); + const lastSavedState = stateManager.getLatestState(); const closeOverlay = (overlayRef: OverlayRef) => { if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { @@ -78,19 +64,15 @@ export const openEditControlGroupFlyout = ( toMountPoint( { - Object.keys(stateManager).forEach((key) => { - ( - stateManager[key as keyof ControlGroupEditorState] as BehaviorSubject< - ControlGroupEditorState[keyof ControlGroupEditorState] - > - ).next(editorStateManager[key as keyof ControlGroupEditorState].getValue()); - }); closeOverlay(overlay); }} onDeleteAll={() => onDeleteAll(overlay)} - onCancel={() => closeOverlay(overlay)} + onCancel={() => { + stateManager.reinitializeState(lastSavedState); + closeOverlay(overlay); + }} />, coreServices ), diff --git a/src/platform/plugins/shared/controls/public/control_group/types.ts b/src/platform/plugins/shared/controls/public/control_group/types.ts index d82e68c4b6320..77054a5f6c1cf 100644 --- a/src/platform/plugins/shared/controls/public/control_group/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/types.ts @@ -13,7 +13,7 @@ import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import { PublishesESQLVariables } from '@kbn/esql-types'; import { Filter } from '@kbn/es-query'; import { - HasSaveNotification, + HasLastSavedChildState, HasSerializedChildState, PresentationContainer, } from '@kbn/presentation-containers'; @@ -49,22 +49,21 @@ import { ControlFetchContext } from './control_fetch/control_fetch'; */ export type ControlGroupApi = PresentationContainer & - DefaultEmbeddableApi & + DefaultEmbeddableApi & PublishesFilters & PublishesDataViews & PublishesESQLVariables & HasSerializedChildState & HasEditCapabilities & - Pick, 'unsavedChanges$'> & + HasLastSavedChildState & PublishesTimeslice & PublishesDisabledActionIds & - Partial & HasSaveNotification & PublishesReload> & { + PublishesUnsavedChanges & + Partial & PublishesReload> & { allowExpensiveQueries$: PublishingSubject; autoApplySelections$: PublishingSubject; ignoreParentSettings$: PublishingSubject; labelPosition: PublishingSubject; - - asyncResetUnsavedChanges: () => Promise; controlFetch$: (controlUuid: string, onReload?: () => void) => Observable; openAddDataControlFlyout: (options?: { controlStateTransform?: ControlStateTransform; @@ -74,7 +73,6 @@ export type ControlGroupApi = PresentationContainer & /** Public getters */ getEditorConfig: () => ControlGroupEditorConfig | undefined; - getLastSavedControlState: (controlUuid: string) => object; /** Public setters */ setChainingSystem: (chainingSystem: ControlGroupChainingSystem) => void; diff --git a/src/platform/plugins/shared/controls/public/control_group/utils/serialize_runtime_state.ts b/src/platform/plugins/shared/controls/public/control_group/utils/serialize_runtime_state.ts new file mode 100644 index 0000000000000..1958277b6b32d --- /dev/null +++ b/src/platform/plugins/shared/controls/public/control_group/utils/serialize_runtime_state.ts @@ -0,0 +1,45 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { SerializedPanelState } from '@kbn/presentation-publishing'; +import { omit } from 'lodash'; +import { + ControlGroupRuntimeState, + ControlGroupSerializedState, + DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_CONTROL_CHAINING, + DEFAULT_CONTROL_LABEL_POSITION, + DEFAULT_IGNORE_PARENT_SETTINGS, +} from '../../../common'; + +export const defaultRuntimeState = { + labelPosition: DEFAULT_CONTROL_LABEL_POSITION, + chainingSystem: DEFAULT_CONTROL_CHAINING, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, +}; + +/** + * @deprecated use controlGroupApi.serializeState + * Converts runtime state to serialized state + * Only use for BWC when runtime state needs to be converted outside of ControlGroup api + */ +export function serializeRuntimeState( + runtimeState: Partial +): SerializedPanelState { + return { + rawState: { + ...defaultRuntimeState, + ...omit(runtimeState, ['initialChildControlState']), + controls: Object.entries(runtimeState?.initialChildControlState ?? {}).map( + ([controlId, value]) => ({ ...value, id: controlId }) + ), + }, + }; +} diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts index 136c029943eb0..466b47a9c2603 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/initialize_data_control.ts @@ -8,7 +8,18 @@ */ import { isEqual } from 'lodash'; -import { BehaviorSubject, combineLatest, debounceTime, first, skip, switchMap, tap } from 'rxjs'; +import { + BehaviorSubject, + Observable, + combineLatest, + debounceTime, + first, + map, + merge, + skip, + switchMap, + tap, +} from 'rxjs'; import { DATA_VIEW_SAVED_OBJECT_TYPE, @@ -19,15 +30,25 @@ import { Filter } from '@kbn/es-query'; import { StateComparators, SerializedPanelState } from '@kbn/presentation-publishing'; import { i18n } from '@kbn/i18n'; -import type { DefaultControlState, DefaultDataControlState } from '../../../common'; +import type { DefaultDataControlState } from '../../../common'; import { dataViewsService } from '../../services/kibana_services'; import type { ControlGroupApi } from '../../control_group/types'; -import { initializeDefaultControlApi } from '../initialize_default_control_api'; +import { + defaultControlComparators, + initializeDefaultControlApi, +} from '../initialize_default_control_api'; import type { ControlApiInitialization, ControlStateManager } from '../types'; import { openDataControlEditor } from './open_data_control_editor'; import { getReferenceName } from './reference_name_utils'; import type { DataControlApi, DataControlFieldFormatter } from './types'; +export const defaultDataControlComparators: StateComparators = { + ...defaultControlComparators, + title: 'referenceEquality', + dataViewId: 'referenceEquality', + fieldName: 'referenceEquality', +}; + export const initializeDataControl = ( controlId: string, controlType: string, @@ -40,15 +61,16 @@ export const initializeDataControl = ( editorStateManager: ControlStateManager, controlGroupApi: ControlGroupApi ): { - api: ControlApiInitialization; + api: Omit, 'hasUnsavedChanges$' | 'resetUnsavedChanges'>; cleanup: () => void; - comparators: StateComparators; + anyStateChange$: Observable; setters: { onSelectionChange: () => void; setOutputFilter: (filter: Filter | undefined) => void; }; stateManager: ControlStateManager; - serialize: () => SerializedPanelState; + getLatestState: () => SerializedPanelState; + reinitializeState: (lastSaved?: DefaultDataControlState) => void; } => { const defaultControl = initializeDefaultControlApi(state); @@ -164,7 +186,10 @@ export const initializeDataControl = ( ); } else { // replace the control with a new one of the updated type - controlGroupApi.replacePanel(controlId, { panelType: newType, initialState: newState }); + controlGroupApi.replacePanel(controlId, { + panelType: newType, + serializedState: { rawState: newState }, + }); } }, initialState: { @@ -184,42 +209,35 @@ export const initializeDataControl = ( filtersReady$.next(true); }); - const api: ControlApiInitialization = { - ...defaultControl.api, - title$, - defaultTitle$, - dataViews$, - field$, - fieldFormatter, - onEdit, - filters$, - isEditingEnabled: () => true, - untilFiltersReady: async () => { - return new Promise((resolve) => { - combineLatest([defaultControl.api.blockingError$, filtersReady$]) - .pipe( - first(([blockingError, filtersReady]) => filtersReady || blockingError !== undefined) - ) - .subscribe(() => { - resolve(); - }); - }); - }, - }; - return { - api, + api: { + ...defaultControl.api, + title$, + defaultTitle$, + dataViews$, + field$, + fieldFormatter, + onEdit, + filters$, + isEditingEnabled: () => true, + untilFiltersReady: async () => { + return new Promise((resolve) => { + combineLatest([defaultControl.api.blockingError$, filtersReady$]) + .pipe( + first(([blockingError, filtersReady]) => filtersReady || blockingError !== undefined) + ) + .subscribe(() => { + resolve(); + }); + }); + }, + }, cleanup: () => { dataViewIdSubscription.unsubscribe(); fieldNameSubscription.unsubscribe(); filtersReadySubscription.unsubscribe(); }, - comparators: { - ...defaultControl.comparators, - title: [title$, (value: string | undefined) => title$.next(value)], - dataViewId: [dataViewId, (value: string) => dataViewId.next(value)], - fieldName: [fieldName, (value: string) => fieldName.next(value)], - }, + anyStateChange$: merge(title$, dataViewId, fieldName).pipe(map(() => undefined)), setters: { onSelectionChange: () => { filtersReady$.next(false); @@ -229,10 +247,10 @@ export const initializeDataControl = ( }, }, stateManager, - serialize: () => { + getLatestState: () => { return { rawState: { - ...defaultControl.serialize().rawState, + ...defaultControl.getLatestState().rawState, dataViewId: dataViewId.getValue(), fieldName: fieldName.getValue(), title: title$.getValue(), @@ -246,5 +264,11 @@ export const initializeDataControl = ( ], }; }, + reinitializeState: (lastSaved?: DefaultDataControlState) => { + defaultControl.reinitializeState(lastSaved); + title$.next(lastSaved?.title); + dataViewId.next(lastSaved?.dataViewId ?? ''); + fieldName.next(lastSaved?.fieldName ?? ''); + }, }; }; diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx index cf5539a0a2680..8b02162d4055d 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.test.tsx @@ -15,13 +15,15 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { coreServices, dataViewsService } from '../../../services/kibana_services'; -import { getMockedBuildApi, getMockedControlGroupApi } from '../../mocks/control_mocks'; +import { getMockedControlGroupApi, getMockedFinalizeApi } from '../../mocks/control_mocks'; import * as initializeControl from '../initialize_data_control'; import { getOptionsListControlFactory } from './get_options_list_control_factory'; describe('Options List Control Api', () => { const uuid = 'myControl1'; const controlGroupApi = getMockedControlGroupApi(); + const factory = getOptionsListControlFactory(); + const finalizeApi = getMockedFinalizeApi(uuid, factory, controlGroupApi); dataViewsService.get = jest.fn().mockImplementation(async (id: string): Promise => { if (id !== 'myDataViewId') { @@ -55,33 +57,31 @@ describe('Options List Control Api', () => { return stubDataView; }); - const factory = getOptionsListControlFactory(); - describe('filters$', () => { test('should not set filters$ when selectedOptions is not provided', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.filters$.value).toBeUndefined(); }); test('should set filters$ when selectedOptions is provided', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', selectedOptions: ['cool', 'test'], }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.filters$.value).toEqual([ { meta: { @@ -112,16 +112,16 @@ describe('Options List Control Api', () => { }); test('should set filters$ when exists is selected', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', existsSelected: true, }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.filters$.value).toEqual([ { meta: { @@ -138,17 +138,17 @@ describe('Options List Control Api', () => { }); test('should set filters$ when exclude is selected', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', existsSelected: true, exclude: true, }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.filters$.value).toEqual([ { meta: { @@ -178,16 +178,16 @@ describe('Options List Control Api', () => { }); test('clicking another option unselects "Exists"', async () => { - const { Component } = await factory.buildControl( - { + const { Component } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', existsSelected: true, }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const control = render(); await userEvent.click(control.getByTestId(`optionsList-control-${uuid}`)); @@ -207,16 +207,16 @@ describe('Options List Control Api', () => { }); test('clicking "Exists" unselects all other selections', async () => { - const { Component } = await factory.buildControl( - { + const { Component } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', selectedOptions: ['woof', 'bark'], }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const control = render(); await userEvent.click(control.getByTestId(`optionsList-control-${uuid}`)); @@ -241,16 +241,16 @@ describe('Options List Control Api', () => { }); test('deselects when showOnlySelected is true', async () => { - const { Component, api } = await factory.buildControl( - { + const { Component, api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', selectedOptions: ['woof', 'bark'], }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const control = render(); await userEvent.click(control.getByTestId(`optionsList-control-${uuid}`)); @@ -287,17 +287,17 @@ describe('Options List Control Api', () => { }); test('replace selection when singleSelect is true', async () => { - const { Component, api } = await factory.buildControl( - { + const { Component, api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', singleSelect: true, selectedOptions: ['woof'], }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const control = render(); @@ -354,16 +354,16 @@ describe('Options List Control Api', () => { fieldName: 'myFieldName', runPastTimeout: true, }; - await factory.buildControl( - { + await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', runPastTimeout: true, }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(initializeSpy).toHaveBeenCalledWith( uuid, 'optionsListControl', 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 3fa8be558099f..5a0d4c7ee917c 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 @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import fastIsEqual from 'fast-deep-equal'; import React, { useEffect } from 'react'; import { BehaviorSubject, @@ -16,13 +15,15 @@ import { distinctUntilChanged, filter, map, + merge, skip, Subject, } from 'rxjs'; import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter, Filter } from '@kbn/es-query'; -import { PublishingSubject, useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { PublishingSubject } from '@kbn/presentation-publishing'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { OPTIONS_LIST_CONTROL } from '../../../../common'; import type { OptionsListControlState, @@ -33,7 +34,7 @@ import type { OptionsListSuggestions, } from '../../../../common/options_list'; import { getSelectionAsFieldType, isValidSearch } from '../../../../common/options_list'; -import { initializeDataControl } from '../initialize_data_control'; +import { defaultDataControlComparators, initializeDataControl } from '../initialize_data_control'; import type { DataControlFactory } from '../types'; import { OptionsListControl } from './components/options_list_control'; import { OptionsListEditorOptions } from './components/options_list_editor_options'; @@ -44,7 +45,10 @@ import { } from './constants'; import { fetchAndValidate$ } from './fetch_and_validate'; import { OptionsListControlContext } from './options_list_context_provider'; -import { initializeOptionsListSelections } from './options_list_control_selections'; +import { + initializeOptionsListSelections, + selectionComparators, +} from './options_list_control_selections'; import { OptionsListStrings } from './options_list_strings'; import type { OptionsListControlApi } from './types'; @@ -65,7 +69,7 @@ export const getOptionsListControlFactory = (): DataControlFactory< ); }, CustomOptionsComponent: OptionsListEditorOptions, - buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { + buildControl: async ({ initialState, finalizeApi, uuid, controlGroupApi }) => { /** Serializable state - i.e. the state that is saved with the control */ const searchTechnique$ = new BehaviorSubject( initialState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE @@ -76,12 +80,11 @@ export const getOptionsListControlFactory = (): DataControlFactory< initialState.sort ?? OPTIONS_LIST_DEFAULT_SORT ); - /** Creation options state - cannot currently be changed after creation, but need subjects for comparators */ - const placeholder$ = new BehaviorSubject(initialState.placeholder); - const hideActionBar$ = new BehaviorSubject(initialState.hideActionBar); - const hideExclude$ = new BehaviorSubject(initialState.hideExclude); - const hideExists$ = new BehaviorSubject(initialState.hideExists); - const hideSort$ = new BehaviorSubject(initialState.hideSort); + const placeholder = initialState.placeholder; + const hideActionBar = initialState.hideActionBar; + const hideExclude = initialState.hideExclude; + const hideExists = initialState.hideExists; + const hideSort = initialState.hideSort; /** Runtime / component state - none of this is serialized */ const searchString$ = new BehaviorSubject(''); @@ -115,10 +118,8 @@ export const getOptionsListControlFactory = (): DataControlFactory< const stateManager = { ...dataControl.stateManager, - exclude: selections.exclude$, - existsSelected: selections.existsSelected$, + ...selections.stateManager, searchTechnique: searchTechnique$, - selectedOptions: selections.selectedOptions$, singleSelect: singleSelect$, sort: sort$, searchString: searchString$, @@ -169,9 +170,9 @@ export const getOptionsListControlFactory = (): DataControlFactory< ) .subscribe(() => { searchString$.next(''); - selections.setSelectedOptions(undefined); - selections.setExistsSelected(false); - selections.setExclude(false); + selections.internalApi.setSelectedOptions(undefined); + selections.internalApi.setExistsSelected(false); + selections.internalApi.setExclude(false); requestSize$.next(MIN_OPTIONS_LIST_REQUEST_SIZE); sort$.next(OPTIONS_LIST_DEFAULT_SORT); }); @@ -214,16 +215,17 @@ export const getOptionsListControlFactory = (): DataControlFactory< const singleSelectSubscription = singleSelect$ .pipe(filter((singleSelect) => Boolean(singleSelect))) .subscribe(() => { - const currentSelections = selections.selectedOptions$.getValue() ?? []; - if (currentSelections.length > 1) selections.setSelectedOptions([currentSelections[0]]); + const currentSelections = stateManager.selectedOptions.getValue() ?? []; + if (currentSelections.length > 1) + selections.internalApi.setSelectedOptions([currentSelections[0]]); }); const hasSelections$ = new BehaviorSubject( Boolean(initialState.selectedOptions?.length || initialState.existsSelected) ); const hasSelectionsSubscription = combineLatest([ - selections.selectedOptions$, - selections.existsSelected$, + stateManager.selectedOptions, + stateManager.existsSelected, ]) .pipe( map(([selectedOptions, existsSelected]) => { @@ -238,9 +240,9 @@ export const getOptionsListControlFactory = (): DataControlFactory< const outputFilterSubscription = combineLatest([ dataControl.api.dataViews$, dataControl.stateManager.fieldName, - selections.selectedOptions$, - selections.existsSelected$, - selections.exclude$, + stateManager.selectedOptions, + stateManager.existsSelected, + stateManager.exclude, ]) .pipe(debounceTime(0)) .subscribe(([dataViews, fieldName, selectedOptions, existsSelected, exclude]) => { @@ -265,68 +267,91 @@ export const getOptionsListControlFactory = (): DataControlFactory< dataControl.setters.setOutputFilter(newFilter); }); - const api = buildApi( - { - ...dataControl.api, - dataLoading$, - getTypeDisplayName: OptionsListStrings.control.getDisplayName, - serializeState: () => { - const { rawState: dataControlState, references } = dataControl.serialize(); - return { - rawState: { - ...dataControlState, - searchTechnique: searchTechnique$.getValue(), - runPastTimeout: runPastTimeout$.getValue(), - singleSelect: singleSelect$.getValue(), - selectedOptions: selections.selectedOptions$.getValue(), - sort: sort$.getValue(), - existsSelected: selections.existsSelected$.getValue(), - exclude: selections.exclude$.getValue(), - - // serialize state that cannot be changed to keep it consistent - placeholder: placeholder$.getValue(), - hideActionBar: hideActionBar$.getValue(), - hideExclude: hideExclude$.getValue(), - hideExists: hideExists$.getValue(), - hideSort: hideSort$.getValue(), - }, - references, // does not have any references other than those provided by the data control serializer - }; - }, - clearSelections: () => { - if (selections.selectedOptions$.getValue()?.length) selections.setSelectedOptions([]); - if (selections.existsSelected$.getValue()) selections.setExistsSelected(false); - if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([])); - }, - hasSelections$: hasSelections$ as PublishingSubject, - setSelectedOptions: (options: OptionsListSelection[] | undefined) => { - selections.setSelectedOptions(options); + function serializeState() { + const { rawState: dataControlState, references } = dataControl.getLatestState(); + return { + rawState: { + ...dataControlState, + ...selections.getLatestState(), + searchTechnique: searchTechnique$.getValue(), + runPastTimeout: runPastTimeout$.getValue(), + singleSelect: singleSelect$.getValue(), + sort: sort$.getValue(), + exclude: stateManager.exclude.getValue(), + + // serialize state that cannot be changed to keep it consistent + placeholder, + hideActionBar, + hideExclude, + hideExists, + hideSort, }, + references, // does not have any references other than those provided by the data control serializer + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi: controlGroupApi, + serializeState, + anyStateChange$: merge( + dataControl.anyStateChange$, + selections.anyStateChange$, + runPastTimeout$, + searchTechnique$, + singleSelect$, + sort$ + ).pipe(map(() => undefined)), + getComparators: () => { + return { + ...defaultDataControlComparators, + ...selectionComparators, + runPastTimeout: 'referenceEquality', + searchTechnique: 'referenceEquality', + singleSelect: 'referenceEquality', + sort: 'deepEquality', + // This state cannot currently be changed after the control is created + placeholder: 'skip', + hideActionBar: 'skip', + hideExclude: 'skip', + hideExists: 'skip', + hideSort: 'skip', + }; }, - { - ...dataControl.comparators, - ...selections.comparators, - runPastTimeout: [runPastTimeout$, (runPast) => runPastTimeout$.next(runPast)], - searchTechnique: [ - searchTechnique$, - (technique) => searchTechnique$.next(technique), - (a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE), - ], - singleSelect: [singleSelect$, (selected) => singleSelect$.next(selected)], - sort: [ - sort$, - (sort) => sort$.next(sort), - (a, b) => fastIsEqual(a ?? OPTIONS_LIST_DEFAULT_SORT, b ?? OPTIONS_LIST_DEFAULT_SORT), - ], - - /** This state cannot currently be changed after the control is created */ - placeholder: [placeholder$, (placeholder) => placeholder$.next(placeholder)], - hideActionBar: [hideActionBar$, (hideActionBar) => hideActionBar$.next(hideActionBar)], - hideExclude: [hideExclude$, (hideExclude) => hideExclude$.next(hideExclude)], - hideExists: [hideExists$, (hideExists) => hideExists$.next(hideExists)], - hideSort: [hideSort$, (hideSort) => hideSort$.next(hideSort)], - } - ); + defaultState: { + searchTechnique: DEFAULT_SEARCH_TECHNIQUE, + sort: OPTIONS_LIST_DEFAULT_SORT, + exclude: false, + existsSelected: false, + }, + onReset: (lastSaved) => { + dataControl.reinitializeState(lastSaved?.rawState); + selections.reinitializeState(lastSaved?.rawState); + runPastTimeout$.next(lastSaved?.rawState.runPastTimeout); + searchTechnique$.next(lastSaved?.rawState.searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE); + singleSelect$.next(lastSaved?.rawState.singleSelect); + sort$.next(lastSaved?.rawState.sort ?? OPTIONS_LIST_DEFAULT_SORT); + }, + }); + + const api = finalizeApi({ + ...unsavedChangesApi, + ...dataControl.api, + dataLoading$, + getTypeDisplayName: OptionsListStrings.control.getDisplayName, + serializeState, + clearSelections: () => { + if (stateManager.selectedOptions.getValue()?.length) + selections.internalApi.setSelectedOptions([]); + if (stateManager.existsSelected.getValue()) + selections.internalApi.setExistsSelected(false); + if (invalidSelections$.getValue().size) invalidSelections$.next(new Set([])); + }, + hasSelections$: hasSelections$ as PublishingSubject, + setSelectedOptions: (options: OptionsListSelection[] | undefined) => { + selections.internalApi.setSelectedOptions(options); + }, + }); const componentApi = { ...api, @@ -334,7 +359,7 @@ export const getOptionsListControlFactory = (): DataControlFactory< totalCardinality$, availableOptions$, invalidSelections$, - setExclude: selections.setExclude, + setExclude: selections.internalApi.setExclude, deselectOption: (key: string | undefined) => { const field = api.field$.getValue(); if (!key || !field) { @@ -347,12 +372,12 @@ export const getOptionsListControlFactory = (): DataControlFactory< const keyAsType = getSelectionAsFieldType(field, key); // delete from selections - const selectedOptions = selections.selectedOptions$.getValue() ?? []; - const itemIndex = (selections.selectedOptions$.getValue() ?? []).indexOf(keyAsType); + const selectedOptions = stateManager.selectedOptions.getValue() ?? []; + const itemIndex = (stateManager.selectedOptions.getValue() ?? []).indexOf(keyAsType); if (itemIndex !== -1) { const newSelections = [...selectedOptions]; newSelections.splice(itemIndex, 1); - selections.setSelectedOptions(newSelections); + selections.internalApi.setSelectedOptions(newSelections); } // delete from invalid selections const currentInvalid = invalidSelections$.getValue(); @@ -370,36 +395,36 @@ export const getOptionsListControlFactory = (): DataControlFactory< return; } - const existsSelected = Boolean(selections.existsSelected$.getValue()); - const selectedOptions = selections.selectedOptions$.getValue() ?? []; + const existsSelected = Boolean(stateManager.existsSelected.getValue()); + const selectedOptions = stateManager.selectedOptions.getValue() ?? []; const singleSelect = singleSelect$.getValue(); // the order of these checks matters, so be careful if rearranging them const keyAsType = getSelectionAsFieldType(field, key); if (key === 'exists-option') { // if selecting exists, then deselect everything else - selections.setExistsSelected(!existsSelected); + selections.internalApi.setExistsSelected(!existsSelected); if (!existsSelected) { - selections.setSelectedOptions([]); + selections.internalApi.setSelectedOptions([]); invalidSelections$.next(new Set([])); } } else if (showOnlySelected || selectedOptions.includes(keyAsType)) { componentApi.deselectOption(key); } else if (singleSelect) { // replace selection - selections.setSelectedOptions([keyAsType]); - if (existsSelected) selections.setExistsSelected(false); + selections.internalApi.setSelectedOptions([keyAsType]); + if (existsSelected) selections.internalApi.setExistsSelected(false); } else { // select option - if (existsSelected) selections.setExistsSelected(false); - selections.setSelectedOptions( + if (existsSelected) selections.internalApi.setExistsSelected(false); + selections.internalApi.setSelectedOptions( selectedOptions ? [...selectedOptions, keyAsType] : [keyAsType] ); } }, }; - if (selections.hasInitialSelections) { + if (selections.internalApi.hasInitialSelections) { await dataControl.api.untilFiltersReady(); } @@ -419,16 +444,6 @@ export const getOptionsListControlFactory = (): DataControlFactory< }; }, []); - /** Get display settings - if these are ever made editable, should be part of stateManager instead */ - const [placeholder, hideActionBar, hideExclude, hideExists, hideSort] = - useBatchedPublishingSubjects( - placeholder$, - hideActionBar$, - hideExclude$, - hideExists$, - hideSort$ - ); - return ( +> = { + exclude: 'referenceEquality', + existsSelected: 'referenceEquality', + selectedOptions: selectedOptionsComparatorFunction, +}; + export function initializeOptionsListSelections( initialState: OptionsListControlState, onSelectionChange: () => void @@ -22,10 +37,7 @@ export function initializeOptionsListSelections( const selectedOptions$ = new BehaviorSubject( initialState.selectedOptions ?? [] ); - const selectedOptionsComparatorFunction = ( - a: OptionsListSelection[] | undefined, - b: OptionsListSelection[] | undefined - ) => deepEqual(a ?? [], b ?? []); + function setSelectedOptions(next: OptionsListSelection[] | undefined) { if (!selectedOptionsComparatorFunction(selectedOptions$.value, next)) { selectedOptions$.next(next); @@ -50,19 +62,29 @@ export function initializeOptionsListSelections( } return { - comparators: { - exclude: [exclude$, setExclude], - existsSelected: [existsSelected$, setExistsSelected], - selectedOptions: [selectedOptions$, setSelectedOptions, selectedOptionsComparatorFunction], - } as StateComparators< - Pick - >, - hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected, - selectedOptions$: selectedOptions$ as PublishingSubject, - setSelectedOptions, - existsSelected$: existsSelected$ as PublishingSubject, - setExistsSelected, - exclude$: exclude$ as PublishingSubject, - setExclude, + anyStateChange$: merge(exclude$, existsSelected$, selectedOptions$).pipe(map(() => undefined)), + getLatestState: () => { + return { + selectedOptions: selectedOptions$.getValue(), + existsSelected: existsSelected$.getValue(), + exclude: exclude$.getValue(), + }; + }, + reinitializeState: (lastSaved?: OptionsListControlState) => { + setExclude(lastSaved?.exclude); + setExistsSelected(lastSaved?.existsSelected); + setSelectedOptions(lastSaved?.selectedOptions ?? []); + }, + stateManager: { + selectedOptions: selectedOptions$ as PublishingSubject, + existsSelected: existsSelected$ as PublishingSubject, + exclude: exclude$ as PublishingSubject, + }, + internalApi: { + hasInitialSelections: initialState.selectedOptions?.length || initialState.existsSelected, + setSelectedOptions, + setExistsSelected, + setExclude, + }, }; } diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx index 607eb57b5368d..dc3fbf599e686 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx @@ -16,7 +16,7 @@ import { SerializedPanelState } from '@kbn/presentation-publishing'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { dataService, dataViewsService } from '../../../services/kibana_services'; -import { getMockedBuildApi, getMockedControlGroupApi } from '../../mocks/control_mocks'; +import { getMockedControlGroupApi, getMockedFinalizeApi } from '../../mocks/control_mocks'; import { getRangesliderControlFactory } from './get_range_slider_control_factory'; import { RangesliderControlState } from './types'; @@ -28,6 +28,8 @@ describe('RangesliderControlApi', () => { const uuid = 'myControl1'; const controlGroupApi = getMockedControlGroupApi(); + const factory = getRangesliderControlFactory(); + const finalizeApi = getMockedFinalizeApi(uuid, factory, controlGroupApi); let totalResults = DEFAULT_TOTAL_RESULTS; let min: estypes.AggregationsSingleMetricAggregateBase['value'] = DEFAULT_MIN; @@ -78,8 +80,6 @@ describe('RangesliderControlApi', () => { } as unknown as DataView; }); - const factory = getRangesliderControlFactory(); - beforeEach(() => { totalResults = DEFAULT_TOTAL_RESULTS; min = DEFAULT_MIN; @@ -88,29 +88,29 @@ describe('RangesliderControlApi', () => { describe('filters$', () => { test('should not set filters$ when value is not provided', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataView', fieldName: 'myFieldName', }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.filters$.value).toBeUndefined(); }); test('should set filters$ when value is provided', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', value: ['5', '10'], }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.filters$.value).toEqual([ { meta: { @@ -136,16 +136,16 @@ describe('RangesliderControlApi', () => { }); test('should set blocking error when data view is not found', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'notGonnaFindMeDataView', fieldName: 'myFieldName', value: ['5', '10'], }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.filters$.value).toBeUndefined(); expect(api.blockingError$.value?.message).toEqual( 'no data view found for id notGonnaFindMeDataView' @@ -158,16 +158,16 @@ describe('RangesliderControlApi', () => { totalResults = 0; // simulate no results by returning hits total of zero min = null; // simulate no results by returning min aggregation value of null max = null; // simulate no results by returning max aggregation value of null - const { Component } = await factory.buildControl( - { + const { Component } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', value: ['5', '10'], }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const { findByTestId } = render(); await waitFor(async () => { await findByTestId('range-slider-control-invalid-append-myControl1'); @@ -177,15 +177,15 @@ describe('RangesliderControlApi', () => { describe('min max', () => { test('bounds inputs should display min and max placeholders when there is no selected range', async () => { - const { Component } = await factory.buildControl( - { + const { Component } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const { findByTestId } = render(); await waitFor(async () => { const minInput = await findByTestId('rangeSlider__lowerBoundFieldNumber'); @@ -198,30 +198,30 @@ describe('RangesliderControlApi', () => { describe('step state', () => { test('default value provided when state.step is undefined', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const serializedState = api.serializeState() as SerializedPanelState; expect(serializedState.rawState.step).toBe(1); }); test('retains value from initial state', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { dataViewId: 'myDataViewId', fieldName: 'myFieldName', step: 1024, }, - getMockedBuildApi(uuid, factory, controlGroupApi), + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); const serializedState = api.serializeState() as SerializedPanelState; expect(serializedState.rawState.step).toBe(1024); }); diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 42481da081685..f9e8da9101828 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -8,15 +8,16 @@ */ import React, { useEffect, useState } from 'react'; -import { BehaviorSubject, combineLatest, debounceTime, map, skip } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, map, merge, skip } from 'rxjs'; import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; import { Filter, RangeFilterParams, buildRangeFilter } from '@kbn/es-query'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { isCompressed } from '../../../control_group/utils/is_compressed'; import { RANGE_SLIDER_CONTROL } from '../../../../common'; -import { initializeDataControl } from '../initialize_data_control'; +import { defaultDataControlComparators, initializeDataControl } from '../initialize_data_control'; import type { DataControlFactory } from '../types'; import { RangeSliderControl } from './components/range_slider_control'; import { hasNoResults$ } from './has_no_results'; @@ -59,7 +60,7 @@ export const getRangesliderControlFactory = (): DataControlFactory< ); }, - buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { + buildControl: async ({ initialState, finalizeApi, uuid, controlGroupApi }) => { const controlFetch$ = controlGroupApi.controlFetch$(uuid); const loadingMinMax$ = new BehaviorSubject(false); const loadingHasNoResults$ = new BehaviorSubject(false); @@ -82,37 +83,50 @@ export const getRangesliderControlFactory = (): DataControlFactory< dataControl.setters.onSelectionChange ); - const api = buildApi( - { - ...dataControl.api, - dataLoading$, - getTypeDisplayName: RangeSliderStrings.control.getDisplayName, - serializeState: () => { - const { rawState: dataControlState, references } = dataControl.serialize(); - return { - rawState: { - ...dataControlState, - step: step$.getValue(), - value: selections.value$.getValue(), - }, - references, // does not have any references other than those provided by the data control serializer - }; + function serializeState() { + const { rawState: dataControlState, references } = dataControl.getLatestState(); + return { + rawState: { + ...dataControlState, + step: step$.getValue(), + value: selections.value$.getValue(), }, - clearSelections: () => { - selections.setValue(undefined); - }, - hasSelections$: selections.hasRangeSelection$, + references, // does not have any references other than those provided by the data control serializer + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi: controlGroupApi, + serializeState, + anyStateChange$: merge(dataControl.anyStateChange$, selections.value$, step$).pipe( + map(() => undefined) + ), + getComparators: () => { + return { + ...defaultDataControlComparators, + value: 'referenceEquality', + step: (a, b) => (a ?? 1) === (b ?? 1), + }; }, - { - ...dataControl.comparators, - ...selections.comparators, - step: [ - step$, - (nextStep: number | undefined) => step$.next(nextStep), - (a, b) => (a ?? 1) === (b ?? 1), - ], - } - ); + onReset: (lastSaved) => { + dataControl.reinitializeState(lastSaved?.rawState); + selections.setValue(lastSaved?.rawState.value); + step$.next(lastSaved?.rawState.step ?? 1); + }, + }); + + const api = finalizeApi({ + ...unsavedChangesApi, + ...dataControl.api, + dataLoading$, + getTypeDisplayName: RangeSliderStrings.control.getDisplayName, + serializeState, + clearSelections: () => { + selections.setValue(undefined); + }, + hasSelections$: selections.hasRangeSelection$, + }); const dataLoadingSubscription = combineLatest([ loadingMinMax$, diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/range_control_selections.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/range_control_selections.ts index 29f01b9c86efc..9a064b6b8406e 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/range_control_selections.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/range_control_selections.ts @@ -8,7 +8,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import { PublishingSubject } from '@kbn/presentation-publishing'; import { RangeValue, RangesliderControlState } from './types'; export function initializeRangeControlSelections( @@ -27,9 +27,6 @@ export function initializeRangeControlSelections( } return { - comparators: { - value: [value$, setValue], - } as StateComparators>, hasInitialSelections: initialState.value !== undefined, value$: value$ as PublishingSubject, hasRangeSelection$: hasRangeSelection$ as PublishingSubject, diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts b/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts index 6f64b89305ed8..7e77d47316b9f 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts @@ -7,10 +7,36 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ import deepEqual from 'react-fast-compare'; -import { BehaviorSubject, combineLatest } from 'rxjs'; -import { ESQLVariableType, type ESQLControlVariable, type ESQLControlState } from '@kbn/esql-types'; +import { BehaviorSubject, combineLatest, map, merge } from 'rxjs'; +import type { ESQLControlVariable, ESQLControlState, EsqlControlType } from '@kbn/esql-types'; +import { ESQLVariableType } from '@kbn/esql-types'; import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +function selectedOptionsComparatorFunction(a?: string[], b?: string[]) { + return deepEqual(a ?? [], b ?? []); +} + +export const selectionComparators: StateComparators< + Pick< + ESQLControlState, + | 'selectedOptions' + | 'availableOptions' + | 'variableName' + | 'variableType' + | 'controlType' + | 'esqlQuery' + | 'title' + > +> = { + selectedOptions: selectedOptionsComparatorFunction, + availableOptions: 'referenceEquality', + variableName: 'referenceEquality', + variableType: 'referenceEquality', + controlType: 'referenceEquality', + esqlQuery: 'referenceEquality', + title: 'referenceEquality', +}; + export function initializeESQLControlSelections(initialState: ESQLControlState) { const availableOptions$ = new BehaviorSubject(initialState.availableOptions ?? []); const selectedOptions$ = new BehaviorSubject(initialState.selectedOptions ?? []); @@ -19,13 +45,10 @@ export function initializeESQLControlSelections(initialState: ESQLControlState) const variableType$ = new BehaviorSubject( initialState.variableType ?? ESQLVariableType.VALUES ); - const controlType$ = new BehaviorSubject(initialState.controlType ?? ''); + const controlType$ = new BehaviorSubject(initialState.controlType ?? ''); const esqlQuery$ = new BehaviorSubject(initialState.esqlQuery ?? ''); const title$ = new BehaviorSubject(initialState.title); - const selectedOptionsComparatorFunction = (a: string[], b: string[]) => - deepEqual(a ?? [], b ?? []); - function setSelectedOptions(next: string[]) { if (!selectedOptionsComparatorFunction(selectedOptions$.value, next)) { selectedOptions$.next(next); @@ -51,34 +74,39 @@ export function initializeESQLControlSelections(initialState: ESQLControlState) hasSelections$: hasSelections$ as PublishingSubject, esqlVariable$: esqlVariable$ as PublishingSubject, }, - comparators: { - selectedOptions: [selectedOptions$, setSelectedOptions, selectedOptionsComparatorFunction], - availableOptions: [availableOptions$, (next) => availableOptions$.next(next)], - variableName: [variableName$, (next) => variableName$.next(next)], - variableType: [variableType$, (next) => variableType$.next(next)], - controlType: [controlType$, (next) => controlType$.next(next)], - esqlQuery: [esqlQuery$, (next) => esqlQuery$.next(next)], - title: [title$, (next) => title$.next(next)], - } as StateComparators< - Pick< - ESQLControlState, - | 'selectedOptions' - | 'availableOptions' - | 'variableName' - | 'variableType' - | 'controlType' - | 'esqlQuery' - | 'title' - > - >, - hasInitialSelections: initialState.selectedOptions?.length, - selectedOptions$: selectedOptions$ as PublishingSubject, - availableOptions$: availableOptions$ as PublishingSubject, - variableName$: variableName$ as PublishingSubject, - variableType$: variableType$ as PublishingSubject, - controlType$: controlType$ as PublishingSubject, - esqlQuery$: esqlQuery$ as PublishingSubject, - title$: title$ as PublishingSubject, - setSelectedOptions, + anyStateChange$: merge( + selectedOptions$, + availableOptions$, + variableName$, + variableType$, + controlType$, + esqlQuery$, + title$ + ).pipe(map(() => undefined)), + reinitializeState: (lastSaved?: ESQLControlState) => { + setSelectedOptions(lastSaved?.selectedOptions ?? []); + availableOptions$.next(lastSaved?.availableOptions ?? []); + variableName$.next(lastSaved?.variableName ?? ''); + variableType$.next(lastSaved?.variableType ?? ESQLVariableType.VALUES); + if (lastSaved?.controlType) controlType$.next(lastSaved?.controlType); + esqlQuery$.next(lastSaved?.esqlQuery ?? ''); + title$.next(lastSaved?.title); + }, + getLatestState: () => { + return { + selectedOptions: selectedOptions$.getValue() ?? [], + availableOptions: availableOptions$.getValue() ?? [], + variableName: variableName$.getValue() ?? '', + variableType: variableType$.getValue() ?? ESQLVariableType.VALUES, + controlType: controlType$.getValue(), + esqlQuery: esqlQuery$.getValue() ?? '', + title: title$.getValue() ?? '', + }; + }, + internalApi: { + selectedOptions$: selectedOptions$ as PublishingSubject, + availableOptions$: availableOptions$ as PublishingSubject, + setSelectedOptions, + }, }; } diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx index 5a6342f9cf57d..7bfd0a3935eb0 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx @@ -8,37 +8,18 @@ */ import React from 'react'; -import { BehaviorSubject } from 'rxjs'; -import { StateComparators } from '@kbn/presentation-publishing'; import { fireEvent, render, waitFor } from '@testing-library/react'; import type { ESQLControlState } from '@kbn/esql-types'; -import { getMockedControlGroupApi } from '../mocks/control_mocks'; -import type { ControlApiRegistration } from '../types'; +import { getMockedControlGroupApi, getMockedFinalizeApi } from '../mocks/control_mocks'; import { getESQLControlFactory } from './get_esql_control_factory'; -import type { ESQLControlApi } from './types'; describe('ESQLControlApi', () => { const uuid = 'myESQLControl'; const dashboardApi = {}; const controlGroupApi = getMockedControlGroupApi(dashboardApi); - const factory = getESQLControlFactory(); - function buildApiMock( - api: ControlApiRegistration, - nextComparators: StateComparators - ) { - return { - ...api, - uuid, - parentApi: controlGroupApi, - unsavedChanges$: new BehaviorSubject | undefined>(undefined), - resetUnsavedChanges: () => { - return true; - }, - type: factory.type, - }; - } + const finalizeApi = getMockedFinalizeApi(uuid, factory, controlGroupApi); test('Should publish ES|QL variable', async () => { const initialState = { @@ -49,7 +30,12 @@ describe('ESQLControlApi', () => { esqlQuery: 'FROM foo | WHERE column = ?variable1', controlType: 'STATIC_VALUES', } as ESQLControlState; - const { api } = await factory.buildControl(initialState, buildApiMock, uuid, controlGroupApi); + const { api } = await factory.buildControl({ + initialState, + finalizeApi, + uuid, + controlGroupApi, + }); expect(api.esqlVariable$.value).toStrictEqual({ key: 'variable1', type: 'values', @@ -66,7 +52,12 @@ describe('ESQLControlApi', () => { esqlQuery: 'FROM foo | WHERE column = ?variable1', controlType: 'STATIC_VALUES', } as ESQLControlState; - const { api } = await factory.buildControl(initialState, buildApiMock, uuid, controlGroupApi); + const { api } = await factory.buildControl({ + initialState, + finalizeApi, + uuid, + controlGroupApi, + }); expect(api.serializeState()).toStrictEqual({ rawState: { availableOptions: ['option1', 'option2'], @@ -74,7 +65,7 @@ describe('ESQLControlApi', () => { esqlQuery: 'FROM foo | WHERE column = ?variable1', grow: undefined, selectedOptions: ['option1'], - title: undefined, + title: '', variableName: 'variable1', variableType: 'values', width: undefined, @@ -92,12 +83,12 @@ describe('ESQLControlApi', () => { esqlQuery: 'FROM foo | WHERE column = ?variable1', controlType: 'STATIC_VALUES', } as ESQLControlState; - const { Component, api } = await factory.buildControl( + const { Component, api } = await factory.buildControl({ initialState, - buildApiMock, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(api.esqlVariable$.value).toStrictEqual({ key: 'variable1', diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx index 1270df53ff7b4..ed94e82354214 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx @@ -9,18 +9,21 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, merge } from 'rxjs'; import { css } from '@emotion/react'; import { EuiComboBox } from '@elastic/eui'; import { apiPublishesESQLVariables, type ESQLControlState } from '@kbn/esql-types'; import { useBatchedPublishingSubjects, apiHasParentApi } from '@kbn/presentation-publishing'; -import { tracksOverlays } from '@kbn/presentation-containers'; +import { initializeUnsavedChanges, tracksOverlays } from '@kbn/presentation-containers'; import { ESQL_CONTROL } from '../../../common'; import type { ESQLControlApi } from './types'; import { ControlFactory } from '../types'; import { uiActionsService } from '../../services/kibana_services'; -import { initializeDefaultControlApi } from '../initialize_default_control_api'; -import { initializeESQLControlSelections } from './esql_control_selections'; +import { + defaultControlComparators, + initializeDefaultControlApi, +} from '../initialize_default_control_api'; +import { initializeESQLControlSelections, selectionComparators } from './esql_control_selections'; const displayName = i18n.translate('controls.esqlValuesControl.displayName', { defaultMessage: 'Static values list', @@ -32,7 +35,7 @@ export const getESQLControlFactory = (): ControlFactory 'editorChecklist', getDisplayName: () => displayName, - buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { + buildControl: async ({ initialState, finalizeApi, uuid, controlGroupApi }) => { const defaultControl = initializeDefaultControlApi(initialState); const selections = initializeESQLControlSelections(initialState); @@ -44,63 +47,73 @@ export const getESQLControlFactory = (): ControlFactory { controlGroupApi?.replacePanel(uuid, { panelType: 'esqlControl', - initialState: updatedState, + serializedState: { + rawState: updatedState, + }, }); closeOverlay(); }; - const api = buildApi( - { - ...defaultControl.api, - ...selections.api, - defaultTitle$: new BehaviorSubject(initialState.title), - isEditingEnabled: () => true, - getTypeDisplayName: () => displayName, - onEdit: async () => { - const state = { - ...initialState, - ...defaultControl.serialize().rawState, - }; - const variablesInParent = apiPublishesESQLVariables(api.parentApi) - ? api.parentApi.esqlVariables$.value - : []; - try { - await uiActionsService.getTrigger('ESQL_CONTROL_TRIGGER').exec({ - queryString: initialState.esqlQuery, - variableType: initialState.variableType, - controlType: initialState.controlType, - esqlVariables: variablesInParent, - onSaveControl, - onCancelControl: closeOverlay, - initialState: state, - }); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error getting ESQL control trigger', e); - } - }, - serializeState: () => { - const { rawState: defaultControlState } = defaultControl.serialize(); - return { - rawState: { - ...defaultControlState, - selectedOptions: selections.selectedOptions$.getValue(), - availableOptions: selections.availableOptions$.getValue(), - variableName: selections.variableName$.getValue(), - variableType: selections.variableType$.getValue(), - controlType: selections.controlType$.getValue(), - esqlQuery: selections.esqlQuery$.getValue(), - title: selections.title$.getValue(), - }, - references: [], - }; + function serializeState() { + const { rawState: defaultControlState } = defaultControl.getLatestState(); + return { + rawState: { + ...defaultControlState, + ...selections.getLatestState(), }, + references: [], + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi: controlGroupApi, + serializeState, + anyStateChange$: merge(defaultControl.anyStateChange$, selections.anyStateChange$), + getComparators: () => { + return { + ...defaultControlComparators, + ...selectionComparators, + }; }, - { - ...defaultControl.comparators, - ...selections.comparators, - } - ); + onReset: (lastSaved) => { + defaultControl.reinitializeState(lastSaved?.rawState); + selections.reinitializeState(lastSaved?.rawState); + }, + }); + + const api = finalizeApi({ + ...unsavedChangesApi, + ...defaultControl.api, + ...selections.api, + defaultTitle$: new BehaviorSubject(initialState.title), + isEditingEnabled: () => true, + getTypeDisplayName: () => displayName, + onEdit: async () => { + const state = { + ...initialState, + ...defaultControl.getLatestState().rawState, + }; + const variablesInParent = apiPublishesESQLVariables(api.parentApi) + ? api.parentApi.esqlVariables$.value + : []; + try { + await uiActionsService.getTrigger('ESQL_CONTROL_TRIGGER').exec({ + queryString: initialState.esqlQuery, + variableType: initialState.variableType, + controlType: initialState.controlType, + esqlVariables: variablesInParent, + onSaveControl, + onCancelControl: closeOverlay, + initialState: state, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error getting ESQL control trigger', e); + } + }, + serializeState, + }); const inputCss = css` .euiComboBox__inputWrap { @@ -111,8 +124,8 @@ export const getESQLControlFactory = (): ControlFactory { const [availableOptions, selectedOptions] = useBatchedPublishingSubjects( - selections.availableOptions$, - selections.selectedOptions$ + selections.internalApi.availableOptions$, + selections.internalApi.selectedOptions$ ); return ( @@ -140,7 +153,7 @@ export const getESQLControlFactory = (): ControlFactory { const selectedValues = options.map((option) => option.label); - selections.setSelectedOptions(selectedValues); + selections.internalApi.setSelectedOptions(selectedValues); }} /> diff --git a/src/platform/plugins/shared/controls/public/controls/initialize_default_control_api.tsx b/src/platform/plugins/shared/controls/public/controls/initialize_default_control_api.tsx index 61a5d0dcdc8eb..c0dcb688b9bed 100644 --- a/src/platform/plugins/shared/controls/public/controls/initialize_default_control_api.tsx +++ b/src/platform/plugins/shared/controls/public/controls/initialize_default_control_api.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Observable, map, merge } from 'rxjs'; import { StateComparators, SerializedPanelState } from '@kbn/presentation-publishing'; import type { ControlWidth, DefaultControlState } from '../../common'; @@ -15,13 +15,19 @@ import type { ControlApiInitialization, ControlStateManager, DefaultControlApi } export type ControlApi = ControlApiInitialization; +export const defaultControlComparators: StateComparators = { + grow: 'referenceEquality', + width: 'referenceEquality', +}; + export const initializeDefaultControlApi = ( state: DefaultControlState ): { - api: ControlApi; + api: Omit; stateManager: ControlStateManager; - comparators: StateComparators; - serialize: () => SerializedPanelState; + anyStateChange$: Observable; + getLatestState: () => SerializedPanelState; + reinitializeState: (lastSaved?: DefaultControlState) => void; } => { const dataLoading$ = new BehaviorSubject(false); const blockingError$ = new BehaviorSubject(undefined); @@ -37,16 +43,17 @@ export const initializeDefaultControlApi = ( setBlockingError: (error) => blockingError$.next(error), setDataLoading: (loading) => dataLoading$.next(loading), }, - comparators: { - grow: [grow, (newGrow: boolean | undefined) => grow.next(newGrow)], - width: [width, (newWidth: ControlWidth | undefined) => width.next(newWidth)], - }, + anyStateChange$: merge(grow, width).pipe(map(() => undefined)), stateManager: { grow, width, }, - serialize: () => { + getLatestState: () => { return { rawState: { grow: grow.getValue(), width: width.getValue() }, references: [] }; }, + reinitializeState: (lastSaved?: DefaultControlState) => { + grow.next(lastSaved?.grow); + width.next(lastSaved?.width); + }, }; }; diff --git a/src/platform/plugins/shared/controls/public/controls/mocks/control_mocks.ts b/src/platform/plugins/shared/controls/public/controls/mocks/control_mocks.ts index 8b94ef9c4a256..d8acd0a15aa1b 100644 --- a/src/platform/plugins/shared/controls/public/controls/mocks/control_mocks.ts +++ b/src/platform/plugins/shared/controls/public/controls/mocks/control_mocks.ts @@ -7,19 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; -import { StateComparators } from '@kbn/presentation-publishing'; +import { SerializedPanelState } from '@kbn/presentation-publishing'; import { CONTROL_GROUP_TYPE } from '../../../common'; import type { ControlFetchContext } from '../../control_group/control_fetch/control_fetch'; import type { ControlGroupApi } from '../../control_group/types'; -import type { ControlApiRegistration, ControlFactory, DefaultControlApi } from '../types'; +import { ControlApiRegistration, ControlFactory, DefaultControlApi } from '../types'; + +export type MockedControlGroupApi = ControlGroupApi & { + setLastSavedStateForChild: (uuid: string, state: object) => void; +}; export const getMockedControlGroupApi = ( dashboardApi?: unknown, overwriteApi?: Partial ) => { + const controlStateMap: Record>> = {}; return { type: CONTROL_GROUP_TYPE, parentApi: dashboardApi, @@ -27,25 +32,35 @@ export const getMockedControlGroupApi = ( ignoreParentSettings$: new BehaviorSubject(undefined), controlFetch$: () => new BehaviorSubject({}), allowExpensiveQueries$: new BehaviorSubject(true), + lastSavedStateForChild$: (childId: string) => controlStateMap[childId] ?? of(undefined), + getLastSavedStateForChild: (childId: string) => { + return controlStateMap[childId]?.value ?? { rawState: {} }; + }, + setLastSavedStateForChild: ( + childId: string, + serializePanelState: SerializedPanelState + ) => { + if (!controlStateMap[childId]) { + controlStateMap[childId] = new BehaviorSubject(serializePanelState); + return; + } + controlStateMap[childId].next(serializePanelState); + }, ...overwriteApi, - } as unknown as ControlGroupApi; + } as unknown as MockedControlGroupApi; }; -export const getMockedBuildApi = +export const getMockedFinalizeApi = ( uuid: string, factory: ControlFactory, controlGroupApi?: ControlGroupApi ) => - (api: ControlApiRegistration, nextComparators: StateComparators) => { + (api: ControlApiRegistration) => { return { ...api, uuid, parentApi: controlGroupApi ?? getMockedControlGroupApi(), - unsavedChanges$: new BehaviorSubject | undefined>(undefined), - resetUnsavedChanges: () => { - return true; - }, type: factory.type, }; }; 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 b97eb38725719..bd751e3909da3 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 @@ -12,14 +12,11 @@ import { BehaviorSubject } from 'rxjs'; import dateMath from '@kbn/datemath'; import { TimeRange } from '@kbn/es-query'; -import { StateComparators } from '@kbn/presentation-publishing'; import { fireEvent, render } from '@testing-library/react'; import { dataService } from '../../services/kibana_services'; -import { getMockedControlGroupApi } from '../mocks/control_mocks'; -import { ControlApiRegistration } from '../types'; +import { getMockedControlGroupApi, getMockedFinalizeApi } from '../mocks/control_mocks'; import { getTimesliderControlFactory } from './get_timeslider_control_factory'; -import { TimesliderControlApi, TimesliderControlState } from './types'; describe('TimesliderControlApi', () => { const uuid = 'myControl1'; @@ -28,6 +25,8 @@ describe('TimesliderControlApi', () => { timeRange$: new BehaviorSubject(undefined), }; const controlGroupApi = getMockedControlGroupApi(dashboardApi); + const factory = getTimesliderControlFactory(); + const finalizeApi = getMockedFinalizeApi(uuid, factory, controlGroupApi); dataService.query.timefilter.timefilter.calculateBounds = (timeRange: TimeRange) => { const now = new Date(); @@ -36,24 +35,6 @@ describe('TimesliderControlApi', () => { max: dateMath.parse(timeRange.to, { roundUp: true, forceNow: now }), }; }; - const factory = getTimesliderControlFactory(); - let comparators: StateComparators | undefined; - function buildApiMock( - api: ControlApiRegistration, - nextComparators: StateComparators - ) { - comparators = nextComparators; - return { - ...api, - uuid, - parentApi: controlGroupApi, - unsavedChanges$: new BehaviorSubject | undefined>(undefined), - resetUnsavedChanges: () => { - return true; - }, - type: factory.type, - }; - } beforeEach(() => { dashboardApi.timeRange$.next({ @@ -63,35 +44,40 @@ describe('TimesliderControlApi', () => { }); test('Should set timeslice to undefined when state does not provide percentage of timeRange', async () => { - const { api } = await factory.buildControl({}, buildApiMock, uuid, controlGroupApi); + const { api } = await factory.buildControl({ + initialState: {}, + finalizeApi, + uuid, + controlGroupApi, + }); expect(api.timeslice$.value).toBe(undefined); }); test('Should set timeslice to values within time range when state provides percentage of timeRange', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { timesliceStartAsPercentageOfTimeRange: 0.25, timesliceEndAsPercentageOfTimeRange: 0.5, }, - buildApiMock, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T06:00:00.000Z'); expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T12:00:00.000Z'); }); test('Should update timeslice when time range changes', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { timesliceStartAsPercentageOfTimeRange: 0.25, timesliceEndAsPercentageOfTimeRange: 0.5, }, - buildApiMock, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); // change time range to single hour dashboardApi.timeRange$.next({ @@ -107,15 +93,15 @@ describe('TimesliderControlApi', () => { }); test('Clicking previous button should advance timeslice backward', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { timesliceStartAsPercentageOfTimeRange: 0.25, timesliceEndAsPercentageOfTimeRange: 0.5, }, - buildApiMock, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); if (!api.CustomPrependComponent) { throw new Error('API does not return CustomPrependComponent'); } @@ -129,15 +115,15 @@ describe('TimesliderControlApi', () => { }); test('Clicking previous button should wrap when time range start is reached', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { timesliceStartAsPercentageOfTimeRange: 0.25, timesliceEndAsPercentageOfTimeRange: 0.5, }, - buildApiMock, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); if (!api.CustomPrependComponent) { throw new Error('API does not return CustomPrependComponent'); } @@ -152,15 +138,15 @@ describe('TimesliderControlApi', () => { }); test('Clicking next button should advance timeslice forward', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { timesliceStartAsPercentageOfTimeRange: 0.25, timesliceEndAsPercentageOfTimeRange: 0.5, }, - buildApiMock, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); if (!api.CustomPrependComponent) { throw new Error('API does not return CustomPrependComponent'); } @@ -174,15 +160,15 @@ describe('TimesliderControlApi', () => { }); test('Clicking next button should wrap when time range end is reached', async () => { - const { api } = await factory.buildControl( - { + const { api } = await factory.buildControl({ + initialState: { timesliceStartAsPercentageOfTimeRange: 0.25, timesliceEndAsPercentageOfTimeRange: 0.5, }, - buildApiMock, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); if (!api.CustomPrependComponent) { throw new Error('API does not return CustomPrependComponent'); } @@ -197,28 +183,33 @@ describe('TimesliderControlApi', () => { expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T06:00:00.000Z'); }); - test('Resetting state with comparators should reset timeslice', async () => { - const { api } = await factory.buildControl( - { - timesliceStartAsPercentageOfTimeRange: 0.25, - timesliceEndAsPercentageOfTimeRange: 0.5, - }, - buildApiMock, + test('Resetting state should reset timeslice', async () => { + const rawState = { + timesliceStartAsPercentageOfTimeRange: 0.25, + timesliceEndAsPercentageOfTimeRange: 0.5, + }; + controlGroupApi.setLastSavedStateForChild(uuid, { + rawState, + }); + const { api } = await factory.buildControl({ + initialState: rawState, + finalizeApi, uuid, - controlGroupApi - ); + controlGroupApi, + }); if (!api.CustomPrependComponent) { throw new Error('API does not return CustomPrependComponent'); } + // advance time by clicking next const { findByTestId } = render(); fireEvent.click(await findByTestId('timeSlider-nextTimeWindow')); await new Promise((resolve) => setTimeout(resolve, 0)); + // ensure time advanced expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T12:00:00.000Z'); expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T18:00:00.000Z'); - comparators!.timesliceStartAsPercentageOfTimeRange[1](0.25); - comparators!.timesliceEndAsPercentageOfTimeRange[1](0.5); + api.resetUnsavedChanges(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T06:00:00.000Z'); diff --git a/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx index dcd09097fd8f4..0c80130c17f63 100644 --- a/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx @@ -8,7 +8,7 @@ */ import React, { useEffect, useMemo } from 'react'; -import { BehaviorSubject, debounceTime, first, map } from 'rxjs'; +import { BehaviorSubject, debounceTime, first, map, merge } from 'rxjs'; import { EuiInputPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -17,19 +17,25 @@ import { ViewMode, apiHasParentApi, apiPublishesDataLoading, - getUnchangingComparator, getViewModeSubject, useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { TIME_SLIDER_CONTROL } from '../../../common'; -import { initializeDefaultControlApi } from '../initialize_default_control_api'; +import { + defaultControlComparators, + initializeDefaultControlApi, +} from '../initialize_default_control_api'; import { ControlFactory } from '../types'; import './components/index.scss'; import { TimeSliderPopoverButton } from './components/time_slider_popover_button'; import { TimeSliderPopoverContent } from './components/time_slider_popover_content'; import { TimeSliderPrepend } from './components/time_slider_prepend'; -import { initTimeRangePercentage } from './init_time_range_percentage'; +import { + initTimeRangePercentage, + timeRangePercentageComparators, +} from './init_time_range_percentage'; import { initTimeRangeSubscription } from './init_time_range_subscription'; import { FROM_INDEX, @@ -52,7 +58,7 @@ export const getTimesliderControlFactory = (): ControlFactory< type: TIME_SLIDER_CONTROL, getIconType: () => 'search', getDisplayName: () => displayName, - buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { + buildControl: async ({ initialState, finalizeApi, uuid, controlGroupApi }) => { const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } = initTimeRangeSubscription(controlGroupApi); const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined); @@ -209,56 +215,75 @@ export const getTimesliderControlFactory = (): ControlFactory< }) ); - const api = buildApi( - { - ...defaultControl.api, - defaultTitle$: new BehaviorSubject(displayName), - timeslice$, - serializeState: () => { - const { rawState: defaultControlState } = defaultControl.serialize(); - return { - rawState: { - ...defaultControlState, - ...timeRangePercentage.serializeState(), - isAnchored: isAnchored$.value, - }, - references: [], - }; - }, - clearSelections: () => { - setTimeslice(undefined); - hasTimeSliceSelection$.next(false); - }, - hasSelections$: hasTimeSliceSelection$ as PublishingSubject, - CustomPrependComponent: () => { - const [autoApplySelections, viewMode] = useBatchedPublishingSubjects( - controlGroupApi.autoApplySelections$, - viewModeSubject - ); - - return ( - isPopoverOpen$.next(value)} - waitForControlOutputConsumersToLoad$={waitForDashboardPanelsToLoad$} - /> - ); + function serializeState() { + const { rawState: defaultControlState } = defaultControl.getLatestState(); + return { + rawState: { + ...defaultControlState, + ...timeRangePercentage.getLatestState(), + isAnchored: isAnchored$.value, }, + references: [], + }; + } + + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi: controlGroupApi, + serializeState, + anyStateChange$: merge( + defaultControl.anyStateChange$, + timeRangePercentage.anyStateChange$, + isAnchored$.pipe(map(() => undefined)) + ), + getComparators: () => { + return { + ...defaultControlComparators, + ...timeRangePercentageComparators, + width: 'skip', + isAnchored: 'skip', + }; }, - { - ...defaultControl.comparators, - width: getUnchangingComparator(), - ...timeRangePercentage.comparators, - isAnchored: [isAnchored$, setIsAnchored], - } - ); + onReset: (lastSaved) => { + defaultControl.reinitializeState(lastSaved?.rawState); + timeRangePercentage.reinitializeState(lastSaved?.rawState); + setIsAnchored(lastSaved?.rawState?.isAnchored); + }, + }); + + const api = finalizeApi({ + ...unsavedChangesApi, + ...defaultControl.api, + defaultTitle$: new BehaviorSubject(displayName), + timeslice$, + serializeState, + clearSelections: () => { + setTimeslice(undefined); + hasTimeSliceSelection$.next(false); + }, + hasSelections$: hasTimeSliceSelection$ as PublishingSubject, + CustomPrependComponent: () => { + const [autoApplySelections, viewMode] = useBatchedPublishingSubjects( + controlGroupApi.autoApplySelections$, + viewModeSubject + ); + + return ( + isPopoverOpen$.next(value)} + waitForControlOutputConsumersToLoad$={waitForDashboardPanelsToLoad$} + /> + ); + }, + }); const timeRangeMetaSubscription = timeRangeMeta$.subscribe((timeRangeMeta) => { const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } = - timeRangePercentage.serializeState(); + timeRangePercentage.getLatestState(); syncTimesliceWithTimeRangePercentage( timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange diff --git a/src/platform/plugins/shared/controls/public/controls/timeslider_control/init_time_range_percentage.ts b/src/platform/plugins/shared/controls/public/controls/timeslider_control/init_time_range_percentage.ts index 4200261f7deef..aa7da0656101a 100644 --- a/src/platform/plugins/shared/controls/public/controls/timeslider_control/init_time_range_percentage.ts +++ b/src/platform/plugins/shared/controls/public/controls/timeslider_control/init_time_range_percentage.ts @@ -7,13 +7,22 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { BehaviorSubject, map, merge } from 'rxjs'; import { StateComparators } from '@kbn/presentation-publishing'; -import { debounce } from 'lodash'; -import { BehaviorSubject } from 'rxjs'; import { TimeRangeMeta } from './get_time_range_meta'; import { FROM_INDEX, TO_INDEX } from './time_utils'; import { Timeslice, TimesliderControlState } from './types'; +export const timeRangePercentageComparators: StateComparators< + Pick< + TimesliderControlState, + 'timesliceStartAsPercentageOfTimeRange' | 'timesliceEndAsPercentageOfTimeRange' + > +> = { + timesliceStartAsPercentageOfTimeRange: 'referenceEquality', + timesliceEndAsPercentageOfTimeRange: 'referenceEquality', +}; + export function initTimeRangePercentage( state: TimesliderControlState, onReset: ( @@ -28,14 +37,6 @@ export function initTimeRangePercentage( state.timesliceEndAsPercentageOfTimeRange ); - // debounce to avoid calling 'resetTimeslice' on each comparator reset - const debouncedOnReset = debounce(() => { - onReset( - timesliceStartAsPercentageOfTimeRange$.value, - timesliceEndAsPercentageOfTimeRange$.value - ); - }, 0); - return { setTimeRangePercentage(timeslice: Timeslice | undefined, timeRangeMeta: TimeRangeMeta) { let timesliceStartAsPercentageOfTimeRange: number | undefined; @@ -51,32 +52,23 @@ export function initTimeRangePercentage( timesliceStartAsPercentageOfTimeRange$.next(timesliceStartAsPercentageOfTimeRange); timesliceEndAsPercentageOfTimeRange$.next(timesliceEndAsPercentageOfTimeRange); }, - serializeState: () => { + getLatestState: () => { return { timesliceStartAsPercentageOfTimeRange: timesliceStartAsPercentageOfTimeRange$.value, timesliceEndAsPercentageOfTimeRange: timesliceEndAsPercentageOfTimeRange$.value, }; }, - comparators: { - timesliceStartAsPercentageOfTimeRange: [ - timesliceStartAsPercentageOfTimeRange$, - (value: number | undefined) => { - timesliceStartAsPercentageOfTimeRange$.next(value); - debouncedOnReset(); - }, - ], - timesliceEndAsPercentageOfTimeRange: [ - timesliceEndAsPercentageOfTimeRange$, - (value: number | undefined) => { - timesliceEndAsPercentageOfTimeRange$.next(value); - debouncedOnReset(); - }, - ], - } as StateComparators< - Pick< - TimesliderControlState, - 'timesliceStartAsPercentageOfTimeRange' | 'timesliceEndAsPercentageOfTimeRange' - > - >, + anyStateChange$: merge( + timesliceStartAsPercentageOfTimeRange$, + timesliceEndAsPercentageOfTimeRange$ + ).pipe(map(() => undefined)), + reinitializeState: (lastSaved?: TimesliderControlState) => { + timesliceStartAsPercentageOfTimeRange$.next(lastSaved?.timesliceStartAsPercentageOfTimeRange); + timesliceEndAsPercentageOfTimeRange$.next(lastSaved?.timesliceEndAsPercentageOfTimeRange); + onReset( + lastSaved?.timesliceStartAsPercentageOfTimeRange, + lastSaved?.timesliceEndAsPercentageOfTimeRange + ); + }, }; } diff --git a/src/platform/plugins/shared/controls/public/controls/types.ts b/src/platform/plugins/shared/controls/public/controls/types.ts index d2fd714d9587a..26be5993904c3 100644 --- a/src/platform/plugins/shared/controls/public/controls/types.ts +++ b/src/platform/plugins/shared/controls/public/controls/types.ts @@ -21,7 +21,6 @@ import { PublishesTitle, PublishesUnsavedChanges, PublishingSubject, - StateComparators, } from '@kbn/presentation-publishing'; import { ControlWidth, DefaultControlState } from '../../common/types'; @@ -49,7 +48,7 @@ export type DefaultControlApi = PublishesDataLoading & export type ControlApiRegistration = Omit< ControlApi, - 'uuid' | 'parentApi' | 'type' | 'unsavedChanges$' | 'resetUnsavedChanges' + 'uuid' | 'parentApi' | 'type' >; export type ControlApiInitialization = @@ -66,17 +65,20 @@ export interface ControlFactory< order?: number; getIconType: () => string; getDisplayName: () => string; - buildControl: ( - initialState: State, - buildApi: ( - apiRegistration: ControlApiRegistration, - comparators: StateComparators - ) => ControlApi, - uuid: string, - parentApi: ControlGroupApi - ) => Promise<{ api: ControlApi; Component: React.FC<{ className: string }> }>; + buildControl: ({ + initialState, + finalizeApi, + uuid, + controlGroupApi, + }: { + initialState: State; + finalizeApi: (apiRegistration: ControlApiRegistration) => ControlApi; + uuid: string; + controlGroupApi: ControlGroupApi; + }) => Promise<{ api: ControlApi; Component: React.FC<{ className: string }> }>; } +// TODO replace with StateManager from @kbn/presentation-publishing export type ControlStateManager = { [key in keyof Required]: BehaviorSubject; }; diff --git a/src/platform/plugins/shared/controls/public/index.ts b/src/platform/plugins/shared/controls/public/index.ts index 3daca3f95edc2..ce25a8f15f8b9 100644 --- a/src/platform/plugins/shared/controls/public/index.ts +++ b/src/platform/plugins/shared/controls/public/index.ts @@ -51,6 +51,8 @@ export type { } from '../common'; export type { OptionsListControlState } from '../common/options_list'; +export { serializeRuntimeState } from './control_group/utils/serialize_runtime_state'; + export function plugin() { return new ControlsPlugin(); } diff --git a/src/platform/plugins/shared/dashboard/common/types.ts b/src/platform/plugins/shared/dashboard/common/types.ts index f7fe3c11a8884..ef9c859c4e374 100644 --- a/src/platform/plugins/shared/dashboard/common/types.ts +++ b/src/platform/plugins/shared/dashboard/common/types.ts @@ -8,7 +8,7 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import type { SerializableRecord } from '@kbn/utility-types'; +import type { SerializableRecord, Writable } from '@kbn/utility-types'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import type { ViewMode } from '@kbn/presentation-publishing'; import type { RefreshInterval } from '@kbn/data-plugin/public'; @@ -45,7 +45,7 @@ export interface DashboardAttributesAndReferences { references: Reference[]; } -export type DashboardSettings = DashboardOptions & { +export type DashboardSettings = Writable & { description?: DashboardAttributes['description']; tags: string[]; timeRestore: DashboardAttributes['timeRestore']; @@ -70,18 +70,30 @@ export interface DashboardState extends DashboardSettings { * Serialized control group state. * Contains state loaded from dashboard saved object */ - controlGroupInput?: ControlGroupSerializedState | undefined; - /** - * Runtime control group state. - * Contains state passed from dashboard locator - * Use runtime state when building input for portable dashboards - */ - controlGroupState?: Partial; + controlGroupInput?: ControlGroupSerializedState; } -export type DashboardLocatorParams = Partial< - Omit -> & { +/** + * Dashboard state stored in dashboard URLs + * Do not change type without considering BWC of stored URLs + */ +export type SharedDashboardState = Partial< + Omit & { + controlGroupInput?: DashboardState['controlGroupInput'] & SerializableRecord; + + /** + * Runtime control group state. + * @deprecated use controlGroupInput + */ + controlGroupState?: Partial & SerializableRecord; + + panels: DashboardPanel[]; + + references?: DashboardState['references'] & SerializableRecord; + } +>; + +export type DashboardLocatorParams = Partial & { /** * If given, the dashboard saved object with this id will be loaded. If not given, * a new, unsaved dashboard will be loaded up. @@ -107,14 +119,4 @@ export type DashboardLocatorParams = Partial< * (Background search) */ searchSessionId?: string; - - /** - * List of dashboard panels - */ - panels?: Array; // used SerializableRecord here to force the GridData type to be read as serializable - - /** - * Control group changes - */ - controlGroupState?: Partial & SerializableRecord; // used SerializableRecord here to force the GridData type to be read as serializable }; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx index ba2633a868b81..ff9b2038610e8 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx @@ -18,16 +18,15 @@ import { EuiRadio, EuiSpacer, } from '@elastic/eui'; -import { EmbeddablePackageState, PanelNotFoundError } from '@kbn/embeddable-plugin/public'; -import { apiHasSnapshottableState } from '@kbn/presentation-publishing'; +import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; import { LazyDashboardPicker, withSuspense } from '@kbn/presentation-util-plugin/public'; -import { omit } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import { CREATE_NEW_DASHBOARD_URL, createDashboardEditUrl } from '../utils/urls'; import { embeddableService } from '../services/kibana_services'; import { getDashboardCapabilities } from '../utils/get_dashboard_capabilities'; import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_strings'; import { CopyToDashboardAPI } from './copy_to_dashboard_action'; +import { DashboardApi } from '../dashboard_api/types'; interface CopyToDashboardModalProps { api: CopyToDashboardAPI; @@ -51,19 +50,13 @@ export function CopyToDashboardModal({ api, closeModal }: CopyToDashboardModalPr const dashboardId = api.parentApi.savedObjectId$.value; const onSubmit = useCallback(() => { - const dashboard = api.parentApi; + const dashboard = api.parentApi as DashboardApi; + // TODO handle getDashboardPanelFromId throw const panelToCopy = dashboard.getDashboardPanelFromId(api.uuid); - const runtimeSnapshot = apiHasSnapshottableState(api) ? api.snapshotRuntimeState() : undefined; - - if (!panelToCopy && !runtimeSnapshot) { - throw new PanelNotFoundError(); - } const state: EmbeddablePackageState = { type: panelToCopy.type, - input: runtimeSnapshot ?? { - ...omit(panelToCopy.explicitInput, 'id'), - }, + serializedState: panelToCopy.serializedState, size: { width: panelToCopy.gridData.w, height: panelToCopy.gridData.h, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/are_panel_layouts_equal.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/are_panel_layouts_equal.ts index 3af80356bc734..a881f7bb62145 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/are_panel_layouts_equal.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/are_panel_layouts_equal.ts @@ -7,53 +7,31 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { isEmpty, xor } from 'lodash'; -import fastIsEqual from 'fast-deep-equal'; -import { DashboardPanelMap } from '../../common'; +import { xor } from 'lodash'; +import deepEqual from 'fast-deep-equal'; +import { DashboardLayout } from './types'; /** - * Checks whether the panel maps have the same keys, and if they do, whether all of the other keys inside each panel - * are equal. Skips explicit input as that needs to be handled asynchronously. + * Checks whether the panel maps have the same keys, and if they do, whether the grid data and types of each panel + * are equal. */ export const arePanelLayoutsEqual = ( - originalPanels: DashboardPanelMap, - newPanels: DashboardPanelMap + originalPanels?: DashboardLayout, + newPanels?: DashboardLayout ) => { - const originalEmbeddableIds = Object.keys(originalPanels); - const newEmbeddableIds = Object.keys(newPanels); + const originalUuids = Object.keys(originalPanels ?? {}); + const newUuids = Object.keys(newPanels ?? {}); - const embeddableIdDiff = xor(originalEmbeddableIds, newEmbeddableIds); - if (embeddableIdDiff.length > 0) { - return false; - } - const commonPanelDiff = (originalObj: Partial, newObj: Partial) => { - const differences: Partial = {}; - const keys = [ - ...new Set([ - ...(Object.keys(originalObj) as Array), - ...(Object.keys(newObj) as Array), - ]), - ]; - for (const key of keys) { - if (key === undefined) continue; - if (!fastIsEqual(originalObj[key], newObj[key])) differences[key] = newObj[key]; - } - return differences; - }; - - for (const embeddableId of newEmbeddableIds) { - const { - explicitInput: originalExplicitInput, - panelRefName: panelRefA, - ...commonPanelDiffOriginal - } = originalPanels[embeddableId]; - const { - explicitInput: newExplicitInput, - panelRefName: panelRefB, - ...commonPanelDiffNew - } = newPanels[embeddableId]; + const idDiff = xor(originalUuids, newUuids); + if (idDiff.length > 0) return false; - if (!isEmpty(commonPanelDiff(commonPanelDiffOriginal, commonPanelDiffNew))) return false; + for (const embeddableId of newUuids) { + if (originalPanels?.[embeddableId]?.type !== newPanels?.[embeddableId]?.type) { + return false; + } + if (!deepEqual(originalPanels?.[embeddableId]?.gridData, newPanels?.[embeddableId]?.gridData)) { + return false; + } } return true; }; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/control_group_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/control_group_manager.ts new file mode 100644 index 0000000000000..c8aacc0aa1405 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/control_group_manager.ts @@ -0,0 +1,57 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Reference } from '@kbn/content-management-utils'; +import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; +import { BehaviorSubject } from 'rxjs'; + +export const CONTROL_GROUP_EMBEDDABLE_ID = 'CONTROL_GROUP_EMBEDDABLE_ID'; + +export function initializeControlGroupManager( + initialState: ControlGroupSerializedState | undefined, + getReferences: (id: string) => Reference[] +) { + const controlGroupApi$ = new BehaviorSubject(undefined); + + return { + api: { + controlGroupApi$, + }, + internalApi: { + getStateForControlGroup: () => { + return { + rawState: initialState + ? initialState + : ({ + autoApplySelections: true, + chainingSystem: 'HIERARCHICAL', + controls: [], + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + labelPosition: 'oneLine', + } as ControlGroupSerializedState), + references: getReferences(CONTROL_GROUP_EMBEDDABLE_ID), + }; + }, + serializeControlGroup: () => { + const serializedState = controlGroupApi$.value?.serializeState(); + return { + controlGroupInput: serializedState?.rawState, + controlGroupReferences: serializedState?.references ?? [], + }; + }, + setControlGroupApi: (controlGroupApi: ControlGroupApi) => + controlGroupApi$.next(controlGroupApi), + }, + }; +} diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts index bda7c13f9f5bd..1c58861e78e42 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts @@ -14,9 +14,10 @@ import { apiPublishesDataLoading, } from '@kbn/presentation-publishing'; import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; +import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; export function initializeDataLoadingManager( - children$: PublishingSubject<{ [key: string]: unknown }> + children$: PublishingSubject<{ [key: string]: DefaultEmbeddableApi }> ) { const dataLoading$ = new BehaviorSubject(undefined); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts index ba3032c656c0d..34d2d10433292 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts @@ -7,23 +7,22 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { uniqBy } from 'lodash'; -import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; - +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { DataView } from '@kbn/data-views-plugin/common'; +import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { apiPublishesDataViews, PublishesDataViews, PublishingSubject, } from '@kbn/presentation-publishing'; - -import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { uniqBy } from 'lodash'; +import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; import { dataService } from '../services/kibana_services'; export function initializeDataViewsManager( controlGroupApi$: PublishingSubject, - children$: PublishingSubject<{ [key: string]: unknown }> + children$: PublishingSubject<{ [key: string]: DefaultEmbeddableApi }> ) { const dataViews$ = new BehaviorSubject([]); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts index 37636a6033fb8..7fc631bae3da3 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts @@ -8,10 +8,7 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; -import { StateComparators } from '@kbn/presentation-publishing'; -import { omit } from 'lodash'; import { BehaviorSubject, debounceTime, merge } from 'rxjs'; import { v4 } from 'uuid'; import { @@ -19,15 +16,14 @@ import { getReferencesForPanelId, } from '../../common/dashboard_container/persistable_state/dashboard_container_references'; import { DASHBOARD_APP_ID } from '../../common/constants'; -import { PANELS_CONTROL_GROUP_KEY } from '../services/dashboard_backup_service'; import { getDashboardContentManagementService } from '../services/dashboard_content_management_service'; import { LoadDashboardReturn } from '../services/dashboard_content_management_service/types'; import { initializeDataLoadingManager } from './data_loading_manager'; import { initializeDataViewsManager } from './data_views_manager'; import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state'; import { getSerializedState } from './get_serialized_state'; -import { openSaveModal } from './save_modal/open_save_modal'; import { initializePanelsManager } from './panels_manager'; +import { openSaveModal } from './save_modal/open_save_modal'; import { initializeSearchSessionManager } from './search_sessions/search_session_manager'; import { initializeSettingsManager } from './settings_manager'; import { initializeTrackContentfulRender } from './track_contentful_render'; @@ -38,107 +34,100 @@ import { DashboardApi, DashboardCreationOptions, DashboardInternalApi, - UnsavedPanelState, } from './types'; import type { DashboardState } from '../../common/types'; import { initializeUnifiedSearchManager } from './unified_search_manager'; import { initializeUnsavedChangesManager } from './unsaved_changes_manager'; import { initializeViewModeManager } from './view_mode_manager'; +import { + CONTROL_GROUP_EMBEDDABLE_ID, + initializeControlGroupManager, +} from './control_group_manager'; export function getDashboardApi({ creationOptions, incomingEmbeddable, initialState, - initialPanelsRuntimeState, savedObjectResult, savedObjectId, }: { creationOptions?: DashboardCreationOptions; incomingEmbeddable?: EmbeddablePackageState | undefined; initialState: DashboardState; - initialPanelsRuntimeState?: UnsavedPanelState; savedObjectResult?: LoadDashboardReturn; savedObjectId?: string; }) { - const controlGroupApi$ = new BehaviorSubject(undefined); const fullScreenMode$ = new BehaviorSubject(creationOptions?.fullScreenMode ?? false); const isManaged = savedObjectResult?.managed ?? false; const savedObjectId$ = new BehaviorSubject(savedObjectId); const viewModeManager = initializeViewModeManager(incomingEmbeddable, savedObjectResult); - const trackPanel = initializeTrackPanel( - async (id: string) => await panelsManager.api.untilEmbeddableLoaded(id) - ); + const trackPanel = initializeTrackPanel(async (id: string) => { + await panelsManager.api.getChildApi(id); + }); const references$ = new BehaviorSubject(initialState.references); - const getPanelReferences = (id: string) => { + const getReferences = (id: string) => { + if (id === CONTROL_GROUP_EMBEDDABLE_ID) { + return getReferencesForControls(references$.value ?? []); + } + const panelReferences = getReferencesForPanelId(id, references$.value ?? []); // references from old installations may not be prefixed with panel id // fall back to passing all references in these cases to preserve backwards compatability return panelReferences.length > 0 ? panelReferences : references$.value ?? []; }; - const pushPanelReferences = (refs: Reference[]) => { - references$.next([...(references$.value ?? []), ...refs]); - }; - const referencesComparator: StateComparators> = { - references: [references$, (nextRefs) => references$.next(nextRefs)], - }; const panelsManager = initializePanelsManager( incomingEmbeddable, initialState.panels, - initialPanelsRuntimeState ?? {}, trackPanel, - getPanelReferences, - pushPanelReferences, - savedObjectId + getReferences + ); + const controlGroupManager = initializeControlGroupManager( + initialState.controlGroupInput, + getReferences ); const dataLoadingManager = initializeDataLoadingManager(panelsManager.api.children$); const dataViewsManager = initializeDataViewsManager( - controlGroupApi$, + controlGroupManager.api.controlGroupApi$, panelsManager.api.children$ ); const settingsManager = initializeSettingsManager(initialState); const unifiedSearchManager = initializeUnifiedSearchManager( initialState, - controlGroupApi$, + controlGroupManager.api.controlGroupApi$, settingsManager.api.timeRestore$, dataLoadingManager.internalApi.waitForPanelsToLoad$, () => unsavedChangesManager.internalApi.getLastSavedState(), creationOptions ); const unsavedChangesManager = initializeUnsavedChangesManager({ + viewModeManager, creationOptions, - controlGroupApi$, - lastSavedState: omit(savedObjectResult?.dashboardInput, 'controlGroupInput') ?? { - ...DEFAULT_DASHBOARD_STATE, - }, + controlGroupManager, + lastSavedState: savedObjectResult?.dashboardInput ?? DEFAULT_DASHBOARD_STATE, panelsManager, savedObjectId$, settingsManager, - viewModeManager, unifiedSearchManager, - referencesComparator, + getReferences, }); + function getState() { - const { panels, references: panelReferences } = panelsManager.internalApi.getState(); + const { panels, references: panelReferences } = panelsManager.internalApi.serializePanels(); const { state: unifiedSearchState, references: searchSourceReferences } = unifiedSearchManager.internalApi.getState(); const dashboardState: DashboardState = { - ...settingsManager.internalApi.getState(), + ...settingsManager.api.getSettings(), ...unifiedSearchState, panels, viewMode: viewModeManager.api.viewMode$.value, }; - const controlGroupApi = controlGroupApi$.value; - let controlGroupReferences: Reference[] | undefined; - if (controlGroupApi) { - const { rawState: controlGroupSerializedState, references: extractedReferences } = - controlGroupApi.serializeState(); - controlGroupReferences = extractedReferences; - dashboardState.controlGroupInput = controlGroupSerializedState; - } + const { controlGroupInput, controlGroupReferences } = + controlGroupManager.internalApi.serializeControlGroup(); + dashboardState.controlGroupInput = controlGroupInput; return { dashboardState, @@ -161,7 +150,7 @@ export function getDashboardApi({ ...unsavedChangesManager.api, ...trackOverlayApi, ...initializeTrackContentfulRender(), - controlGroupApi$, + ...controlGroupManager.api, executionContext: { type: 'dashboard', description: settingsManager.api.title$.value, @@ -191,7 +180,10 @@ export function getDashboardApi({ }); if (saveResult) { - unsavedChangesManager.internalApi.onSave(saveResult.savedState); + unsavedChangesManager.internalApi.onSave( + saveResult.savedState, + saveResult.references ?? [] + ); const settings = settingsManager.api.getSettings(); settingsManager.api.setSettings({ ...settings, @@ -221,22 +213,34 @@ export function getDashboardApi({ lastSavedId: savedObjectId$.value, }); - unsavedChangesManager.internalApi.onSave(dashboardState); + unsavedChangesManager.internalApi.onSave(dashboardState, searchSourceReferences); references$.next(saveResult.references); return; }, savedObjectId$, setFullScreenMode: (fullScreenMode: boolean) => fullScreenMode$.next(fullScreenMode), + getSerializedStateForChild: (childId: string) => { + return childId === CONTROL_GROUP_EMBEDDABLE_ID + ? controlGroupManager.internalApi.getStateForControlGroup() + : panelsManager.internalApi.getSerializedStateForPanel(childId); + }, setSavedObjectId: (id: string | undefined) => savedObjectId$.next(id), type: DASHBOARD_API_TYPE as 'dashboard', uuid: v4(), } as Omit; + const internalApi: DashboardInternalApi = { + ...panelsManager.internalApi, + ...unifiedSearchManager.internalApi, + setControlGroupApi: controlGroupManager.internalApi.setControlGroupApi, + }; + const searchSessionManager = initializeSearchSessionManager( creationOptions?.searchSessionSettings, incomingEmbeddable, - dashboardApi + dashboardApi, + internalApi ); return { @@ -244,35 +248,7 @@ export function getDashboardApi({ ...dashboardApi, ...searchSessionManager.api, }, - internalApi: { - ...panelsManager.internalApi, - ...unifiedSearchManager.internalApi, - getSerializedStateForControlGroup: () => { - return { - rawState: savedObjectResult?.dashboardInput?.controlGroupInput - ? savedObjectResult.dashboardInput.controlGroupInput - : ({ - autoApplySelections: true, - chainingSystem: 'HIERARCHICAL', - controls: [], - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - ignoreValidations: false, - }, - labelPosition: 'oneLine', - showApplySelections: false, - } as ControlGroupSerializedState), - references: getReferencesForControls(references$.value ?? []), - }; - }, - getRuntimeStateForControlGroup: () => { - return panelsManager!.api.getRuntimeStateForChild(PANELS_CONTROL_GROUP_KEY); - }, - setControlGroupApi: (controlGroupApi: ControlGroupApi) => - controlGroupApi$.next(controlGroupApi), - } as DashboardInternalApi, + internalApi, cleanup: () => { dataLoadingManager.cleanup(); dataViewsManager.cleanup(); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts index 24553eaf698b2..ba630e28cc871 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts @@ -8,18 +8,15 @@ */ import { ContentInsightsClient } from '@kbn/content-management-content-insights-public'; -import { DashboardPanelMap, DashboardState } from '../../common'; +import { DashboardState } from '../../common'; +import { getDashboardBackupService } from '../services/dashboard_backup_service'; import { getDashboardContentManagementService } from '../services/dashboard_content_management_service'; -import { DashboardCreationOptions, UnsavedPanelState } from './types'; -import { getDashboardApi } from './get_dashboard_api'; -import { startQueryPerformanceTracking } from './performance/query_performance_tracking'; import { coreServices } from '../services/kibana_services'; import { logger } from '../services/logger'; -import { - PANELS_CONTROL_GROUP_KEY, - getDashboardBackupService, -} from '../services/dashboard_backup_service'; import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state'; +import { getDashboardApi } from './get_dashboard_api'; +import { startQueryPerformanceTracking } from './performance/query_performance_tracking'; +import { DashboardCreationOptions } from './types'; export async function loadDashboardApi({ getCreationOptions, @@ -50,14 +47,9 @@ export async function loadDashboardApi({ // -------------------------------------------------------------------------------------- // Combine saved object state and session storage state // -------------------------------------------------------------------------------------- - const dashboardBackupState = getDashboardBackupService().getState(savedObjectResult.dashboardId); - const initialPanelsRuntimeState: UnsavedPanelState = creationOptions?.useSessionStorageIntegration - ? dashboardBackupState?.panels ?? {} - : {}; - const sessionStorageInput = ((): Partial | undefined => { if (!creationOptions?.useSessionStorageIntegration) return; - return dashboardBackupState?.dashboardState; + return getDashboardBackupService().getState(savedObjectResult.dashboardId); })(); const combinedSessionState: DashboardState = { @@ -73,32 +65,11 @@ export async function loadDashboardApi({ // Combine state with overrides. // -------------------------------------------------------------------------------------- const overrideState = creationOptions?.getInitialInput?.(); - if (overrideState?.panels) { - const overridePanels: DashboardPanelMap = {}; - for (const [panelId, panel] of Object.entries(overrideState?.panels)) { - overridePanels[panelId] = { - ...panel, - /** - * here we need to keep the state of the panel that was already in the Dashboard if one exists. - * This is because this state will become the "last saved state" for this panel. - */ - ...(combinedSessionState.panels[panelId] ?? []), - }; - /** - * We also need to add the state of this react embeddable into the runtime state to be restored. - */ - initialPanelsRuntimeState[panelId] = panel.explicitInput; - } - overrideState.panels = overridePanels; - } // Back up any view mode passed in explicitly. if (overrideState?.viewMode) { getDashboardBackupService().storeViewMode(overrideState?.viewMode); } - if (overrideState?.controlGroupState) { - initialPanelsRuntimeState[PANELS_CONTROL_GROUP_KEY] = overrideState.controlGroupState; - } // -------------------------------------------------------------------------------------- // get dashboard Api @@ -110,7 +81,6 @@ export async function loadDashboardApi({ ...combinedSessionState, ...overrideState, }, - initialPanelsRuntimeState, savedObjectResult, savedObjectId, }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts index 54528907415bf..eb0be0fe0cf6c 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts @@ -7,403 +7,373 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject, merge } from 'rxjs'; -import { filter, map, max } from 'lodash'; -import { v4 } from 'uuid'; -import { asyncForEach } from '@kbn/std'; -import type { Reference } from '@kbn/content-management-utils'; import { METRIC_TYPE } from '@kbn/analytics'; -import { PanelPackage } from '@kbn/presentation-containers'; +import type { Reference } from '@kbn/content-management-utils'; import { DefaultEmbeddableApi, EmbeddablePackageState, PanelNotFoundError, } from '@kbn/embeddable-plugin/public'; import { - StateComparators, + CanDuplicatePanels, + HasSerializedChildState, + PanelPackage, + PresentationContainer, +} from '@kbn/presentation-containers'; +import { + SerializedPanelState, + SerializedTitles, apiHasLibraryTransforms, + apiHasSerializableState, apiPublishesTitle, apiPublishesUnsavedChanges, - apiHasSerializableState, getTitle, } from '@kbn/presentation-publishing'; -import { i18n } from '@kbn/i18n'; -import { coreServices, usageCollectionService } from '../services/kibana_services'; +import { asyncForEach } from '@kbn/std'; +import { filter, map as lodashMap, max } from 'lodash'; +import { BehaviorSubject, Observable, combineLatestWith, debounceTime, map, merge } from 'rxjs'; +import { v4 } from 'uuid'; +import type { DashboardState } from '../../common'; +import { DashboardPanelMap } from '../../common'; +import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../common/content_management'; import { prefixReferencesFromPanel } from '../../common/dashboard_container/persistable_state/dashboard_container_references'; -import type { DashboardPanelMap, DashboardPanelState, DashboardState } from '../../common'; -import type { initializeTrackPanel } from './track_panel'; +import { dashboardClonePanelActionStrings } from '../dashboard_actions/_dashboard_actions_strings'; import { getPanelAddedSuccessString } from '../dashboard_app/_dashboard_app_strings'; -import { runPanelPlacementStrategy } from '../panel_placement/place_new_panel_strategies'; -import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../common/content_management'; -import { DASHBOARD_UI_METRIC_ID } from '../utils/telemetry_constants'; import { getDashboardPanelPlacementSetting } from '../panel_placement/panel_placement_registry'; -import { UnsavedPanelState } from './types'; -import { arePanelLayoutsEqual } from './are_panel_layouts_equal'; -import { dashboardClonePanelActionStrings } from '../dashboard_actions/_dashboard_actions_strings'; import { placeClonePanel } from '../panel_placement/place_clone_panel_strategy'; +import { runPanelPlacementStrategy } from '../panel_placement/place_new_panel_strategies'; import { PanelPlacementStrategy } from '../plugin_constants'; -import { getDashboardBackupService } from '../services/dashboard_backup_service'; +import { coreServices, usageCollectionService } from '../services/kibana_services'; +import { DASHBOARD_UI_METRIC_ID } from '../utils/telemetry_constants'; +import { arePanelLayoutsEqual } from './are_panel_layouts_equal'; +import type { initializeTrackPanel } from './track_panel'; +import { + DashboardApi, + DashboardChildState, + DashboardChildren, + DashboardLayout, + DashboardLayoutItem, +} from './types'; export function initializePanelsManager( incomingEmbeddable: EmbeddablePackageState | undefined, - initialPanels: DashboardPanelMap, - initialPanelsRuntimeState: UnsavedPanelState, + initialPanels: DashboardPanelMap, // SERIALIZED STATE ONLY TODO Remove the DashboardPanelMap layer. We could take the Saved Dashboard Panels array here directly. trackPanel: ReturnType, - getReferencesForPanelId: (id: string) => Reference[], - pushReferences: (references: Reference[]) => void, - dashboardId?: string -) { - const children$ = new BehaviorSubject<{ - [key: string]: unknown; - }>({}); - const panels$ = new BehaviorSubject(initialPanels); - function setPanels(panels: DashboardPanelMap) { - if (panels !== panels$.value) panels$.next(panels); - } - let restoredRuntimeState: UnsavedPanelState = initialPanelsRuntimeState; - - function setRuntimeStateForChild(childId: string, state: object) { - restoredRuntimeState[childId] = state; - } - + getReferences: (id: string) => Reference[] +): { + internalApi: { + startComparing$: ( + lastSavedState$: BehaviorSubject + ) => Observable<{ panels?: DashboardPanelMap }>; + getSerializedStateForPanel: HasSerializedChildState['getSerializedStateForChild']; + layout$: BehaviorSubject; + registerChildApi: (api: DefaultEmbeddableApi) => void; + resetPanels: (lastSavedPanels: DashboardPanelMap) => void; + setChildState: (uuid: string, state: SerializedPanelState) => void; + serializePanels: () => { panels: DashboardPanelMap; references: Reference[] }; + }; + api: PresentationContainer & + CanDuplicatePanels & { getDashboardPanelFromId: DashboardApi['getDashboardPanelFromId'] }; +} { // -------------------------------------------------------------------------------------- - // Place the incoming embeddable if there is one + // Set up panel state manager // -------------------------------------------------------------------------------------- - if (incomingEmbeddable) { - const incomingPanelId = incomingEmbeddable.embeddableId ?? v4(); - let incomingPanelState: DashboardPanelState; - if (incomingEmbeddable.embeddableId && Boolean(panels$.value[incomingPanelId])) { - // this embeddable already exists, just update the explicit input. - incomingPanelState = panels$.value[incomingPanelId]; - const sameType = incomingPanelState.type === incomingEmbeddable.type; - - incomingPanelState.type = incomingEmbeddable.type; - setRuntimeStateForChild(incomingPanelId, { - // if the incoming panel is the same type as what was there before we can safely spread the old panel's explicit input - ...(sameType ? incomingPanelState.explicitInput : {}), - - ...incomingEmbeddable.input, - - // maintain hide panel titles setting. - hidePanelTitles: (incomingPanelState.explicitInput as { hidePanelTitles?: boolean }) - .hidePanelTitles, - }); - incomingPanelState.explicitInput = {}; - } else { - // otherwise this incoming embeddable is brand new. - setRuntimeStateForChild(incomingPanelId, incomingEmbeddable.input); - const { newPanelPlacement } = runPanelPlacementStrategy( - PanelPlacementStrategy.findTopLeftMostOpenSpace, - { - width: incomingEmbeddable.size?.width ?? DEFAULT_PANEL_WIDTH, - height: incomingEmbeddable.size?.height ?? DEFAULT_PANEL_HEIGHT, - currentPanels: panels$.value, - } - ); - incomingPanelState = { - explicitInput: {}, - type: incomingEmbeddable.type, - gridData: { - ...newPanelPlacement, - i: incomingPanelId, - }, + const children$ = new BehaviorSubject({}); + const { layout: initialLayout, childState: initialChildState } = deserializePanels(initialPanels); + const layout$ = new BehaviorSubject(initialLayout); // layout is the source of truth for which panels are in the dashboard. + let currentChildState = initialChildState; // childState is the source of truth for the state of each panel. + + function deserializePanels(panelMap: DashboardPanelMap) { + const layout: DashboardLayout = {}; + const childState: DashboardChildState = {}; + Object.keys(panelMap).forEach((uuid) => { + const { gridData, explicitInput, type } = panelMap[uuid]; + layout[uuid] = { type, gridData }; + childState[uuid] = { + rawState: explicitInput, + references: getReferences(uuid), }; - } - - setPanels({ - ...panels$.value, - [incomingPanelId]: incomingPanelState, }); - trackPanel.setScrollToPanelId(incomingPanelId); - trackPanel.setHighlightPanelId(incomingPanelId); + return { layout, childState }; } - async function untilEmbeddableLoaded(id: string): Promise { - if (!panels$.value[id]) { - throw new PanelNotFoundError(); + const serializePanels = (): { references: Reference[]; panels: DashboardPanelMap } => { + const references: Reference[] = []; + const panels: DashboardPanelMap = {}; + for (const uuid of Object.keys(layout$.value)) { + references.push( + ...prefixReferencesFromPanel(uuid, currentChildState[uuid]?.references ?? []) + ); + panels[uuid] = { + ...layout$.value[uuid], + explicitInput: currentChildState[uuid]?.rawState ?? {}, + }; } + return { panels, references }; + }; - if (children$.value[id]) { - return children$.value[id] as ApiType; + const resetPanels = (lastSavedPanels: DashboardPanelMap) => { + const { layout: lastSavedLayout, childState: lstSavedChildState } = + deserializePanels(lastSavedPanels); + + layout$.next(lastSavedLayout); + currentChildState = lstSavedChildState; + let childrenModified = false; + const currentChildren = { ...children$.value }; + for (const uuid of Object.keys(currentChildren)) { + if (lastSavedLayout[uuid]) { + 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); + }; - return new Promise((resolve, reject) => { - const subscription = merge(children$, panels$).subscribe(() => { - if (children$.value[id]) { - subscription.unsubscribe(); - resolve(children$.value[id] as ApiType); - } + // -------------------------------------------------------------------------------------- + // Panel placement functions + // -------------------------------------------------------------------------------------- + const placeIncomingPanel = (uuid: string, size: EmbeddablePackageState['size']) => { + const { newPanelPlacement } = runPanelPlacementStrategy( + PanelPlacementStrategy.findTopLeftMostOpenSpace, + { + width: size?.width ?? DEFAULT_PANEL_WIDTH, + height: size?.height ?? DEFAULT_PANEL_HEIGHT, + currentPanels: layout$.value, + } + ); + return { ...newPanelPlacement, i: uuid }; + }; - // If we hit this, the panel was removed before the embeddable finished loading. - if (panels$.value[id] === undefined) { - subscription.unsubscribe(); - resolve(undefined); - } - }); + const placeNewPanel = async ( + uuid: string, + panelPackage: PanelPackage, + gridData?: DashboardLayoutItem['gridData'] + ): Promise => { + const { panelType: type, serializedState } = panelPackage; + if (gridData) { + return { ...layout$.value, [uuid]: { gridData: { ...gridData, i: uuid }, type } }; + } + const getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting(type); + const customPlacementSettings = getCustomPlacementSettingFunc + ? await getCustomPlacementSettingFunc(serializedState) + : undefined; + const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy( + customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace, + { + currentPanels: layout$.value, + height: customPlacementSettings?.height ?? DEFAULT_PANEL_HEIGHT, + width: customPlacementSettings?.width ?? DEFAULT_PANEL_WIDTH, + } + ); + return { ...otherPanels, [uuid]: { gridData: { ...newPanelPlacement, i: uuid }, type } }; + }; + + // -------------------------------------------------------------------------------------- + // Place the incoming embeddable if there is one + // -------------------------------------------------------------------------------------- + if (incomingEmbeddable) { + const { serializedState, size, type } = incomingEmbeddable; + const uuid = incomingEmbeddable.embeddableId ?? v4(); + const existingPanel: DashboardLayoutItem | undefined = layout$.value[uuid]; + const sameType = existingPanel?.type === type; + + const gridData = existingPanel ? existingPanel.gridData : placeIncomingPanel(uuid, size); + currentChildState[uuid] = { + rawState: { + ...(sameType && currentChildState[uuid] ? currentChildState[uuid].rawState : {}), + ...serializedState.rawState, + }, + references: serializedState?.references, + }; + + layout$.next({ + ...layout$.value, + [uuid]: { gridData, type }, }); + trackPanel.setScrollToPanelId(uuid); + trackPanel.setHighlightPanelId(uuid); } function getDashboardPanelFromId(panelId: string) { - const panel = panels$.value[panelId]; - const child = children$.value[panelId]; - if (!child || !panel) throw new PanelNotFoundError(); - const serialized = apiHasSerializableState(child) ? child.serializeState() : { rawState: {} }; + const childLayout = layout$.value[panelId]; + const childApi = children$.value[panelId]; + if (!childApi || !childLayout) throw new PanelNotFoundError(); return { - type: panel.type, - explicitInput: { ...panel.explicitInput, ...serialized.rawState }, - gridData: panel.gridData, - references: serialized.references, + type: childLayout.type, + gridData: childLayout.gridData, + serializedState: apiHasSerializableState(childApi) + ? childApi.serializeState() + : { rawState: {} }, }; } async function getPanelTitles(): Promise { const titles: string[] = []; - await asyncForEach(Object.keys(panels$.value), async (id) => { - const childApi = await untilEmbeddableLoaded(id); + await asyncForEach(Object.keys(layout$.value), async (id) => { + const childApi = await getChildApi(id); const title = apiPublishesTitle(childApi) ? getTitle(childApi) : ''; if (title) titles.push(title); }); return titles; } - return { - api: { - addNewPanel: async ( - panelPackage: PanelPackage, - displaySuccessMessage?: boolean - ) => { - const { panelType: type, serializedState, initialState } = panelPackage; - - usageCollectionService?.reportUiCounter(DASHBOARD_UI_METRIC_ID, METRIC_TYPE.CLICK, type); + // -------------------------------------------------------------------------------------- + // API definition + // -------------------------------------------------------------------------------------- + const addNewPanel = async ( + panelPackage: PanelPackage, + displaySuccessMessage?: boolean, + gridData?: DashboardLayoutItem['gridData'] + ) => { + const uuid = v4(); + const { panelType: type, serializedState } = panelPackage; + usageCollectionService?.reportUiCounter(DASHBOARD_UI_METRIC_ID, METRIC_TYPE.CLICK, type); + + if (serializedState) currentChildState[uuid] = serializedState; + + layout$.next(await placeNewPanel(uuid, panelPackage, gridData)); + + if (displaySuccessMessage) { + const title = (serializedState?.rawState as SerializedTitles)?.title; + coreServices.notifications.toasts.addSuccess({ + title: getPanelAddedSuccessString(title), + 'data-test-subj': 'addEmbeddableToDashboardSuccess', + }); + trackPanel.setScrollToPanelId(uuid); + trackPanel.setHighlightPanelId(uuid); + } + return (await getChildApi(uuid)) as ApiType; + }; - const newId = v4(); + const removePanel = (uuid: string) => { + const layout = { ...layout$.value }; + if (layout[uuid]) { + delete layout[uuid]; + layout$.next(layout); + } + const children = { ...children$.value }; + if (children[uuid]) { + delete children[uuid]; + children$.next(children); + } + if (currentChildState[uuid]) { + delete currentChildState[uuid]; + } + }; - const getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting(type); + const replacePanel = async (idToRemove: string, panelPackage: PanelPackage) => { + const existingGridData = layout$.value[idToRemove]?.gridData; + if (!existingGridData) throw new PanelNotFoundError(); - const customPlacementSettings = getCustomPlacementSettingFunc - ? await getCustomPlacementSettingFunc(initialState) - : undefined; + removePanel(idToRemove); + const newPanel = await addNewPanel(panelPackage, false, existingGridData); + return newPanel.uuid; + }; - const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy( - customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace, - { - currentPanels: panels$.value, - height: customPlacementSettings?.height ?? DEFAULT_PANEL_HEIGHT, - width: customPlacementSettings?.width ?? DEFAULT_PANEL_WIDTH, - } - ); + const duplicatePanel = async (uuidToDuplicate: string) => { + const layoutItemToDuplicate = layout$.value[uuidToDuplicate]; + const apiToDuplicate = children$.value[uuidToDuplicate]; + if (!apiToDuplicate || !layoutItemToDuplicate) throw new PanelNotFoundError(); - if (serializedState?.references && serializedState.references.length > 0) { - pushReferences(prefixReferencesFromPanel(newId, serializedState.references)); - } - const newPanel: DashboardPanelState = { - type, - gridData: { - ...newPanelPlacement, - i: newId, - }, - explicitInput: { - ...serializedState?.rawState, - }, - }; - if (initialState) setRuntimeStateForChild(newId, initialState); - - setPanels({ ...otherPanels, [newId]: newPanel }); - if (displaySuccessMessage) { - coreServices.notifications.toasts.addSuccess({ - title: getPanelAddedSuccessString((newPanel.explicitInput as { title?: string }).title), - 'data-test-subj': 'addEmbeddableToDashboardSuccess', - }); - trackPanel.setScrollToPanelId(newId); - trackPanel.setHighlightPanelId(newId); - } - return await untilEmbeddableLoaded(newId); - }, - canRemovePanels: () => trackPanel.expandedPanelId$.value === undefined, - children$, - duplicatePanel: async (idToDuplicate: string) => { - const panelToClone = getDashboardPanelFromId(idToDuplicate); - const childApi = children$.value[idToDuplicate]; - if (!apiHasSerializableState(childApi)) { - throw new Error('cannot duplicate a non-serializable panel'); - } - - const id = v4(); - const allPanelTitles = await getPanelTitles(); - const lastTitle = apiPublishesTitle(childApi) ? getTitle(childApi) ?? '' : ''; - const newTitle = getClonedPanelTitle(allPanelTitles, lastTitle); - - /** - * For embeddables that have library transforms, we need to ensure - * to clone them with by value serialized state. - */ - const serializedState = apiHasLibraryTransforms(childApi) - ? childApi.getSerializedStateByValue() - : childApi.serializeState(); - - if (serializedState.references) { - pushReferences(prefixReferencesFromPanel(id, serializedState.references)); - } + const allTitles = await getPanelTitles(); + const lastTitle = apiPublishesTitle(apiToDuplicate) ? getTitle(apiToDuplicate) ?? '' : ''; + const newTitle = getClonedPanelTitle(allTitles, lastTitle); - coreServices.notifications.toasts.addSuccess({ - title: dashboardClonePanelActionStrings.getSuccessMessage(), - 'data-test-subj': 'addObjectToContainerSuccess', - }); + const uuidOfDuplicate = v4(); + const serializedState = apiHasLibraryTransforms(apiToDuplicate) + ? apiToDuplicate.getSerializedStateByValue() + : apiToDuplicate.serializeState(); + (serializedState.rawState as SerializedTitles).title = newTitle; - const { newPanelPlacement, otherPanels } = placeClonePanel({ - width: panelToClone.gridData.w, - height: panelToClone.gridData.h, - currentPanels: panels$.value, - placeBesideId: idToDuplicate, - }); + currentChildState[uuidOfDuplicate] = serializedState; - const newPanel = { - type: panelToClone.type, - explicitInput: { - ...serializedState.rawState, - title: newTitle, - id, - }, - gridData: { - ...newPanelPlacement, - i: id, - }, - }; - - setPanels({ - ...otherPanels, - [id]: newPanel, - }); - }, - getDashboardPanelFromId, - getPanelCount: () => { - return Object.keys(panels$.value).length; - }, - getSerializedStateForChild: (childId: string) => { - const rawState = panels$.value[childId]?.explicitInput ?? {}; - return Object.keys(rawState).length === 0 - ? undefined - : { - rawState, - references: getReferencesForPanelId(childId), - }; - }, - getRuntimeStateForChild: (childId: string) => { - return restoredRuntimeState?.[childId]; - }, - panels$, - removePanel: (id: string) => { - const panels = { ...panels$.value }; - if (panels[id]) { - delete panels[id]; - setPanels(panels); - } - const children = { ...children$.value }; - if (children[id]) { - delete children[id]; - children$.next(children); - } + const { newPanelPlacement, otherPanels } = placeClonePanel({ + width: layoutItemToDuplicate.gridData.w, + height: layoutItemToDuplicate.gridData.h, + currentPanels: layout$.value, + placeBesideId: uuidToDuplicate, + }); + layout$.next({ + ...otherPanels, + [uuidOfDuplicate]: { + gridData: { ...newPanelPlacement, i: uuidOfDuplicate }, + type: layoutItemToDuplicate.type, }, - replacePanel: async (idToRemove: string, panelPackage: PanelPackage) => { - const panels = { ...panels$.value }; - if (!panels[idToRemove]) { - throw new PanelNotFoundError(); - } - - const id = v4(); - const oldPanel = panels[idToRemove]; - delete panels[idToRemove]; + }); - const { panelType: type, serializedState, initialState } = panelPackage; - if (serializedState?.references && serializedState.references.length > 0) { - pushReferences(prefixReferencesFromPanel(id, serializedState?.references)); - } + coreServices.notifications.toasts.addSuccess({ + title: dashboardClonePanelActionStrings.getSuccessMessage(), + 'data-test-subj': 'addObjectToContainerSuccess', + }); + }; - if (initialState) setRuntimeStateForChild(id, initialState); + const getChildApi = async (uuid: string): Promise => { + if (!layout$.value[uuid]) throw new PanelNotFoundError(); + if (children$.value[uuid]) return children$.value[uuid]; - setPanels({ - ...panels, - [id]: { - ...oldPanel, - explicitInput: { ...serializedState?.rawState, id }, - type, - }, - }); + return new Promise((resolve) => { + const subscription = merge(children$, layout$).subscribe(() => { + if (children$.value[uuid]) { + subscription.unsubscribe(); + resolve(children$.value[uuid]); + } - const children = { ...children$.value }; - if (children[idToRemove]) { - delete children[idToRemove]; - children$.next(children); + // If we hit this, the panel was removed before the embeddable finished loading. + if (layout$.value[uuid] === undefined) { + subscription.unsubscribe(); + resolve(undefined); } + }); + }); + }; - await untilEmbeddableLoaded(id); - return id; - }, - setPanels, - setRuntimeStateForChild, - untilEmbeddableLoaded, - }, - comparators: { - panels: [panels$, setPanels, arePanelLayoutsEqual], - } as StateComparators>, + return { internalApi: { + getSerializedStateForPanel: (uuid: string) => currentChildState[uuid], + layout$, + resetPanels, + serializePanels, + startComparing$: ( + lastSavedState$: BehaviorSubject + ): Observable<{ panels?: DashboardPanelMap }> => { + return layout$.pipe( + debounceTime(100), + combineLatestWith(lastSavedState$.pipe(map((lastSaved) => lastSaved.panels))), + map(([, lastSavedPanels]) => { + const panels = serializePanels().panels; + if (!arePanelLayoutsEqual(lastSavedPanels, panels)) { + return { panels }; + } + return {}; + }) + ); + }, registerChildApi: (api: DefaultEmbeddableApi) => { children$.next({ ...children$.value, [api.uuid]: api, }); }, - setPanels, - reset: (lastSavedState: DashboardState) => { - setPanels(lastSavedState.panels); - restoredRuntimeState = {}; - let resetChangedPanelCount = false; - const currentChildren = children$.value; - for (const panelId of Object.keys(currentChildren)) { - if (panels$.value[panelId]) { - const child = currentChildren[panelId]; - if (apiPublishesUnsavedChanges(child)) { - const success = child.resetUnsavedChanges(); - if (!success) { - coreServices.notifications.toasts.addWarning( - i18n.translate('dashboard.reset.panelError', { - defaultMessage: 'Unable to reset panel changes', - }) - ); - } - } - } else { - // if reset resulted in panel removal, we need to update the list of children - delete currentChildren[panelId]; - resetChangedPanelCount = true; - } - } - if (resetChangedPanelCount) children$.next(currentChildren); - }, - getState: (): { - panels: DashboardState['panels']; - references: Reference[]; - } => { - const references: Reference[] = []; - - const panels = Object.keys(panels$.value).reduce((acc, id) => { - const childApi = children$.value[id]; - const serializeResult = apiHasSerializableState(childApi) - ? childApi.serializeState() - : getDashboardBackupService().getSerializedPanelBackup(id, dashboardId) ?? { - rawState: panels$.value[id].explicitInput ?? {}, - references: getReferencesForPanelId(id), - }; - acc[id] = { ...panels$.value[id], explicitInput: { ...serializeResult.rawState, id } }; - - references.push(...prefixReferencesFromPanel(id, serializeResult.references ?? [])); - - return acc; - }, {} as DashboardPanelMap); - - return { panels, references }; + setChildState: (uuid: string, state: SerializedPanelState) => { + currentChildState[uuid] = state; }, }, + api: { + children$, + getChildApi, + addNewPanel, + removePanel, + replacePanel, + duplicatePanel, + getDashboardPanelFromId, + getPanelCount: () => Object.keys(layout$.value).length, + canRemovePanels: () => trackPanel.expandedPanelId$.value === undefined, + }, }; } @@ -418,7 +388,7 @@ function getClonedPanelTitle(panelTitles: string[], rawTitle: string) { return title.startsWith(baseTitle); }); - const cloneNumbers = map(similarTitles, (title: string) => { + const cloneNumbers = lodashMap(similarTitles, (title: string) => { if (title.match(cloneRegex)) return 0; const cloneTag = title.match(cloneNumberRegex); return cloneTag ? parseInt(cloneTag[0].replace(/[^0-9.]/g, ''), 10) : -1; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/search_session_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/search_session_manager.ts index 19ea2a7ae4db9..88fc3a9810c7d 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/search_session_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/search_session_manager.ts @@ -9,14 +9,15 @@ import { BehaviorSubject } from 'rxjs'; import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; -import { DashboardApi, DashboardCreationOptions } from '../types'; +import { DashboardApi, DashboardCreationOptions, DashboardInternalApi } from '../types'; import { dataService } from '../../services/kibana_services'; import { startDashboardSearchSessionIntegration } from './start_dashboard_search_session_integration'; export function initializeSearchSessionManager( searchSessionSettings: DashboardCreationOptions['searchSessionSettings'], incomingEmbeddable: EmbeddablePackageState | undefined, - dashboardApi: Omit + dashboardApi: Omit, + dashboardInternalApi: DashboardInternalApi ) { const searchSessionId$ = new BehaviorSubject(undefined); @@ -45,6 +46,7 @@ export function initializeSearchSessionManager( ...dashboardApi, searchSessionId$, }, + dashboardInternalApi, searchSessionSettings, (searchSessionId: string) => searchSessionId$.next(searchSessionId) ); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/start_dashboard_search_session_integration.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/start_dashboard_search_session_integration.ts index cc0360a96ba5e..2460327a33a5b 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/start_dashboard_search_session_integration.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/search_sessions/start_dashboard_search_session_integration.ts @@ -15,12 +15,14 @@ import { dataService } from '../../services/kibana_services'; import type { DashboardApi, DashboardCreationOptions } from '../..'; import { newSession$ } from './new_session'; import { getDashboardCapabilities } from '../../utils/get_dashboard_capabilities'; +import { DashboardInternalApi } from '../types'; /** * Enables dashboard search sessions. */ export function startDashboardSearchSessionIntegration( dashboardApi: DashboardApi, + dashboardInternalApi: DashboardInternalApi, searchSessionSettings: DashboardCreationOptions['searchSessionSettings'], setSearchSessionId: (searchSessionId: string) => void ) { @@ -33,15 +35,18 @@ export function startDashboardSearchSessionIntegration( createSessionRestorationDataProvider, } = searchSessionSettings; - dataService.search.session.enableStorage(createSessionRestorationDataProvider(dashboardApi), { - isDisabled: () => - getDashboardCapabilities().storeSearchSession - ? { disabled: false } - : { - disabled: true, - reasonText: noSearchSessionStorageCapabilityMessage, - }, - }); + dataService.search.session.enableStorage( + createSessionRestorationDataProvider(dashboardApi, dashboardInternalApi), + { + isDisabled: () => + getDashboardCapabilities().storeSearchSession + ? { disabled: false } + : { + disabled: true, + reasonText: noSearchSessionStorageCapabilityMessage, + }, + } + ); // force refresh when the session id in the URL changes. This will also fire off the "handle search session change" below. const searchSessionIdChangeSubscription = sessionIdUrlChangeObservable diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/settings_manager.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_api/settings_manager.tsx index 32ac316ae1102..5db2fba782bb6 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/settings_manager.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/settings_manager.tsx @@ -7,12 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { + StateComparators, + diffComparators, + initializeTitleManager, + titleComparators, +} from '@kbn/presentation-publishing'; import fastIsEqual from 'fast-deep-equal'; -import { StateComparators, initializeTitleManager } from '@kbn/presentation-publishing'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, combineLatest, combineLatestWith, debounceTime, map } from 'rxjs'; import { DashboardSettings, DashboardState } from '../../common'; import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state'; +// SERIALIZED STATE ONLY TODO: This could be simplified by using src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_manager.ts export function initializeSettingsManager(initialState?: DashboardState) { const syncColors$ = new BehaviorSubject( initialState?.syncColors ?? DEFAULT_DASHBOARD_STATE.syncColors @@ -50,14 +56,17 @@ export function initializeSettingsManager(initialState?: DashboardState) { if (useMargins !== useMargins$.value) useMargins$.next(useMargins); } - function getSettings() { + function getSettings(): DashboardSettings { + const titleState = titleManager.getLatestState(); return { - ...titleManager.serialize(), + title: titleState.title ?? '', + description: titleState.description, + hidePanelTitles: titleState.hidePanelTitles ?? DEFAULT_DASHBOARD_STATE.hidePanelTitles, syncColors: syncColors$.value, syncCursor: syncCursor$.value, syncTooltips: syncTooltips$.value, tags: tags$.value, - timeRestore: timeRestore$.value, + timeRestore: timeRestore$.value ?? DEFAULT_DASHBOARD_STATE.timeRestore, useMargins: useMargins$.value, }; } @@ -74,6 +83,18 @@ export function initializeSettingsManager(initialState?: DashboardState) { titleManager.api.setTitle(settings.title); } + const comparators: StateComparators = { + title: titleComparators.title, + description: titleComparators.description, + hidePanelTitles: 'referenceEquality', + syncColors: 'referenceEquality', + syncCursor: 'referenceEquality', + syncTooltips: 'referenceEquality', + timeRestore: 'referenceEquality', + useMargins: 'referenceEquality', + tags: 'deepEquality', + }; + return { api: { ...titleManager.api, @@ -88,23 +109,25 @@ export function initializeSettingsManager(initialState?: DashboardState) { setTags, timeRestore$, }, - comparators: { - ...titleManager.comparators, - syncColors: [syncColors$, setSyncColors], - syncCursor: [syncCursor$, setSyncCursor], - syncTooltips: [syncTooltips$, setSyncTooltips], - timeRestore: [timeRestore$, setTimeRestore], - useMargins: [useMargins$, setUseMargins], - } as StateComparators>, internalApi: { - getState: (): DashboardSettings => { - const settings = getSettings(); - return { - ...settings, - title: settings.title ?? '', - timeRestore: settings.timeRestore ?? DEFAULT_DASHBOARD_STATE.timeRestore, - hidePanelTitles: settings.hidePanelTitles ?? DEFAULT_DASHBOARD_STATE.hidePanelTitles, - }; + startComparing$: (lastSavedState$: BehaviorSubject) => { + return combineLatest([ + syncColors$, + syncCursor$, + syncTooltips$, + tags$, + timeRestore$, + useMargins$, + + titleManager.anyStateChange$, + ]).pipe( + debounceTime(100), + map(() => getSettings()), + combineLatestWith(lastSavedState$), + map(([latestState, lastSavedState]) => + diffComparators(comparators, lastSavedState, latestState) + ) + ); }, reset: (lastSavedState: DashboardState) => { setSettings(lastSavedState); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts index bdbe14c96ef7c..4b266b8a53d18 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts @@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'; -export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Promise) { +export function initializeTrackPanel(untilLoaded: (id: string) => Promise) { const expandedPanelId$ = new BehaviorSubject(undefined); const focusedPanelId$ = new BehaviorSubject(undefined); const highlightPanelId$ = new BehaviorSubject(undefined); @@ -44,7 +44,7 @@ export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Prom const id = highlightPanelId$.value; if (id && panelRef) { - untilEmbeddableLoaded(id).then(() => { + untilLoaded(id).then(() => { panelRef.classList.add('dshDashboardGrid__item--highlighted'); // Removes the class after the highlight animation finishes setTimeout(() => { @@ -59,7 +59,7 @@ export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Prom const id = scrollToPanelId$.value; if (!id) return; - untilEmbeddableLoaded(id).then(() => { + untilLoaded(id).then(() => { setScrollToPanelId(undefined); if (scrollPosition !== undefined) { window.scrollTo({ top: scrollPosition }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts index ac2b417ada8ae..fb18255a98460 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts @@ -8,7 +8,7 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { SearchSessionInfoProvider } from '@kbn/data-plugin/public'; import type { DefaultEmbeddableApi, EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; import { Filter, Query, TimeRange } from '@kbn/es-query'; @@ -16,8 +16,7 @@ import { PublishesESQLVariables } from '@kbn/esql-types'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { CanExpandPanels, - HasRuntimeChildState, - HasSaveNotification, + HasLastSavedChildState, HasSerializedChildState, PresentationContainer, PublishesSettings, @@ -25,7 +24,6 @@ import { TracksOverlays, } from '@kbn/presentation-containers'; import { - SerializedPanelState, EmbeddableAppContext, HasAppContext, HasExecutionContext, @@ -34,25 +32,25 @@ import { PublishesDataLoading, PublishesDataViews, PublishesDescription, - PublishesTitle, PublishesSavedObjectId, + PublishesTitle, PublishesUnifiedSearch, PublishesViewMode, PublishesWritableViewMode, PublishingSubject, + SerializedPanelState, } from '@kbn/presentation-publishing'; import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload'; import { PublishesSearchSession } from '@kbn/presentation-publishing/interfaces/fetch/publishes_search_session'; import { LocatorPublic } from '@kbn/share-plugin/common'; -import { Observable, Subject } from 'rxjs'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { DashboardLocatorParams, DashboardPanelMap, - DashboardPanelState, DashboardSettings, DashboardState, } from '../../common'; -import type { DashboardAttributes } from '../../server/content_management'; +import type { DashboardAttributes, GridData } from '../../server/content_management'; import { LoadDashboardReturn, SaveDashboardReturn, @@ -60,6 +58,19 @@ import { export const DASHBOARD_API_TYPE = 'dashboard'; +export type DashboardLayoutItem = { gridData: GridData } & HasType; +export interface DashboardLayout { + [uuid: string]: DashboardLayoutItem; +} + +export interface DashboardChildState { + [uuid: string]: SerializedPanelState; +} + +export interface DashboardChildren { + [uuid: string]: DefaultEmbeddableApi; +} + export interface DashboardCreationOptions { getInitialInput?: () => Partial; @@ -71,7 +82,10 @@ export interface DashboardCreationOptions { sessionIdUrlChangeObservable?: Observable; getSearchSessionIdFromURL: () => string | undefined; removeSessionIdFromUrl: () => void; - createSessionRestorationDataProvider: (dashboardApi: DashboardApi) => SearchSessionInfoProvider; + createSessionRestorationDataProvider: ( + dashboardApi: DashboardApi, + dashboardInternalApi: DashboardInternalApi + ) => SearchSessionInfoProvider; }; useSessionStorageIntegration?: boolean; @@ -87,15 +101,10 @@ export interface DashboardCreationOptions { getEmbeddableAppContext?: (dashboardId?: string) => EmbeddableAppContext; } -export interface UnsavedPanelState { - [key: string]: object | undefined; -} - export type DashboardApi = CanExpandPanels & HasAppContext & HasExecutionContext & - HasRuntimeChildState & - HasSaveNotification & + HasLastSavedChildState & HasSerializedChildState & HasType & HasUniqueId & @@ -125,7 +134,11 @@ export type DashboardApi = CanExpandPanels & attributes: DashboardAttributes; references: Reference[]; }; - getDashboardPanelFromId: (id: string) => DashboardPanelState; + getDashboardPanelFromId: (id: string) => { + type: string; + gridData: GridData; + serializedState: SerializedPanelState; + }; hasOverlays$: PublishingSubject; hasUnsavedChanges$: PublishingSubject; highlightPanel: (panelRef: HTMLDivElement) => void; @@ -133,7 +146,6 @@ export type DashboardApi = CanExpandPanels & isEmbeddedExternally: boolean; isManaged: boolean; locator?: Pick, 'navigate' | 'getRedirectUrl'>; - panels$: PublishingSubject; runInteractiveSave: () => Promise; runQuickSave: () => Promise; scrollToPanel: (panelRef: HTMLDivElement) => void; @@ -142,21 +154,19 @@ export type DashboardApi = CanExpandPanels & setFilters: (filters?: Filter[] | undefined) => void; setFullScreenMode: (fullScreenMode: boolean) => void; setHighlightPanelId: (id: string | undefined) => void; - setPanels: (panels: DashboardPanelMap) => void; setQuery: (query?: Query | undefined) => void; setScrollToPanelId: (id: string | undefined) => void; setSettings: (settings: DashboardSettings) => void; setTags: (tags: string[]) => void; setTimeRange: (timeRange?: TimeRange | undefined) => void; unifiedSearchFilters$: PublishesUnifiedSearch['filters$']; - untilEmbeddableLoaded: (id: string) => Promise; }; export interface DashboardInternalApi { controlGroupReload$: Subject; panelsReload$: Subject; - getRuntimeStateForControlGroup: () => object | undefined; - getSerializedStateForControlGroup: () => SerializedPanelState; + layout$: BehaviorSubject; registerChildApi: (api: DefaultEmbeddableApi) => void; setControlGroupApi: (controlGroupApi: ControlGroupApi) => void; + serializePanels: () => { references: Reference[]; panels: DashboardPanelMap }; } diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts index b289488956494..e8f7a4f874a99 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { Reference } from '@kbn/content-management-utils'; import { ControlGroupApi } from '@kbn/controls-plugin/public'; import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server'; import { @@ -15,6 +16,7 @@ import { connectToQueryState, extractSearchSourceReferences, syncGlobalQueryStateWithUrl, + injectSearchSourceReferences, } from '@kbn/data-plugin/public'; import { COMPARE_ALL_OPTIONS, @@ -25,7 +27,7 @@ import { isFilterPinned, } from '@kbn/es-query'; import { ESQLControlVariable } from '@kbn/esql-types'; -import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import { PublishingSubject, StateComparators, diffComparators } from '@kbn/presentation-publishing'; import fastIsEqual from 'fast-deep-equal'; import { cloneDeep } from 'lodash'; import moment, { Moment } from 'moment'; @@ -35,6 +37,7 @@ import { Subject, Subscription, combineLatest, + combineLatestWith, debounceTime, distinctUntilChanged, finalize, @@ -275,6 +278,52 @@ export function initializeUnifiedSearchManager( ); } + const comparators = { + filters: (a, b) => + compareFilters( + (a ?? []).filter((f) => !isFilterPinned(f)), + (b ?? []).filter((f) => !isFilterPinned(f)), + COMPARE_ALL_OPTIONS + ), + query: 'deepEquality', + refreshInterval: (a: RefreshInterval | undefined, b: RefreshInterval | undefined) => + timeRestore$.value ? fastIsEqual(a, b) : true, + timeRange: (a: TimeRange | undefined, b: TimeRange | undefined) => { + if (!timeRestore$.value) return true; // if time restore is set to false, time range doesn't count as a change. + if (!areTimesEqual(a?.from, b?.from) || !areTimesEqual(a?.to, b?.to)) { + return false; + } + return true; + }, + } as StateComparators< + Pick + >; + + const getState = (): { + state: Pick< + DashboardState, + 'filters' | 'query' | 'refreshInterval' | 'timeRange' | 'timeRestore' + >; + references: SavedObjectReference[]; + } => { + // pinned filters are not serialized when saving the dashboard + const serializableFilters = unifiedSearchFilters$.value?.filter((f) => !isFilterPinned(f)); + const [{ filter, query }, references] = extractSearchSourceReferences({ + filter: serializableFilters, + query: query$.value, + }); + return { + state: { + filters: filter ?? DEFAULT_DASHBOARD_STATE.filters, + query: (query as Query) ?? DEFAULT_DASHBOARD_STATE.query, + refreshInterval: refreshInterval$.value, + timeRange: timeRange$.value, + timeRestore: timeRestore$.value ?? DEFAULT_DASHBOARD_STATE.timeRestore, + }, + references, + }; + }; + return { api: { filters$, @@ -292,45 +341,23 @@ export function initializeUnifiedSearchManager( timeslice$, unifiedSearchFilters$, }, - comparators: { - filters: [ - unifiedSearchFilters$, - setUnifiedSearchFilters, - // exclude pinned filters from comparision because pinned filters are not part of application state - (a, b) => - compareFilters( - (a ?? []).filter((f) => !isFilterPinned(f)), - (b ?? []).filter((f) => !isFilterPinned(f)), - COMPARE_ALL_OPTIONS - ), - ], - query: [query$, setQuery, fastIsEqual], - refreshInterval: [ - refreshInterval$, - (refreshInterval: RefreshInterval | undefined) => { - if (timeRestore$.value) setAndSyncRefreshInterval(refreshInterval); - }, - (a: RefreshInterval | undefined, b: RefreshInterval | undefined) => - timeRestore$.value ? fastIsEqual(a, b) : true, - ], - timeRange: [ - timeRange$, - (timeRange: TimeRange | undefined) => { - if (timeRestore$.value) setAndSyncTimeRange(timeRange); - }, - (a: TimeRange | undefined, b: TimeRange | undefined) => { - if (!timeRestore$.value) return true; // if time restore is set to false, time range doesn't count as a change. - if (!areTimesEqual(a?.from, b?.from) || !areTimesEqual(a?.to, b?.to)) { - return false; - } - return true; - }, - ], - } as StateComparators< - Pick - >, internalApi: { controlGroupReload$, + startComparing$: (lastSavedState$: BehaviorSubject) => { + return combineLatest([unifiedSearchFilters$, query$, refreshInterval$, timeRange$]).pipe( + debounceTime(100), + map(([filters, query, refreshInterval, timeRange]) => ({ + filters: filters ?? DEFAULT_DASHBOARD_STATE.filters, + query: query ?? DEFAULT_DASHBOARD_STATE.query, + refreshInterval, + timeRange, + })), + combineLatestWith(lastSavedState$), + map(([latestState, lastSavedState]) => + diffComparators(comparators, lastSavedState, latestState) + ) + ); + }, panelsReload$, reset: (lastSavedState: DashboardState) => { setUnifiedSearchFilters([ @@ -343,28 +370,18 @@ export function initializeUnifiedSearchManager( setAndSyncTimeRange(lastSavedState.timeRange); } }, - getState: (): { - state: Pick< - DashboardState, - 'filters' | 'query' | 'refreshInterval' | 'timeRange' | 'timeRestore' - >; - references: SavedObjectReference[]; - } => { - // pinned filters are not serialized when saving the dashboard - const serializableFilters = unifiedSearchFilters$.value?.filter((f) => !isFilterPinned(f)); - const [{ filter, query }, references] = extractSearchSourceReferences({ - filter: serializableFilters, - query: query$.value, - }); - return { - state: { - filters: filter ?? DEFAULT_DASHBOARD_STATE.filters, - query: (query as Query) ?? DEFAULT_DASHBOARD_STATE.query, - refreshInterval: refreshInterval$.value, - timeRange: timeRange$.value, - timeRestore: timeRestore$.value ?? DEFAULT_DASHBOARD_STATE.timeRestore, + getState, + injectReferences: (dashboardState: DashboardState, references: Reference[]) => { + const searchSourceValues = injectSearchSourceReferences( + { + filter: dashboardState.filters, }, - references, + references + ); + + return { + ...dashboardState, + filters: searchSourceValues.filter ?? dashboardState.filters, }; }, }, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts index ab743747f0336..24216b9846d83 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts @@ -7,152 +7,190 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ControlGroupApi } from '@kbn/controls-plugin/public'; -import { childrenUnsavedChanges$, initializeUnsavedChanges } from '@kbn/presentation-containers'; +import type { Reference } from '@kbn/content-management-utils'; +import { HasLastSavedChildState, childrenUnsavedChanges$ } from '@kbn/presentation-containers'; import { PublishesSavedObjectId, PublishingSubject, - SerializedPanelState, - StateComparators, apiHasSerializableState, } from '@kbn/presentation-publishing'; import { omit } from 'lodash'; -import { BehaviorSubject, Subject, combineLatest, debounceTime, skipWhile, switchMap } from 'rxjs'; import { - PANELS_CONTROL_GROUP_KEY, - getDashboardBackupService, -} from '../services/dashboard_backup_service'; + BehaviorSubject, + Observable, + combineLatest, + debounceTime, + map, + skipWhile, + switchMap, + tap, +} from 'rxjs'; +import { getDashboardBackupService } from '../services/dashboard_backup_service'; import { initializePanelsManager } from './panels_manager'; import { initializeSettingsManager } from './settings_manager'; import { DashboardCreationOptions } from './types'; import { DashboardState } from '../../common'; import { initializeUnifiedSearchManager } from './unified_search_manager'; import { initializeViewModeManager } from './view_mode_manager'; +import { + CONTROL_GROUP_EMBEDDABLE_ID, + initializeControlGroupManager, +} from './control_group_manager'; + +const DEBOUNCE_TIME = 100; export function initializeUnsavedChangesManager({ - creationOptions, - controlGroupApi$, - lastSavedState, panelsManager, savedObjectId$, + lastSavedState, settingsManager, viewModeManager, + creationOptions, + controlGroupManager, + getReferences, unifiedSearchManager, - referencesComparator, }: { - creationOptions?: DashboardCreationOptions; - controlGroupApi$: PublishingSubject; lastSavedState: DashboardState; - panelsManager: ReturnType; + creationOptions?: DashboardCreationOptions; + getReferences: (id: string) => Reference[]; savedObjectId$: PublishesSavedObjectId['savedObjectId$']; - settingsManager: ReturnType; + controlGroupManager: ReturnType; + panelsManager: ReturnType; viewModeManager: ReturnType; + settingsManager: ReturnType; unifiedSearchManager: ReturnType; - referencesComparator: StateComparators>; -}) { +}): { + api: { + hasUnsavedChanges$: PublishingSubject; + asyncResetToLastSavedState: () => Promise; + } & HasLastSavedChildState; + cleanup: () => void; + internalApi: { + getLastSavedState: () => DashboardState; + onSave: (savedState: DashboardState, references: Reference[]) => void; + }; +} { const hasUnsavedChanges$ = new BehaviorSubject(false); + // lastSavedState contains filters with injected references + // references injected while loading dashboard saved object in loadDashboardState const lastSavedState$ = new BehaviorSubject(lastSavedState); - const saveNotification$ = new Subject(); - - const dashboardUnsavedChanges = initializeUnsavedChanges< - Omit - >( - lastSavedState, - { saveNotification$ }, - { - ...panelsManager.comparators, - ...settingsManager.comparators, - ...viewModeManager.comparators, - ...unifiedSearchManager.comparators, - ...referencesComparator, - } + + const hasPanelChanges$ = childrenUnsavedChanges$(panelsManager.api.children$).pipe( + tap((childrenWithChanges) => { + // propagate the latest serialized state back to the panels manager. + for (const { uuid, hasUnsavedChanges } of childrenWithChanges) { + const childApi = panelsManager.api.children$.value[uuid]; + if (!hasUnsavedChanges || !childApi || !apiHasSerializableState(childApi)) continue; + + panelsManager.internalApi.setChildState(uuid, childApi.serializeState()); + } + }), + map((childrenWithChanges) => { + return childrenWithChanges.some(({ hasUnsavedChanges }) => hasUnsavedChanges); + }) + ); + + const dashboardStateChanges$: Observable> = combineLatest([ + settingsManager.internalApi.startComparing$(lastSavedState$), + unifiedSearchManager.internalApi.startComparing$(lastSavedState$), + panelsManager.internalApi.startComparing$(lastSavedState$), + ]).pipe( + map(([settings, unifiedSearch, panels]) => { + return { ...settings, ...unifiedSearch, ...panels }; + }) ); const unsavedChangesSubscription = combineLatest([ - dashboardUnsavedChanges.api.unsavedChanges$, - childrenUnsavedChanges$(panelsManager.api.children$), - controlGroupApi$.pipe( + viewModeManager.api.viewMode$, + dashboardStateChanges$, + hasPanelChanges$, + controlGroupManager.api.controlGroupApi$.pipe( skipWhile((controlGroupApi) => !controlGroupApi), switchMap((controlGroupApi) => { - return controlGroupApi!.unsavedChanges$; + return controlGroupApi!.hasUnsavedChanges$; }) ), ]) - .pipe(debounceTime(0)) - .subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => { - /** - * viewMode needs to be stored in session state because its used to exclude 'view' dashboards on the listing page - * However, viewMode differences should not trigger unsaved changes notification otherwise, opening a dashboard in - * edit mode will always show unsaved changes. Similarly, differences in references are derived from panels, so - * we don't consider them unsaved changes - */ - const hasDashboardChanges = - Object.keys(omit(dashboardChanges ?? {}, ['viewMode', 'references'])).length > 0; - const hasUnsavedChanges = - hasDashboardChanges || unsavedPanelState !== undefined || controlGroupChanges !== undefined; + .pipe(debounceTime(DEBOUNCE_TIME)) + .subscribe(([viewMode, dashboardChanges, hasPanelChanges, hasControlGroupChanges]) => { + const hasDashboardChanges = Object.keys(dashboardChanges ?? {}).length > 0; + const hasUnsavedChanges = hasDashboardChanges || hasPanelChanges || hasControlGroupChanges; + if (hasUnsavedChanges !== hasUnsavedChanges$.value) { hasUnsavedChanges$.next(hasUnsavedChanges); } // backup unsaved changes if configured to do so if (creationOptions?.useSessionStorageIntegration) { - const dashboardBackupService = getDashboardBackupService(); - - // Current behaviour expects time range not to be backed up. Revisit this? - const dashboardStateToBackup = omit(dashboardChanges ?? {}, [ + const dashboardStateToBackup: Partial = omit(dashboardChanges ?? {}, [ 'timeRange', 'refreshInterval', ]); - // TEMPORARY - back up serialized state for all panels with changes - if (unsavedPanelState) { - const serializedPanelBackup: { [key: string]: SerializedPanelState } = {}; - for (const uuid of Object.keys(unsavedPanelState)) { - const childApi = panelsManager.api.children$.value[uuid]; - if (!apiHasSerializableState(childApi)) continue; - serializedPanelBackup[uuid] = childApi.serializeState(); - } - dashboardBackupService.setSerializedPanelsBackups( - serializedPanelBackup, - savedObjectId$.value - ); - } + // always back up view mode. This allows us to know which Dashboards were last changed while in edit mode. + dashboardStateToBackup.viewMode = viewMode; - const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {}; - if (controlGroupChanges) { - reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges; + // Backup latest state from children that have unsaved changes + if (hasPanelChanges || hasControlGroupChanges) { + const { panels, references } = panelsManager.internalApi.serializePanels(); + const { controlGroupInput, controlGroupReferences } = + controlGroupManager.internalApi.serializeControlGroup(); + // dashboardStateToBackup.references will be used instead of savedObjectResult.references + // To avoid missing references, make sure references contains all references + // even if panels or control group does not have unsaved changes + dashboardStateToBackup.references = [...references, ...controlGroupReferences]; + if (hasPanelChanges) dashboardStateToBackup.panels = panels; + if (hasControlGroupChanges) dashboardStateToBackup.controlGroupInput = controlGroupInput; } - dashboardBackupService.setState( - savedObjectId$.value, - dashboardStateToBackup, - reactEmbeddableChanges - ); + getDashboardBackupService().setState(savedObjectId$.value, dashboardStateToBackup); } }); + const getLastSavedStateForChild = (childId: string) => { + const lastSavedDashboardState = lastSavedState$.value; + + if (childId === CONTROL_GROUP_EMBEDDABLE_ID) { + return lastSavedDashboardState.controlGroupInput + ? { + rawState: lastSavedDashboardState.controlGroupInput, + references: getReferences(CONTROL_GROUP_EMBEDDABLE_ID), + } + : undefined; + } + + if (!lastSavedDashboardState.panels[childId]) return; + return { + rawState: lastSavedDashboardState.panels[childId].explicitInput, + references: getReferences(childId), + }; + }; + return { api: { asyncResetToLastSavedState: async () => { - panelsManager.internalApi.reset(lastSavedState$.value); - settingsManager.internalApi.reset(lastSavedState$.value); + panelsManager.internalApi.resetPanels(lastSavedState$.value.panels); unifiedSearchManager.internalApi.reset(lastSavedState$.value); - await controlGroupApi$.value?.asyncResetUnsavedChanges(); + settingsManager.internalApi.reset(lastSavedState$.value); + + await controlGroupManager.api.controlGroupApi$.value?.resetUnsavedChanges(); }, hasUnsavedChanges$, - saveNotification$, + lastSavedStateForChild$: (panelId: string) => + lastSavedState$.pipe(map(() => getLastSavedStateForChild(panelId))), + getLastSavedStateForChild, }, cleanup: () => { - dashboardUnsavedChanges.cleanup(); unsavedChangesSubscription.unsubscribe(); }, internalApi: { getLastSavedState: () => lastSavedState$.value, - onSave: (savedState: DashboardState) => { - lastSavedState$.next(savedState); - // sync panels manager with latest saved state - panelsManager.internalApi.setPanels(savedState.panels); - saveNotification$.next(); + onSave: (savedState: DashboardState, references: Reference[]) => { + // savedState contains filters with extracted references + // lastSavedState$ should contain filters with injected references + lastSavedState$.next( + unifiedSearchManager.internalApi.injectReferences(savedState, references) + ); }, }, }; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts index 658ff6face177..2722c2d3b9e1e 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/view_mode_manager.ts @@ -8,12 +8,11 @@ */ import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; -import { StateComparators, ViewMode } from '@kbn/presentation-publishing'; +import { ViewMode } from '@kbn/presentation-publishing'; import { BehaviorSubject } from 'rxjs'; import { LoadDashboardReturn } from '../services/dashboard_content_management_service/types'; import { getDashboardBackupService } from '../services/dashboard_backup_service'; import { getDashboardCapabilities } from '../utils/get_dashboard_capabilities'; -import { DashboardState } from '../../common'; export function initializeViewModeManager( incomingEmbeddable?: EmbeddablePackageState, @@ -50,14 +49,5 @@ export function initializeViewModeManager( viewMode$, setViewMode, }, - comparators: { - viewMode: [ - viewMode$, - setViewMode, - // When compared view mode is always considered unequal so that it gets backed up. - // view mode unsaved changes do not show unsaved badge - () => false, - ], - } as StateComparators>, }; } diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx index 2eded27427fc5..bd3799ef6df95 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx @@ -7,18 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; +import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; +import { ViewMode } from '@kbn/presentation-publishing'; import { History } from 'history'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { debounceTime } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; - -import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; -import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; -import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; -import { ViewMode } from '@kbn/presentation-publishing'; +import { SharedDashboardState } from '../../common/types'; import { DashboardApi, DashboardCreationOptions } from '..'; import { DASHBOARD_APP_ID } from '../../common/constants'; +import { loadDashboardHistoryLocationState } from '../../common/locator/load_dashboard_history_location_state'; +import { DashboardRenderer } from '../dashboard_renderer/dashboard_renderer'; import { DashboardTopNav } from '../dashboard_top_nav'; import { coreServices, @@ -27,10 +29,10 @@ import { screenshotModeService, shareService, } from '../services/kibana_services'; +import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../utils/urls'; import { useDashboardMountContext } from './hooks/dashboard_mount_context'; import { useDashboardOutcomeValidation } from './hooks/use_dashboard_outcome_validation'; import { useObservabilityAIAssistantContext } from './hooks/use_observability_ai_assistant_context'; -import { loadDashboardHistoryLocationState } from '../../common/locator/load_dashboard_history_location_state'; import { DashboardAppNoDataPage, isDashboardAppInNoDataState, @@ -43,13 +45,7 @@ import { getSessionURLObservable, removeSearchSessionIdFromURL, } from './url/search_sessions_integration'; -import { - loadAndRemoveDashboardState, - startSyncingExpandedPanelState, - type SharedDashboardState, -} from './url/url_utils'; -import { DashboardRenderer } from '../dashboard_renderer/dashboard_renderer'; -import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../utils/urls'; +import { loadAndRemoveDashboardState, startSyncingExpandedPanelState } from './url/url_utils'; export interface DashboardAppProps { history: History; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx index 39ae4594d5bc8..3d5bce98d6197 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/hooks/use_observability_ai_assistant_context.tsx @@ -358,7 +358,9 @@ export function useObservabilityAIAssistantContext({ return dashboardApi .addNewPanel({ panelType: 'lens', - initialState: embeddableInput, + serializedState: { + rawState: { embeddableInput }, + }, }) .then(() => { return { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx index 69279269b1714..000d54e784dfe 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx @@ -10,7 +10,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import useAsync from 'react-use/lib/useAsync'; -import { v4 as uuidv4 } from 'uuid'; import { getESQLAdHocDataview, getESQLQueryColumns, @@ -18,9 +17,8 @@ import { getInitialESQLQuery, } from '@kbn/esql-utils'; import { withSuspense } from '@kbn/shared-ux-utility'; -import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type { LensSerializedState } from '@kbn/lens-plugin/public'; import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils'; - import { coreServices, dataService, @@ -33,10 +31,6 @@ import { import { getDashboardBackupService } from '../../services/dashboard_backup_service'; import { getDashboardContentManagementService } from '../../services/dashboard_content_management_service'; -function generateId() { - return uuidv4(); -} - export const DashboardAppNoDataPage = ({ onDataViewCreated, }: { @@ -100,27 +94,26 @@ export const DashboardAppNoDataPage = ({ if (chartSuggestions?.length) { const [suggestion] = chartSuggestions; - const attrs = getLensAttributesFromSuggestion({ - filters: [], - query: { - esql: esqlQuery, - }, - suggestion, - dataView, - }) as TypedLensByValueInput['attributes']; - - const lensEmbeddableInput = { - attributes: attrs, - id: generateId(), - }; - - await embeddableService.getStateTransfer().navigateToWithEmbeddablePackage('dashboards', { - state: { - type: 'lens', - input: lensEmbeddableInput, - }, - path: '#/create', - }); + await embeddableService + .getStateTransfer() + .navigateToWithEmbeddablePackage('dashboards', { + state: { + type: 'lens', + serializedState: { + rawState: { + attributes: getLensAttributesFromSuggestion({ + filters: [], + query: { + esql: esqlQuery, + }, + suggestion, + dataView, + }), + }, + }, + }, + path: '#/create', + }); } } catch (error) { if (error.name !== 'AbortError') { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx index 47b4b8b06127b..d08601265895e 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/controls_toolbar_button/add_time_slider_control_button.tsx @@ -52,10 +52,12 @@ export const AddTimeSliderControlButton = ({ closePopover, controlGroupApi, ...r onClick={async () => { controlGroupApi?.addNewPanel({ panelType: TIME_SLIDER_CONTROL, - initialState: { - grow: true, - width: 'large', - id: uuidv4(), + serializedState: { + rawState: { + grow: true, + width: 'large', + id: uuidv4(), + }, }, }); dashboardApi.scrollToTop(); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx index f4cf0c77e5308..0c113e0ff531d 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.test.tsx @@ -9,11 +9,10 @@ import { Capabilities } from '@kbn/core/public'; import { convertPanelMapToPanelsArray } from '../../../../common/lib/dashboard_panel_converters'; - -import { shareService } from '../../../services/kibana_services'; -import { showPublicUrlSwitch, ShowShareModal, ShowShareModalProps } from './show_share_modal'; +import { DashboardLocatorParams } from '../../../../common/types'; import { getDashboardBackupService } from '../../../services/dashboard_backup_service'; -import { DashboardLocatorParams, DashboardState } from '../../../../common/types'; +import { shareService } from '../../../services/kibana_services'; +import { showPublicUrlSwitch, ShowShareModal } from './show_share_modal'; describe('showPublicUrlSwitch', () => { test('returns false if "dashboard_v2" app is not available', () => { @@ -67,18 +66,14 @@ describe('ShowShareModal', () => { jest.clearAllMocks(); }); - const getPropsAndShare = (unsavedState?: Partial): ShowShareModalProps => { - dashboardBackupService.getState = jest.fn().mockReturnValue({ dashboardState: unsavedState }); - return { - isDirty: true, - anchorElement: document.createElement('div'), - getPanelsState: () => ({}), - }; + const defaultShareModalProps = { + isDirty: true, + anchorElement: document.createElement('div'), }; it('locatorParams is missing all unsaved state when none is given', () => { - const showModalProps = getPropsAndShare(); - ShowShareModal(showModalProps); + dashboardBackupService.getState = jest.fn().mockReturnValue(undefined); + ShowShareModal(defaultShareModalProps); expect(toggleShareMenuSpy).toHaveBeenCalledTimes(1); const shareLocatorParams = ( toggleShareMenuSpy.mock.calls[0][0].sharingData as { @@ -119,20 +114,8 @@ describe('ShowShareModal', () => { ], query: { query: 'bye', language: 'kuery' }, }; - const showModalProps = getPropsAndShare(unsavedDashboardState); - showModalProps.getPanelsState = () => { - return { - panel_1: { - type: 'panel_type', - gridData: { w: 0, h: 0, x: 0, y: 0, i: '0' }, - panelRefName: 'superPanel', - explicitInput: { - id: 'superPanel', - }, - }, - }; - }; - ShowShareModal(showModalProps); + dashboardBackupService.getState = jest.fn().mockReturnValue(unsavedDashboardState); + ShowShareModal(defaultShareModalProps); expect(toggleShareMenuSpy).toHaveBeenCalledTimes(1); const shareLocatorParams = ( toggleShareMenuSpy.mock.calls[0][0].sharingData as { @@ -149,65 +132,4 @@ describe('ShowShareModal', () => { ); }); }); - - it('applies unsaved panel state from backup service into the locator params', () => { - const unsavedDashboardState = { - panels: { - panel_1: { - gridData: { w: 0, h: 0, x: 0, y: 0, i: '0' }, - type: 'superType', - explicitInput: { - id: 'whatever', - changedKey1: 'not changed....', - }, - }, - }, - }; - const props = getPropsAndShare(unsavedDashboardState); - dashboardBackupService.getState = jest.fn().mockReturnValue({ - dashboardState: unsavedDashboardState, - panels: { - panel_1: { changedKey1: 'changed' }, - panel_2: { changedKey2: 'definitely changed' }, - }, - }); - props.getPanelsState = () => ({ - panel_1: { - gridData: { w: 0, h: 0, x: 0, y: 0, i: '0' }, - type: 'superType', - explicitInput: { - id: 'whatever', - changedKey1: 'NOT changed', - }, - }, - panel_2: { - gridData: { w: 0, h: 0, x: 0, y: 0, i: '0' }, - type: 'superType', - explicitInput: { - id: 'whatever2', - changedKey2: 'definitely NOT changed', - }, - }, - panel_3: { - gridData: { w: 0, h: 0, x: 0, y: 0, i: '0' }, - type: 'superType', - explicitInput: { - id: 'whatever2', - changedKey3: 'should still exist', - }, - }, - }); - ShowShareModal(props); - expect(toggleShareMenuSpy).toHaveBeenCalledTimes(1); - const shareLocatorParams = ( - toggleShareMenuSpy.mock.calls[0][0].sharingData as { - locatorParams: { params: DashboardLocatorParams }; - } - ).locatorParams.params; - - expect(shareLocatorParams.panels).toBeDefined(); - expect(shareLocatorParams.panels![0].panelConfig.changedKey1).toBe('changed'); - expect(shareLocatorParams.panels![1].panelConfig.changedKey2).toBe('definitely changed'); - expect(shareLocatorParams.panels![2].panelConfig.changedKey3).toBe('should still exist'); - }); }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx index 0f5225dd71766..b886bd070415c 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/share/show_share_modal.tsx @@ -7,30 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { omit } from 'lodash'; -import moment from 'moment'; -import React, { ReactElement, useState } from 'react'; - import { EuiCallOut, EuiCheckboxGroup } from '@elastic/eui'; import type { Capabilities } from '@kbn/core/public'; import { QueryState } from '@kbn/data-plugin/common'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; import { i18n } from '@kbn/i18n'; -import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public'; - import { FormattedMessage } from '@kbn/i18n-react'; +import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public'; +import { omit } from 'lodash'; +import moment from 'moment'; +import React, { ReactElement, useState } from 'react'; import { LocatorPublic } from '@kbn/share-plugin/common'; +import { DashboardLocatorParams } from '../../../../common'; import { convertPanelMapToPanelsArray } from '../../../../common/lib/dashboard_panel_converters'; -import { DashboardPanelMap } from '../../../../common'; -import { - getDashboardBackupService, - PANELS_CONTROL_GROUP_KEY, -} from '../../../services/dashboard_backup_service'; +import { SharedDashboardState } from '../../../../common/types'; +import { getDashboardBackupService } from '../../../services/dashboard_backup_service'; import { coreServices, dataService, shareService } from '../../../services/kibana_services'; import { getDashboardCapabilities } from '../../../utils/get_dashboard_capabilities'; +import { DASHBOARD_STATE_STORAGE_KEY } from '../../../utils/urls'; import { shareModalStrings } from '../../_dashboard_app_strings'; import { dashboardUrlParams } from '../../dashboard_router'; -import { DashboardLocatorParams } from '../../../../common'; const showFilterBarId = 'showFilterBar'; @@ -39,7 +35,6 @@ export interface ShowShareModalProps { savedObjectId?: string; dashboardTitle?: string; anchorElement: HTMLElement; - getPanelsState: () => DashboardPanelMap; } export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { @@ -55,7 +50,6 @@ export function ShowShareModal({ anchorElement, savedObjectId, dashboardTitle, - getPanelsState, }: ShowShareModalProps) { if (!shareService) return; @@ -116,62 +110,19 @@ export function ShowShareModal({ ); }; - let unsavedStateForLocator: DashboardLocatorParams = {}; - - const { dashboardState: unsavedDashboardState, panels: panelModifications } = + const { panels: allUnsavedPanelsMap, ...unsavedDashboardState } = getDashboardBackupService().getState(savedObjectId) ?? {}; - const allUnsavedPanels = (() => { - if ( - Object.keys(unsavedDashboardState?.panels ?? {}).length === 0 && - Object.keys(omit(panelModifications ?? {}, PANELS_CONTROL_GROUP_KEY)).length === 0 - ) { - // if this dashboard has no modifications or unsaved panels return early. No overrides needed. - return; - } + const hasPanelChanges = allUnsavedPanelsMap !== undefined; - const latestPanels = getPanelsState(); - // apply modifications to panels. - const modifiedPanels = panelModifications - ? Object.entries(panelModifications).reduce((acc, [panelId, unsavedPanel]) => { - if (unsavedPanel && latestPanels?.[panelId]) { - acc[panelId] = { - ...latestPanels[panelId], - explicitInput: { - ...latestPanels?.[panelId].explicitInput, - ...unsavedPanel, - id: panelId, - }, - }; - } - return acc; - }, {} as DashboardPanelMap) - : {}; - - // The latest state of panels to share. This will overwrite panels from the saved object on Dashboard load. - const allUnsavedPanelsMap = { - ...latestPanels, - ...modifiedPanels, - }; - return convertPanelMapToPanelsArray(allUnsavedPanelsMap); - })(); - - if (unsavedDashboardState) { - unsavedStateForLocator = { - query: unsavedDashboardState.query, - filters: unsavedDashboardState.filters, - controlGroupState: panelModifications?.[ - PANELS_CONTROL_GROUP_KEY - ] as DashboardLocatorParams['controlGroupState'], - panels: allUnsavedPanels as DashboardLocatorParams['panels'], - - // options - useMargins: unsavedDashboardState?.useMargins, - syncColors: unsavedDashboardState?.syncColors, - syncCursor: unsavedDashboardState?.syncCursor, - syncTooltips: unsavedDashboardState?.syncTooltips, - hidePanelTitles: unsavedDashboardState?.hidePanelTitles, - }; + const unsavedDashboardStateForLocator: SharedDashboardState = { + ...unsavedDashboardState, + controlGroupInput: + unsavedDashboardState.controlGroupInput as SharedDashboardState['controlGroupInput'], + references: unsavedDashboardState.references as SharedDashboardState['references'], + }; + if (allUnsavedPanelsMap) { + unsavedDashboardStateForLocator.panels = convertPanelMapToPanelsArray(allUnsavedPanelsMap); } const locatorParams: DashboardLocatorParams = { @@ -181,7 +132,7 @@ export function ShowShareModal({ viewMode: 'view', // For share locators we always load the dashboard in view mode useHash: false, timeRange: dataService.query.timefilter.timefilter.getTime(), - ...unsavedStateForLocator, + ...unsavedDashboardStateForLocator, }; let _g = getStateFromKbnUrl('_g', window.location.href); @@ -191,8 +142,8 @@ export function ShowShareModal({ const baseUrl = setStateToKbnUrl('_g', _g, undefined, window.location.href); const shareableUrl = setStateToKbnUrl( - '_a', - unsavedStateForLocator, + DASHBOARD_STATE_STORAGE_KEY, + unsavedDashboardStateForLocator, { useHash: false, storeInHashQuery: true }, unhashUrl(baseUrl) ); @@ -223,7 +174,7 @@ export function ShowShareModal({ /> } > - {Boolean(unsavedDashboardState?.panels) + {hasPanelChanges ? allowShortUrl ? shareModalStrings.getDraftSharePanelChangesWarning() : shareModalStrings.getSnapshotShareWarning() @@ -243,7 +194,7 @@ export function ShowShareModal({ /> } > - {Boolean(unsavedDashboardState?.panels) + {hasPanelChanges ? shareModalStrings.getEmbedSharePanelChangesWarning() : shareModalStrings.getDraftShareWarning('embed')} diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index a2bb8c32636c2..7b6688273d223 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -61,10 +61,9 @@ export const useDashboardMenuItems = ({ anchorElement, savedObjectId: lastSavedId, isDirty: Boolean(hasUnsavedChanges), - getPanelsState: () => dashboardApi.panels$.value, }); }, - [dashboardTitle, hasUnsavedChanges, lastSavedId, dashboardApi] + [dashboardTitle, hasUnsavedChanges, lastSavedId] ); /** diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/url/search_sessions_integration.ts b/src/platform/plugins/shared/dashboard/public/dashboard_app/url/search_sessions_integration.ts index 7f0d8853f9b0e..ffb2ed8f2b487 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/url/search_sessions_integration.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/url/search_sessions_integration.ts @@ -7,23 +7,22 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { map } from 'rxjs'; -import { History } from 'history'; - +import { SearchSessionInfoProvider } from '@kbn/data-plugin/public'; +import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; +import type { Query } from '@kbn/es-query'; +import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common'; import { - getQueryParams, IKbnUrlStateStorage, createQueryParamObservable, + getQueryParams, } from '@kbn/kibana-utils-plugin/public'; -import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common'; -import type { Query } from '@kbn/es-query'; -import { SearchSessionInfoProvider } from '@kbn/data-plugin/public'; -import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; +import { History } from 'history'; +import { map } from 'rxjs'; import { SEARCH_SESSION_ID } from '../../../common/constants'; import { convertPanelMapToPanelsArray } from '../../../common/lib/dashboard_panel_converters'; -import { dataService } from '../../services/kibana_services'; -import { DashboardApi } from '../../dashboard_api/types'; import { DashboardLocatorParams } from '../../../common/types'; +import { DashboardApi, DashboardInternalApi } from '../../dashboard_api/types'; +import { dataService } from '../../services/kibana_services'; export const removeSearchSessionIdFromURL = (kbnUrlStateStorage: IKbnUrlStateStorage) => { kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => { @@ -46,15 +45,24 @@ export const getSessionURLObservable = (history: History) => ); export function createSessionRestorationDataProvider( - dashboardApi: DashboardApi + dashboardApi: DashboardApi, + dashboardInternalApi: DashboardInternalApi ): SearchSessionInfoProvider { return { getName: async () => dashboardApi.title$.value ?? dashboardApi.savedObjectId$.value ?? dashboardApi.uuid, getLocatorData: async () => ({ id: DASHBOARD_APP_LOCATOR, - initialState: getLocatorParams({ dashboardApi, shouldRestoreSearchSession: false }), - restoreState: getLocatorParams({ dashboardApi, shouldRestoreSearchSession: true }), + initialState: getLocatorParams({ + dashboardApi, + dashboardInternalApi, + shouldRestoreSearchSession: false, + }), + restoreState: getLocatorParams({ + dashboardApi, + dashboardInternalApi, + shouldRestoreSearchSession: true, + }), }), }; } @@ -65,12 +73,15 @@ export function createSessionRestorationDataProvider( */ function getLocatorParams({ dashboardApi, + dashboardInternalApi, shouldRestoreSearchSession, }: { dashboardApi: DashboardApi; + dashboardInternalApi: DashboardInternalApi; shouldRestoreSearchSession: boolean; }): DashboardLocatorParams { const savedObjectId = dashboardApi.savedObjectId$.value; + const { panels, references } = dashboardInternalApi.serializePanels(); return { viewMode: dashboardApi.viewMode$.value ?? 'view', useHash: false, @@ -90,10 +101,11 @@ function getLocatorParams({ value: 0, } : undefined, - panels: savedObjectId - ? undefined - : (convertPanelMapToPanelsArray( - dashboardApi.panels$.value - ) as DashboardLocatorParams['panels']), + ...(savedObjectId + ? {} + : { + panels: convertPanelMapToPanelsArray(panels) as DashboardLocatorParams['panels'], + references: references as DashboardLocatorParams['references'], + }), }; } diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/url/url_utils.ts b/src/platform/plugins/shared/dashboard/public/dashboard_app/url/url_utils.ts index b3dfceb5e3d05..d92920bebc5c5 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/url/url_utils.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/url/url_utils.ts @@ -7,32 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { serializeRuntimeState } from '@kbn/controls-plugin/public'; +import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common'; +import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { History } from 'history'; import _ from 'lodash'; import { skip } from 'rxjs'; import semverSatisfies from 'semver/functions/satisfies'; - -import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common'; -import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; - -import { convertPanelsArrayToPanelMap } from '../../../common/lib/dashboard_panel_converters'; -import type { DashboardState } from '../../../common/types'; import type { DashboardPanelMap } from '../../../common/dashboard_container/types'; +import { convertPanelsArrayToPanelMap } from '../../../common/lib/dashboard_panel_converters'; +import type { DashboardState, SharedDashboardState } from '../../../common/types'; import type { DashboardPanel } from '../../../server/content_management'; import type { SavedDashboardPanel } from '../../../server/dashboard_saved_object'; import { DashboardApi } from '../../dashboard_api/types'; -import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../../utils/urls'; import { migrateLegacyQuery } from '../../services/dashboard_content_management_service/lib/load_dashboard_state'; import { coreServices } from '../../services/kibana_services'; +import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../../utils/urls'; import { getPanelTooOldErrorString } from '../_dashboard_app_strings'; -/** - * For BWC reasons, dashboard state is stored with panels as an array instead of a map - */ -export type SharedDashboardState = Partial< - Omit & { panels: DashboardPanel[] } ->; - const panelIsLegacy = (panel: unknown): panel is SavedDashboardPanel => { return (panel as SavedDashboardPanel).embeddableConfig !== undefined; }; @@ -91,6 +83,7 @@ export const loadAndRemoveDashboardState = ( const rawAppStateInUrl = kbnUrlStateStorage.get( DASHBOARD_STATE_STORAGE_KEY ); + if (!rawAppStateInUrl) return {}; const panelsMap = getPanelsMap(rawAppStateInUrl.panels); @@ -101,7 +94,12 @@ export const loadAndRemoveDashboardState = ( }); kbnUrlStateStorage.kbnUrlControls.update(nextUrl, true); const partialState: Partial = { - ..._.omit(rawAppStateInUrl, ['panels', 'query']), + ..._.omit(rawAppStateInUrl, ['controlGroupState', 'panels', 'query']), + ...(rawAppStateInUrl.controlGroupState + ? { + controlGroupInput: serializeRuntimeState(rawAppStateInUrl.controlGroupState).rawState, + } + : {}), ...(panelsMap ? { panels: panelsMap } : {}), ...(rawAppStateInUrl.query ? { query: migrateLegacyQuery(rawAppStateInUrl.query) } : {}), }; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.test.tsx index bc01c88fbdac0..f6c267987b176 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.test.tsx @@ -116,9 +116,7 @@ test('DashboardGrid removes panel when removed from container', async () => { // remove panel await act(async () => { - dashboardApi.setPanels({ - '2': PANELS['2'], - }); + dashboardApi.removePanel('1'); await new Promise((resolve) => setTimeout(resolve, 1)); }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.tsx index aec55d90de1e5..65ae2b1fa5450 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid.tsx @@ -7,20 +7,19 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import classNames from 'classnames'; -import React, { useCallback, useMemo, useRef } from 'react'; -import { v4 as uuidv4 } from 'uuid'; - +import { useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { useAppFixedViewport } from '@kbn/core-rendering-browser'; import { GridLayout, type GridLayoutData } from '@kbn/grid-layout'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; -import { useEuiTheme } from '@elastic/eui'; - -import { DashboardPanelState } from '../../../common'; +import classNames from 'classnames'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../common/content_management/constants'; import { arePanelLayoutsEqual } from '../../dashboard_api/are_panel_layouts_equal'; +import { DashboardLayout } from '../../dashboard_api/types'; import { useDashboardApi } from '../../dashboard_api/use_dashboard_api'; +import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api'; import { DEFAULT_DASHBOARD_DRAG_TOP_OFFSET, DASHBOARD_GRID_HEIGHT, @@ -35,14 +34,16 @@ export const DashboardGrid = ({ dashboardContainerRef?: React.MutableRefObject; }) => { const dashboardApi = useDashboardApi(); + const dashboardInternalApi = useDashboardInternalApi(); + const layoutStyles = useLayoutStyles(); const panelRefs = useRef<{ [panelId: string]: React.Ref }>({}); const { euiTheme } = useEuiTheme(); const firstRowId = useRef(uuidv4()); - const [expandedPanelId, panels, useMargins, viewMode] = useBatchedPublishingSubjects( + const [expandedPanelId, layout, useMargins, viewMode] = useBatchedPublishingSubjects( dashboardApi.expandedPanelId$, - dashboardApi.panels$, + dashboardInternalApi.layout$, dashboardApi.settings.useMargins$, dashboardApi.viewMode$ ); @@ -58,8 +59,8 @@ export const DashboardGrid = ({ panels: {}, }; - Object.keys(panels).forEach((panelId) => { - const gridData = panels[panelId].gridData; + Object.keys(layout).forEach((panelId) => { + const gridData = layout[panelId].gridData; singleRow.panels[panelId] = { id: panelId, row: gridData.y, @@ -75,14 +76,14 @@ export const DashboardGrid = ({ }); return { [firstRowId.current]: singleRow }; - }, [panels]); + }, [layout]); const onLayoutChange = useCallback( (newLayout: GridLayoutData) => { if (viewMode !== 'edit') return; - const currentPanels = dashboardApi.panels$.getValue(); - const updatedPanels: { [key: string]: DashboardPanelState } = Object.values( + const currentPanels = dashboardInternalApi.layout$.getValue(); + const updatedPanels: DashboardLayout = Object.values( newLayout[firstRowId.current].panels ).reduce((updatedPanelsAcc, panelLayout) => { updatedPanelsAcc[panelLayout.id] = { @@ -96,17 +97,17 @@ export const DashboardGrid = ({ }, }; return updatedPanelsAcc; - }, {} as { [key: string]: DashboardPanelState }); + }, {} as DashboardLayout); if (!arePanelLayoutsEqual(currentPanels, updatedPanels)) { - dashboardApi.setPanels(updatedPanels); + dashboardInternalApi.layout$.next(updatedPanels); } }, - [dashboardApi, viewMode] + [dashboardInternalApi.layout$, viewMode] ); const renderPanelContents = useCallback( (id: string, setDragHandles: (refs: Array) => void) => { - const currentPanels = dashboardApi.panels$.getValue(); + const currentPanels = dashboardInternalApi.layout$.getValue(); if (!currentPanels[id]) return; if (!panelRefs.current[id]) { @@ -126,7 +127,7 @@ export const DashboardGrid = ({ /> ); }, - [appFixedViewport, dashboardApi, dashboardContainerRef] + [appFixedViewport, dashboardContainerRef, dashboardInternalApi.layout$] ); const memoizedgridLayout = useMemo(() => { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx index 303640f0c9fbb..103ec23a249eb 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/dashboard_grid_item.tsx @@ -7,19 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import classNames from 'classnames'; -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; - import { EuiLoadingChart } from '@elastic/eui'; import { css } from '@emotion/react'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; - +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; -import { DASHBOARD_MARGIN_SIZE } from './constants'; -import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api'; +import classNames from 'classnames'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { DashboardPanelState } from '../../../common'; import { useDashboardApi } from '../../dashboard_api/use_dashboard_api'; +import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api'; import { presentationUtilService } from '../../services/kibana_services'; +import { DASHBOARD_MARGIN_SIZE } from './constants'; type DivProps = Pick, 'className' | 'style' | 'children'>; @@ -127,7 +125,7 @@ export const Item = React.forwardRef( }; return ( - ({ diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx index 35cb077be5bb6..e7eb0c532f09d 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx @@ -11,20 +11,17 @@ import classNames from 'classnames'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiPortal } from '@elastic/eui'; -import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; +import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen'; -import { - ControlGroupApi, - ControlGroupRuntimeState, - ControlGroupSerializedState, -} from '@kbn/controls-plugin/public'; +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { DashboardGrid } from '../grid'; import { useDashboardApi } from '../../dashboard_api/use_dashboard_api'; import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api'; import { DashboardEmptyScreen } from './empty_screen/dashboard_empty_screen'; +import { CONTROL_GROUP_EMBEDDABLE_ID } from '../../dashboard_api/control_group_manager'; export const DashboardViewport = ({ dashboardContainerRef, @@ -39,7 +36,7 @@ export const DashboardViewport = ({ dashboardTitle, description, expandedPanelId, - panels, + layout, viewMode, useMargins, fullScreenMode, @@ -48,7 +45,7 @@ export const DashboardViewport = ({ dashboardApi.title$, dashboardApi.description$, dashboardApi.expandedPanelId$, - dashboardApi.panels$, + dashboardInternalApi.layout$, dashboardApi.viewMode$, dashboardApi.settings.useMargins$, dashboardApi.fullScreenMode$ @@ -58,8 +55,8 @@ export const DashboardViewport = ({ }, [dashboardApi]); const panelCount = useMemo(() => { - return Object.keys(panels).length; - }, [panels]); + return Object.keys(layout).length; + }, [layout]); const classes = classNames({ dshDashboardViewport: true, @@ -106,22 +103,16 @@ export const DashboardViewport = ({ > {viewMode !== 'print' ? (
- + key={dashboardApi.uuid} hidePanelChrome={true} panelProps={{ hideLoader: true }} type={CONTROL_GROUP_TYPE} - maybeId={'control_group'} + maybeId={CONTROL_GROUP_EMBEDDABLE_ID} getParentApi={() => { return { ...dashboardApi, reload$: dashboardInternalApi.controlGroupReload$, - getSerializedStateForChild: dashboardInternalApi.getSerializedStateForControlGroup, - getRuntimeStateForChild: dashboardInternalApi.getRuntimeStateForControlGroup, }; }} onApiAvailable={(api) => dashboardInternalApi.setControlGroupApi(api)} diff --git a/src/platform/plugins/shared/dashboard/public/mocks.tsx b/src/platform/plugins/shared/dashboard/public/mocks.tsx index ba7d446332aba..8e5049d313825 100644 --- a/src/platform/plugins/shared/dashboard/public/mocks.tsx +++ b/src/platform/plugins/shared/dashboard/public/mocks.tsx @@ -74,7 +74,7 @@ export const mockControlGroupApi = { timeslice$: new BehaviorSubject(undefined), esqlVariables$: new BehaviorSubject(undefined), dataViews$: new BehaviorSubject(undefined), - unsavedChanges$: new BehaviorSubject(undefined), + hasUnsavedChanges$: new BehaviorSubject(false), } as unknown as ControlGroupApi; export function buildMockDashboardApi({ diff --git a/src/platform/plugins/shared/dashboard/public/panel_placement/place_clone_panel_strategy.ts b/src/platform/plugins/shared/dashboard/public/panel_placement/place_clone_panel_strategy.ts index 229f75e07a4d5..f34f4bcf7dbed 100644 --- a/src/platform/plugins/shared/dashboard/public/panel_placement/place_clone_panel_strategy.ts +++ b/src/platform/plugins/shared/dashboard/public/panel_placement/place_clone_panel_strategy.ts @@ -7,13 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { cloneDeep, forOwn } from 'lodash'; import { PanelNotFoundError } from '@kbn/embeddable-plugin/public'; - -import { DashboardPanelState } from '../../common'; +import { cloneDeep, forOwn } from 'lodash'; +import { DASHBOARD_GRID_COLUMN_COUNT } from '../../common/content_management'; import type { GridData } from '../../server/content_management'; +import { DashboardLayoutItem } from '../dashboard_api/types'; import { PanelPlacementProps, PanelPlacementReturn } from './types'; -import { DASHBOARD_GRID_COLUMN_COUNT } from '../../common/content_management'; interface IplacementDirection { grid: Omit; @@ -52,7 +51,7 @@ export function placeClonePanel({ } const beside = panelToPlaceBeside.gridData; const otherPanelGridData: GridData[] = []; - forOwn(currentPanels, (panel: DashboardPanelState, key: string | undefined) => { + forOwn(currentPanels, (panel: DashboardLayoutItem, key: string | undefined) => { otherPanelGridData.push(panel.gridData); }); diff --git a/src/platform/plugins/shared/dashboard/public/panel_placement/place_new_panel_strategies.ts b/src/platform/plugins/shared/dashboard/public/panel_placement/place_new_panel_strategies.ts index 30b151cb73a2a..20f2a144131ae 100644 --- a/src/platform/plugins/shared/dashboard/public/panel_placement/place_new_panel_strategies.ts +++ b/src/platform/plugins/shared/dashboard/public/panel_placement/place_new_panel_strategies.ts @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { cloneDeep } from 'lodash'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../../common/content_management'; import { PanelPlacementStrategy } from '../plugin_constants'; import { PanelPlacementProps, PanelPlacementReturn } from './types'; diff --git a/src/platform/plugins/shared/dashboard/public/panel_placement/types.ts b/src/platform/plugins/shared/dashboard/public/panel_placement/types.ts index 2a9af173617ed..d7b5753694090 100644 --- a/src/platform/plugins/shared/dashboard/public/panel_placement/types.ts +++ b/src/platform/plugins/shared/dashboard/public/panel_placement/types.ts @@ -8,9 +8,10 @@ */ import { MaybePromise } from '@kbn/utility-types'; -import { DashboardPanelState } from '../../common'; +import { SerializedPanelState } from '@kbn/presentation-publishing'; import type { GridData } from '../../server/content_management'; import { PanelPlacementStrategy } from '../plugin_constants'; +import { DashboardLayout } from '../dashboard_api/types'; export interface PanelPlacementSettings { strategy?: PanelPlacementStrategy; @@ -20,15 +21,15 @@ export interface PanelPlacementSettings { export interface PanelPlacementReturn { newPanelPlacement: Omit; - otherPanels: { [key: string]: DashboardPanelState }; + otherPanels: DashboardLayout; } export interface PanelPlacementProps { width: number; height: number; - currentPanels: { [key: string]: DashboardPanelState }; + currentPanels: DashboardLayout; } export type GetPanelPlacementSettings = ( - serializedState?: SerializedState + serializedState?: SerializedPanelState ) => MaybePromise; diff --git a/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts b/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts index 0c489fb5aaeb7..bbd3f6be16dbe 100644 --- a/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts +++ b/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts @@ -17,12 +17,10 @@ import { set } from '@kbn/safer-lodash-set'; import { SerializedPanelState, ViewMode } from '@kbn/presentation-publishing'; import { coreServices, spacesService } from './kibana_services'; import { DashboardState } from '../../common'; -import { UnsavedPanelState } from '../dashboard_api/types'; import { DEFAULT_DASHBOARD_STATE } from '../dashboard_api/default_dashboard_state'; export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard'; export const PANELS_CONTROL_GROUP_KEY = 'controlGroup'; -const DASHBOARD_PANELS_SESSION_KEY = 'dashboardPanels'; const DASHBOARD_VIEWMODE_LOCAL_KEY = 'dashboardViewMode'; // this key is named `panels` for BWC reasons, but actually contains the entire dashboard state @@ -39,17 +37,8 @@ const getPanelsGetError = (message: string) => interface DashboardBackupServiceType { clearState: (id?: string) => void; - getState: (id: string | undefined) => - | { - dashboardState?: Partial; - panels?: UnsavedPanelState; - } - | undefined; - setState: ( - id: string | undefined, - dashboardState: Partial, - panels: UnsavedPanelState - ) => void; + getState: (id: string | undefined) => Partial | undefined; + setState: (id: string | undefined, dashboardState: Partial) => void; getViewMode: () => ViewMode; storeViewMode: (viewMode: ViewMode) => void; getDashboardIdsWithUnsavedChanges: () => string[]; @@ -103,24 +92,6 @@ class DashboardBackupService implements DashboardBackupServiceType { [this.activeSpaceId]: dashboardStateStorage, }); } - - const panelsStorage = - this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] ?? {}; - if (panelsStorage[id]) { - delete panelsStorage[id]; - this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, { - [this.activeSpaceId]: panelsStorage, - }); - } - - const serializedBackups = - this.sessionStorage.get(DASHBOARD_SERIALIZED_PANEL_BACKUP_KEY)?.[this.activeSpaceId] ?? {}; - if (serializedBackups[id]) { - delete serializedBackups[id]; - this.sessionStorage.set(DASHBOARD_SERIALIZED_PANEL_BACKUP_KEY, { - [this.activeSpaceId]: serializedBackups, - }); - } } catch (e) { coreServices.notifications.toasts.addDanger({ title: i18n.translate('dashboard.panelStorageError.clearError', { @@ -160,14 +131,9 @@ class DashboardBackupService implements DashboardBackupServiceType { public getState(id = DASHBOARD_PANELS_UNSAVED_ID) { try { - const dashboardState = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[ - this.activeSpaceId - ]?.[id] as Partial | undefined; - const panels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[ - id - ] as UnsavedPanelState | undefined; - - return { dashboardState, panels }; + return this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[this.activeSpaceId]?.[id] as + | Partial + | undefined; } catch (e) { coreServices.notifications.toasts.addDanger({ title: getPanelsGetError(e.message), @@ -176,19 +142,11 @@ class DashboardBackupService implements DashboardBackupServiceType { } } - public setState( - id = DASHBOARD_PANELS_UNSAVED_ID, - newState: Partial, - unsavedPanels: UnsavedPanelState - ) { + public setState(id = DASHBOARD_PANELS_UNSAVED_ID, newState: Partial) { try { const dashboardStateStorage = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY) ?? {}; set(dashboardStateStorage, [this.activeSpaceId, id], newState); this.sessionStorage.set(DASHBOARD_STATE_SESSION_KEY, dashboardStateStorage); - - const panelsStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) ?? {}; - set(panelsStorage, [this.activeSpaceId, id], unsavedPanels); - this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, panelsStorage, true); } catch (e) { coreServices.notifications.toasts.addDanger({ title: i18n.translate('dashboard.panelStorageError.setError', { @@ -204,23 +162,18 @@ class DashboardBackupService implements DashboardBackupServiceType { try { const dashboardStatesInSpace = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[this.activeSpaceId] ?? {}; - const panelStatesInSpace = - this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] ?? {}; - const dashboardsSet: Set = new Set(); - [...Object.keys(panelStatesInSpace), ...Object.keys(dashboardStatesInSpace)].map( - (dashboardId) => { - if ( - dashboardStatesInSpace[dashboardId].viewMode === 'edit' && - (Object.keys(dashboardStatesInSpace[dashboardId]).some( - (stateKey) => stateKey !== 'viewMode' && stateKey !== 'references' - ) || - Object.keys(panelStatesInSpace?.[dashboardId]).length > 0) + Object.keys(dashboardStatesInSpace).map((dashboardId) => { + if ( + dashboardStatesInSpace[dashboardId].viewMode === 'edit' && + Object.keys(dashboardStatesInSpace[dashboardId]).some( + (stateKey) => stateKey !== 'viewMode' && stateKey !== 'references' ) - dashboardsSet.add(dashboardId); + ) { + dashboardsSet.add(dashboardId); } - ); + }); const dashboardsWithUnsavedChanges = [...dashboardsSet]; /** diff --git a/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts b/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts index 80fb13563129a..e406f30e212a8 100644 --- a/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts +++ b/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts @@ -132,7 +132,7 @@ export const loadDashboardState = async ({ } try { searchSourceValues = injectSearchSourceReferences( - searchSourceValues as any, + searchSourceValues, references ) as DashboardSearchSource; return await dataSearchService.searchSource.create(searchSourceValues); diff --git a/src/platform/plugins/shared/dashboard/tsconfig.json b/src/platform/plugins/shared/dashboard/tsconfig.json index f97d1d3aeeb0d..914c15bd14be4 100644 --- a/src/platform/plugins/shared/dashboard/tsconfig.json +++ b/src/platform/plugins/shared/dashboard/tsconfig.json @@ -79,12 +79,12 @@ "@kbn/core-custom-branding-browser-mocks", "@kbn/core-mount-utils-browser", "@kbn/visualization-utils", - "@kbn/std", "@kbn/core-rendering-browser", "@kbn/grid-layout", "@kbn/ui-actions-browser", "@kbn/esql-types", - "@kbn/saved-objects-tagging-plugin" + "@kbn/saved-objects-tagging-plugin", + "@kbn/std" ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/data/common/search/search_source/inject_references.ts b/src/platform/plugins/shared/data/common/search/search_source/inject_references.ts index 465d90f3e3023..699d225cfaad0 100644 --- a/src/platform/plugins/shared/data/common/search/search_source/inject_references.ts +++ b/src/platform/plugins/shared/data/common/search/search_source/inject_references.ts @@ -12,7 +12,7 @@ import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; import { SerializedSearchSourceFields } from './types'; export const injectReferences = ( - searchSourceFields: SerializedSearchSourceFields & { indexRefName: string }, + searchSourceFields: SerializedSearchSourceFields & { indexRefName?: string }, references: SavedObjectReference[] ) => { const searchSourceReturnFields: SerializedSearchSourceFields = { ...searchSourceFields }; diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_on_add_search_embeddable.ts b/src/platform/plugins/shared/discover/public/embeddable/get_on_add_search_embeddable.ts deleted file mode 100644 index 2dac96b88683f..0000000000000 --- a/src/platform/plugins/shared/discover/public/embeddable/get_on_add_search_embeddable.ts +++ /dev/null @@ -1,32 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; -import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; -import type { DiscoverServices } from '../build_services'; -import { deserializeState } from './utils/serialization_utils'; - -export const getOnAddSearchEmbeddable = - ( - discoverServices: DiscoverServices - ): Parameters[0]['onAdd'] => - async (container, savedObject) => { - const initialState = await deserializeState({ - serializedState: { - rawState: { savedObjectId: savedObject.id }, - references: savedObject.references, - }, - discoverServices, - }); - - container.addNewPanel({ - panelType: SEARCH_EMBEDDABLE_TYPE, - initialState, - }); - }; 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 346eaca3d4450..3ef59ad7a252b 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 @@ -14,13 +14,8 @@ import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; import type { DataView } from '@kbn/data-views-plugin/common'; import { SHOW_FIELD_STATISTICS } from '@kbn/discover-utils'; import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__'; -import type { BuildReactEmbeddableApiRegistration } from '@kbn/embeddable-plugin/public/react_embeddable_system/types'; import type { PresentationContainer } from '@kbn/presentation-containers'; -import type { - PhaseEvent, - PublishesUnifiedSearch, - StateComparators, -} from '@kbn/presentation-publishing'; +import type { PhaseEvent, PublishesUnifiedSearch } from '@kbn/presentation-publishing'; import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import { act, render, waitFor } from '@testing-library/react'; @@ -28,13 +23,46 @@ import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { createDataViewDataSource } from '../../common/data_sources'; import { discoverServiceMock } from '../__mocks__/services'; import { getSearchEmbeddableFactory } from './get_search_embeddable_factory'; -import type { - SearchEmbeddableApi, - SearchEmbeddableRuntimeState, - SearchEmbeddableSerializedState, -} from './types'; +import type { SearchEmbeddableApi, SearchEmbeddableRuntimeState } from './types'; + +jest.mock('./utils/serialization_utils', () => ({})); describe('saved search embeddable', () => { + const dataViewMock = buildDataViewMock({ name: 'the-data-view', fields: deepMockedFields }); + + const getInitialRuntimeState = ({ + searchMock, + dataView = dataViewMock, + partialState = {}, + }: { + searchMock?: jest.Mock; + dataView?: DataView; + partialState?: Partial; + } = {}): SearchEmbeddableRuntimeState => { + const searchSource = createSearchSourceMock({ index: dataView }, undefined, searchMock); + discoverServiceMock.data.search.searchSource.create = jest + .fn() + .mockResolvedValueOnce(searchSource); + + return { + timeRange: { from: 'now-15m', to: 'now' }, + columns: ['message', 'extension'], + rowHeight: 30, + headerRowHeight: 5, + rowsPerPage: 50, + sampleSize: 250, + serializedSearchSource: searchSource.getSerializedFields(), + ...partialState, + }; + }; + + let runtimeState = getInitialRuntimeState(); + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('./utils/serialization_utils').deserializeState = () => runtimeState; + }); + const mockServices = { discoverServices: discoverServiceMock, startServices: { @@ -42,7 +70,6 @@ describe('saved search embeddable', () => { isEditable: jest.fn().mockReturnValue(true), }, }; - const dataViewMock = buildDataViewMock({ name: 'the-data-view', fields: deepMockedFields }); const uuid = 'mock-embeddable-id'; const factory = getSearchEmbeddableFactory(mockServices); @@ -77,66 +104,28 @@ describe('saved search embeddable', () => { return { search, resolveSearch: () => resolveSearch() }; }; - const buildApiMock = ( - api: BuildReactEmbeddableApiRegistration< - SearchEmbeddableSerializedState, - SearchEmbeddableRuntimeState, - SearchEmbeddableApi - >, - _: StateComparators + const finalizeApiMock = ( + api: Omit ) => ({ ...api, uuid, type: factory.type, parentApi: mockedDashboardApi, phase$: new BehaviorSubject(undefined), - resetUnsavedChanges: jest.fn(), - snapshotRuntimeState: jest.fn(), - unsavedChanges: new BehaviorSubject | undefined>( - undefined - ), }); - const getInitialRuntimeState = ({ - searchMock, - dataView = dataViewMock, - partialState = {}, - }: { - searchMock?: jest.Mock; - dataView?: DataView; - partialState?: Partial; - } = {}): SearchEmbeddableRuntimeState => { - const searchSource = createSearchSourceMock({ index: dataView }, undefined, searchMock); - discoverServiceMock.data.search.searchSource.create = jest - .fn() - .mockResolvedValueOnce(searchSource); - - return { - timeRange: { from: 'now-15m', to: 'now' }, - columns: ['message', 'extension'], - rowHeight: 30, - headerRowHeight: 5, - rowsPerPage: 50, - sampleSize: 250, - serializedSearchSource: searchSource.getSerializedFields(), - ...partialState, - }; - }; - const waitOneTick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); 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( - initialRuntimeState, - buildApiMock, + runtimeState = getInitialRuntimeState({ searchMock: search }); + const { Component, api } = await factory.buildEmbeddable({ + initialState: { rawState: {} }, // runtimeState passed via mocked deserializeState + finalizeApi: finalizeApiMock, uuid, - mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi), - initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState - ); + parentApi: mockedDashboardApi, + }); await waitOneTick(); // wait for build to complete const discoverComponent = render(); @@ -156,19 +145,16 @@ describe('saved search embeddable', () => { it('should render field stats table in AGGREGATED_LEVEL view mode', async () => { const { search, resolveSearch } = createSearchFnMock(0); - - const initialRuntimeState = getInitialRuntimeState({ + runtimeState = getInitialRuntimeState({ searchMock: search, partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL }, }); - const { Component, api } = await factory.buildEmbeddable( - initialRuntimeState, - buildApiMock, + const { Component, api } = await factory.buildEmbeddable({ + initialState: { rawState: {} }, // runtimeState passed via mocked deserializeState + finalizeApi: finalizeApiMock, uuid, - mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi), - initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState - ); + parentApi: mockedDashboardApi, + }); await waitOneTick(); // wait for build to complete discoverServiceMock.uiSettings.get = jest.fn().mockImplementationOnce((key: string) => { @@ -189,18 +175,16 @@ 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({ + runtimeState = getInitialRuntimeState({ searchMock: search, partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL }, }); - const { api } = await factory.buildEmbeddable( - initialRuntimeState, - buildApiMock, + const { api } = await factory.buildEmbeddable({ + initialState: { rawState: {} }, // runtimeState passed via mocked deserializeState + finalizeApi: finalizeApiMock, uuid, - mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi), - initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState - ); + parentApi: mockedDashboardApi, + }); await waitOneTick(); // wait for build to complete // wait for data fetching @@ -232,15 +216,13 @@ describe('saved search embeddable', () => { discoverServiceMock.profilesManager, 'resolveRootProfile' ); - const initialRuntimeState = getInitialRuntimeState(); - await factory.buildEmbeddable( - initialRuntimeState, - buildApiMock, + runtimeState = getInitialRuntimeState(); + await factory.buildEmbeddable({ + initialState: { rawState: {} }, // runtimeState passed via mocked deserializeState + finalizeApi: finalizeApiMock, uuid, - mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi), - initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState - ); + parentApi: mockedDashboardApi, + }); await waitOneTick(); // wait for build to complete expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'test' }); @@ -253,20 +235,18 @@ describe('saved search embeddable', () => { discoverServiceMock.profilesManager, 'resolveRootProfile' ); - const initialRuntimeState = { + runtimeState = { ...getInitialRuntimeState(), nonPersistedDisplayOptions: { solutionNavIdOverride: 'search' as const, }, }; - await factory.buildEmbeddable( - initialRuntimeState, - buildApiMock, + await factory.buildEmbeddable({ + initialState: { rawState: {} }, // runtimeState passed via mocked deserializeState + finalizeApi: finalizeApiMock, uuid, - mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi), - initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState - ); + parentApi: mockedDashboardApi, + }); await waitOneTick(); // wait for build to complete expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'search', @@ -278,15 +258,13 @@ describe('saved search embeddable', () => { discoverServiceMock.profilesManager, 'resolveDataSourceProfile' ); - const initialRuntimeState = getInitialRuntimeState(); - const { api } = await factory.buildEmbeddable( - initialRuntimeState, - buildApiMock, + runtimeState = getInitialRuntimeState(); + const { api } = await factory.buildEmbeddable({ + initialState: { rawState: {} }, // runtimeState passed via mocked deserializeState + finalizeApi: finalizeApiMock, uuid, - mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi), - initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState - ); + parentApi: mockedDashboardApi, + }); await waitOneTick(); // wait for build to complete expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({ @@ -305,18 +283,16 @@ describe('saved search embeddable', () => { it('should pass cell renderers from profile', async () => { const { search, resolveSearch } = createSearchFnMock(1); - const initialRuntimeState = getInitialRuntimeState({ + runtimeState = getInitialRuntimeState({ searchMock: search, partialState: { columns: ['rootProfile', 'message', 'extension'] }, }); - const { Component, api } = await factory.buildEmbeddable( - initialRuntimeState, - buildApiMock, + const { Component, api } = await factory.buildEmbeddable({ + initialState: { rawState: {} }, // runtimeState passed via mocked deserializeState + finalizeApi: finalizeApiMock, uuid, - mockedDashboardApi, - jest.fn().mockImplementation((newApi) => newApi), - initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState - ); + parentApi: mockedDashboardApi, + }); await waitOneTick(); // wait for build to complete const discoverComponent = render(); diff --git a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx index 397f544ddda40..f1be73f156938 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -8,20 +8,21 @@ */ import React, { useCallback, useEffect, useMemo } from 'react'; -import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, merge } from 'rxjs'; import { CellActionsProvider } from '@kbn/cell-actions'; import { APPLY_FILTER_TRIGGER, generateFilters } from '@kbn/data-plugin/public'; import { SEARCH_EMBEDDABLE_TYPE, SHOW_FIELD_STATISTICS } from '@kbn/discover-utils'; -import type { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; import { FilterStateStore } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { FetchContext } from '@kbn/presentation-publishing'; import { - getUnchangingComparator, - initializeTimeRange, + initializeTimeRangeManager, initializeTitleManager, + timeRangeComparators, + titleComparators, useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; @@ -29,6 +30,7 @@ import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import type { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types'; import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { getValidViewMode } from '../application/main/utils/get_valid_view_mode'; import type { DiscoverServices } from '../build_services'; import { SearchEmbeddablFieldStatsTableComponent } from './components/search_embeddable_field_stats_table_component'; @@ -36,12 +38,7 @@ import { SearchEmbeddableGridComponent } from './components/search_embeddable_gr import { initializeEditApi } from './initialize_edit_api'; import { initializeFetch, isEsqlMode } from './initialize_fetch'; import { initializeSearchEmbeddableApi } from './initialize_search_embeddable_api'; -import type { - NonPersistedDisplayOptions, - SearchEmbeddableApi, - SearchEmbeddableRuntimeState, - SearchEmbeddableSerializedState, -} from './types'; +import type { SearchEmbeddableApi, SearchEmbeddableSerializedState } from './types'; import { deserializeState, serializeState } from './utils/serialization_utils'; import { BaseAppWrapper } from '../context_awareness'; @@ -57,19 +54,20 @@ export const getSearchEmbeddableFactory = ({ }) => { const { save, checkForDuplicateTitle } = discoverServices.savedSearch; - const savedSearchEmbeddableFactory: ReactEmbeddableFactory< + const savedSearchEmbeddableFactory: EmbeddableFactory< SearchEmbeddableSerializedState, - SearchEmbeddableRuntimeState, SearchEmbeddableApi > = { type: SEARCH_EMBEDDABLE_TYPE, - deserializeState: async (serializedState) => { - return deserializeState({ serializedState, discoverServices }); - }, - buildEmbeddable: async (initialState, buildApi, uuid, parentApi) => { + buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { + const runtimeState = await deserializeState({ + serializedState: initialState, + discoverServices, + }); + /** One Discover context awareness */ const solutionNavId = - initialState.nonPersistedDisplayOptions?.solutionNavIdOverride ?? + runtimeState.nonPersistedDisplayOptions?.solutionNavIdOverride ?? (await firstValueFrom(discoverServices.core.chrome.getActiveSolutionNavId$())); const { getRenderAppWrapper } = await discoverServices.profilesManager.resolveRootProfile({ solutionNavId, @@ -77,17 +75,12 @@ export const getSearchEmbeddableFactory = ({ const AppWrapper = getRenderAppWrapper?.(BaseAppWrapper) ?? BaseAppWrapper; /** Specific by-reference state */ - const savedObjectId$ = new BehaviorSubject(initialState?.savedObjectId); - const defaultTitle$ = new BehaviorSubject(initialState?.savedObjectTitle); + const savedObjectId$ = new BehaviorSubject(runtimeState?.savedObjectId); + const defaultTitle$ = new BehaviorSubject(runtimeState?.savedObjectTitle); const defaultDescription$ = new BehaviorSubject( - initialState?.savedObjectDescription + runtimeState?.savedObjectDescription ); - /** By-value SavedSearchComponent package (non-dashboard contexts) state, to adhere to the comparator contract of an embeddable. */ - const nonPersistedDisplayOptions$ = new BehaviorSubject< - NonPersistedDisplayOptions | undefined - >(initialState?.nonPersistedDisplayOptions); - /** All other state */ const blockingError$ = new BehaviorSubject(undefined); const dataLoading$ = new BehaviorSubject(true); @@ -95,23 +88,24 @@ export const getSearchEmbeddableFactory = ({ const fetchWarnings$ = new BehaviorSubject([]); /** Build API */ - const titleManager = initializeTitleManager(initialState); - const timeRange = initializeTimeRange(initialState); - const dynamicActionsApi = - discoverServices.embeddableEnhanced?.initializeReactEmbeddableDynamicActions( + const titleManager = initializeTitleManager(initialState.rawState); + const timeRangeManager = initializeTimeRangeManager(initialState.rawState); + const dynamicActionsManager = + discoverServices.embeddableEnhanced?.initializeEmbeddableDynamicActions( uuid, () => titleManager.api.title$.getValue(), - initialState + initialState.rawState ); - const maybeStopDynamicActions = dynamicActionsApi?.startDynamicActions(); - const searchEmbeddable = await initializeSearchEmbeddableApi(initialState, { + const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); + const searchEmbeddable = await initializeSearchEmbeddableApi(runtimeState, { discoverServices, }); const unsubscribeFromFetch = initializeFetch({ api: { parentApi, ...titleManager.api, - ...timeRange.api, + ...timeRangeManager.api, + defaultTitle$, savedSearch$: searchEmbeddable.api.savedSearch$, dataViews$: searchEmbeddable.api.dataViews$, savedObjectId$, @@ -129,87 +123,114 @@ export const getSearchEmbeddableFactory = ({ const serialize = (savedObjectId?: string) => serializeState({ uuid, - initialState, + initialState: runtimeState, savedSearch: searchEmbeddable.api.savedSearch$.getValue(), - serializeTitles: titleManager.serialize, - serializeTimeRange: timeRange.serialize, - serializeDynamicActions: dynamicActionsApi?.serializeDynamicActions, + serializeTitles: titleManager.getLatestState, + serializeTimeRange: timeRangeManager.getLatestState, + serializeDynamicActions: dynamicActionsManager?.getLatestState, savedObjectId, }); - const api: SearchEmbeddableApi = buildApi( - { - ...titleManager.api, - ...searchEmbeddable.api, - ...timeRange.api, - ...dynamicActionsApi?.dynamicActionsApi, - ...initializeEditApi({ - uuid, - parentApi, - partialApi: { ...searchEmbeddable.api, fetchContext$, savedObjectId$ }, - discoverServices, - isEditable: startServices.isEditable, - }), - dataLoading$, - blockingError$, - savedObjectId$, - defaultTitle$, - defaultDescription$, - hasTimeRange: () => { - const fetchContext = fetchContext$.getValue(); - return fetchContext?.timeslice !== undefined || fetchContext?.timeRange !== undefined; - }, - getTypeDisplayName: () => - i18n.translate('discover.embeddable.search.displayName', { - defaultMessage: 'Discover session', - }), - canLinkToLibrary: async () => { - return ( - discoverServices.capabilities.discover_v2.save && !Boolean(savedObjectId$.getValue()) - ); - }, - canUnlinkFromLibrary: async () => Boolean(savedObjectId$.getValue()), - saveToLibrary: async (title: string) => { - const savedObjectId = await save({ - ...api.savedSearch$.getValue(), - title, + const unsavedChangesApi = initializeUnsavedChanges({ + uuid, + parentApi, + serializeState: () => serialize(savedObjectId$.getValue()), + anyStateChange$: merge( + ...(dynamicActionsManager ? [dynamicActionsManager.anyStateChange$] : []), + searchEmbeddable.anyStateChange$, + titleManager.anyStateChange$, + timeRangeManager.anyStateChange$ + ), + getComparators: () => { + return { + ...(dynamicActionsManager?.comparators ?? { enhancements: 'skip' }), + ...titleComparators, + ...timeRangeComparators, + ...searchEmbeddable.comparators, + attributes: 'skip', + breakdownField: 'skip', + hideAggregatedPreview: 'skip', + hideChart: 'skip', + isTextBasedQuery: 'skip', + kibanaSavedObjectMeta: 'skip', + nonPersistedDisplayOptions: 'skip', + refreshInterval: 'skip', + savedObjectId: 'skip', + timeRestore: 'skip', + usesAdHocDataView: 'skip', + visContext: 'skip', + }; + }, + onReset: async (lastSaved) => { + dynamicActionsManager?.reinitializeState(lastSaved?.rawState ?? {}); + timeRangeManager.reinitializeState(lastSaved?.rawState); + titleManager.reinitializeState(lastSaved?.rawState); + if (lastSaved) { + const lastSavedRuntimeState = await deserializeState({ + serializedState: lastSaved, + discoverServices, }); - defaultTitle$.next(title); - return savedObjectId!; - }, - checkForDuplicateTitle: (newTitle, isTitleDuplicateConfirmed, onTitleDuplicate) => - checkForDuplicateTitle({ - newTitle, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }), - getSerializedStateByValue: () => serialize(undefined), - getSerializedStateByReference: (newId: string) => serialize(newId), - serializeState: () => serialize(savedObjectId$.getValue()), - getInspectorAdapters: () => searchEmbeddable.stateManager.inspectorAdapters.getValue(), - supportedTriggers: () => { - // No triggers are supported, but this is still required to pass the drilldown - // compatibilty check and ensure top-level drilldowns (e.g. URL) work as expected - return []; - }, + searchEmbeddable.reinitializeState(lastSavedRuntimeState); + } }, - { - ...titleManager.comparators, - ...timeRange.comparators, - ...(dynamicActionsApi?.dynamicActionsComparator ?? { - enhancements: getUnchangingComparator(), + }); + + const api: SearchEmbeddableApi = finalizeApi({ + ...unsavedChangesApi, + ...titleManager.api, + ...searchEmbeddable.api, + ...timeRangeManager.api, + ...dynamicActionsManager?.api, + ...initializeEditApi({ + uuid, + parentApi, + partialApi: { ...searchEmbeddable.api, fetchContext$, savedObjectId$ }, + discoverServices, + isEditable: startServices.isEditable, + }), + dataLoading$, + blockingError$, + savedObjectId$, + defaultTitle$, + defaultDescription$, + hasTimeRange: () => { + const fetchContext = fetchContext$.getValue(); + return fetchContext?.timeslice !== undefined || fetchContext?.timeRange !== undefined; + }, + getTypeDisplayName: () => + i18n.translate('discover.embeddable.search.displayName', { + defaultMessage: 'Discover session', }), - ...searchEmbeddable.comparators, - rawSavedObjectAttributes: getUnchangingComparator(), - savedObjectId: [savedObjectId$, (value) => savedObjectId$.next(value)], - savedObjectTitle: [defaultTitle$, (value) => defaultTitle$.next(value)], - savedObjectDescription: [defaultDescription$, (value) => defaultDescription$.next(value)], - nonPersistedDisplayOptions: [ - nonPersistedDisplayOptions$, - (value) => nonPersistedDisplayOptions$.next(value), - ], - } - ); + canLinkToLibrary: async () => { + return ( + discoverServices.capabilities.discover_v2.save && !Boolean(savedObjectId$.getValue()) + ); + }, + canUnlinkFromLibrary: async () => Boolean(savedObjectId$.getValue()), + saveToLibrary: async (title: string) => { + const savedObjectId = await save({ + ...api.savedSearch$.getValue(), + title, + }); + defaultTitle$.next(title); + return savedObjectId!; + }, + checkForDuplicateTitle: (newTitle, isTitleDuplicateConfirmed, onTitleDuplicate) => + checkForDuplicateTitle({ + newTitle, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }), + getSerializedStateByValue: () => serialize(undefined), + getSerializedStateByReference: (newId: string) => serialize(newId), + serializeState: () => serialize(savedObjectId$.getValue()), + getInspectorAdapters: () => searchEmbeddable.stateManager.inspectorAdapters.getValue(), + supportedTriggers: () => { + // No triggers are supported, but this is still required to pass the drilldown + // compatibilty check and ensure top-level drilldowns (e.g. URL) work as expected + return []; + }, + }); return { api, @@ -244,9 +265,9 @@ export const getSearchEmbeddableFactory = ({ defaultMessage: 'Missing data view {indexPatternId}', values: { indexPatternId: - typeof initialState.serializedSearchSource?.index === 'string' - ? initialState.serializedSearchSource.index - : initialState.serializedSearchSource?.index?.id ?? '', + typeof runtimeState.serializedSearchSource?.index === 'string' + ? runtimeState.serializedSearchSource.index + : runtimeState.serializedSearchSource?.index?.id ?? '', }, }) ) @@ -314,14 +335,14 @@ export const getSearchEmbeddableFactory = ({ dataView={dataView!} onAddFilter={ isEsqlMode(savedSearch) || - initialState.nonPersistedDisplayOptions?.enableFilters === false + runtimeState.nonPersistedDisplayOptions?.enableFilters === false ? undefined : onAddFilter } enableDocumentViewer={ - initialState.nonPersistedDisplayOptions?.enableDocumentViewer !== + runtimeState.nonPersistedDisplayOptions?.enableDocumentViewer !== undefined - ? initialState.nonPersistedDisplayOptions?.enableDocumentViewer + ? runtimeState.nonPersistedDisplayOptions?.enableDocumentViewer : true } stateManager={searchEmbeddable.stateManager} diff --git a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx index a91473bf876e4..bf970a0d26ee3 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx +++ b/src/platform/plugins/shared/discover/public/embeddable/initialize_search_embeddable_api.tsx @@ -32,6 +32,7 @@ import type { PublishesSavedSearch, SearchEmbeddableRuntimeState, SearchEmbeddableSerializedAttributes, + SearchEmbeddableSerializedState, SearchEmbeddableStateManager, } from './types'; @@ -74,10 +75,11 @@ export const initializeSearchEmbeddableApi = async ( ): Promise<{ api: PublishesSavedSearch & PublishesWritableDataViews & Partial; stateManager: SearchEmbeddableStateManager; + anyStateChange$: Observable; comparators: StateComparators; cleanup: () => void; + reinitializeState: (lastSaved?: SearchEmbeddableSerializedState) => void; }> => { - const serializedSearchSource$ = new BehaviorSubject(initialState.serializedSearchSource); /** We **must** have a search source, so start by initializing it */ const { searchSource, dataView } = await initializeSearchSource( discoverServices.data, @@ -197,38 +199,30 @@ export const initializeSearchEmbeddableApi = async ( canEditUnifiedSearch, }, stateManager, + anyStateChange$: onAnyStateChange.pipe(map(() => undefined)), comparators: { - sort: [sort$, (value) => sort$.next(value), (a, b) => deepEqual(a, b)], - columns: [columns$, (value) => columns$.next(value), (a, b) => deepEqual(a, b)], - grid: [grid$, (value) => grid$.next(value), (a, b) => deepEqual(a, b)], - sampleSize: [ - sampleSize$, - (value) => sampleSize$.next(value), - (a, b) => (a ?? defaults.sampleSize) === (b ?? defaults.sampleSize), - ], - rowsPerPage: [ - rowsPerPage$, - (value) => rowsPerPage$.next(value), - (a, b) => (a ?? defaults.rowsPerPage) === (b ?? defaults.rowsPerPage), - ], - rowHeight: [ - rowHeight$, - (value) => rowHeight$.next(value), - (a, b) => (a ?? defaults.rowHeight) === (b ?? defaults.rowHeight), - ], - headerRowHeight: [ - headerRowHeight$, - (value) => headerRowHeight$.next(value), - (a, b) => (a ?? defaults.headerRowHeight) === (b ?? defaults.headerRowHeight), - ], - - /** The following can't currently be changed from the dashboard */ - serializedSearchSource: [ - serializedSearchSource$, - (value) => serializedSearchSource$.next(value), - ], - viewMode: [savedSearchViewMode$, (value) => savedSearchViewMode$.next(value)], - density: [density$, (value) => density$.next(value)], + sort: (a, b) => deepEqual(a ?? [], b ?? []), + columns: 'deepEquality', + grid: (a, b) => deepEqual(a ?? {}, b ?? {}), + sampleSize: (a, b) => (a ?? defaults.sampleSize) === (b ?? defaults.sampleSize), + rowsPerPage: (a, b) => (a ?? defaults.rowsPerPage) === (b ?? defaults.rowsPerPage), + rowHeight: (a, b) => (a ?? defaults.rowHeight) === (b ?? defaults.rowHeight), + headerRowHeight: (a, b) => + (a ?? defaults.headerRowHeight) === (b ?? defaults.headerRowHeight), + serializedSearchSource: 'referenceEquality', + viewMode: 'referenceEquality', + density: 'referenceEquality', + }, + reinitializeState: (lastSaved?: SearchEmbeddableRuntimeState) => { + sort$.next(lastSaved?.sort); + columns$.next(lastSaved?.columns); + grid$.next(lastSaved?.grid); + sampleSize$.next(lastSaved?.sampleSize); + rowsPerPage$.next(lastSaved?.rowsPerPage); + rowHeight$.next(lastSaved?.rowHeight); + headerRowHeight$.next(lastSaved?.headerRowHeight); + savedSearchViewMode$.next(lastSaved?.viewMode); + density$.next(lastSaved?.density); }, }; }; diff --git a/src/platform/plugins/shared/discover/public/embeddable/types.ts b/src/platform/plugins/shared/discover/public/embeddable/types.ts index 25fc6bda48c7f..8c0827a8a81a8 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/types.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/types.ts @@ -98,10 +98,7 @@ export type SearchEmbeddableRuntimeState = SearchEmbeddableSerializedAttributes nonPersistedDisplayOptions?: NonPersistedDisplayOptions; }; -export type SearchEmbeddableApi = DefaultEmbeddableApi< - SearchEmbeddableSerializedState, - SearchEmbeddableRuntimeState -> & +export type SearchEmbeddableApi = DefaultEmbeddableApi & PublishesSavedObjectId & PublishesDataLoading & PublishesBlockingError & diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts index 3cb19ea0a8779..819f051b0cf2d 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.ts @@ -37,7 +37,7 @@ export const deserializeState = async ({ }: { serializedState: SerializedPanelState; discoverServices: DiscoverServices; -}) => { +}): Promise => { const panelState = pick(serializedState.rawState, EDITABLE_PANEL_KEYS); const savedObjectId = serializedState.rawState.savedObjectId; if (savedObjectId) { diff --git a/src/platform/plugins/shared/discover/public/plugin.tsx b/src/platform/plugins/shared/discover/public/plugin.tsx index 54b9424d44d6e..e6c47a565e6bb 100644 --- a/src/platform/plugins/shared/discover/public/plugin.tsx +++ b/src/platform/plugins/shared/discover/public/plugin.tsx @@ -397,10 +397,14 @@ export class DiscoverPlugin }; plugins.embeddable.registerAddFromLibraryType({ - onAdd: async (...params) => { - const services = await getDiscoverServicesForEmbeddable(); - const { getOnAddSearchEmbeddable } = await getEmbeddableServices(); - return getOnAddSearchEmbeddable(services)(...params); + onAdd: async (container, savedObject) => { + container.addNewPanel({ + panelType: SEARCH_EMBEDDABLE_TYPE, + serializedState: { + rawState: { savedObjectId: savedObject.id }, + references: savedObject.references, + }, + }); }, savedObjectType: SavedSearchType, savedObjectName: i18n.translate('discover.savedSearch.savedObjectName', { diff --git a/src/platform/plugins/shared/discover/public/plugin_imports/embeddable_services.ts b/src/platform/plugins/shared/discover/public/plugin_imports/embeddable_services.ts index a6b9bd0b43baa..7d9cff7eb303c 100644 --- a/src/platform/plugins/shared/discover/public/plugin_imports/embeddable_services.ts +++ b/src/platform/plugins/shared/discover/public/plugin_imports/embeddable_services.ts @@ -8,5 +8,4 @@ */ export { ViewSavedSearchAction } from '../embeddable/actions/view_saved_search_action'; -export { getOnAddSearchEmbeddable } from '../embeddable/get_on_add_search_embeddable'; export { getSearchEmbeddableFactory } from '../embeddable/get_search_embeddable_factory'; diff --git a/src/platform/plugins/shared/embeddable/README.md b/src/platform/plugins/shared/embeddable/README.md index 4e0deea10830c..b973b4134c5a5 100644 --- a/src/platform/plugins/shared/embeddable/README.md +++ b/src/platform/plugins/shared/embeddable/README.md @@ -23,13 +23,6 @@ An embeddable API shares state via a publishing subject, a read only RxJS Observ For example, [publishes_panel_title](https://github.com/elastic/kibana/tree/main/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/publishes_panel_title.ts) publishing package defines interfaces and type guards for title state. [initializeTitles](https://github.com/elastic/kibana/tree/main/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/titles_api.ts) provides an implemenation for the titles publishing package. `panelTitle` is provided as a publishing subject. [PresentationPanelInternal React component](https://github.com/elastic/kibana/tree/main/src/platform/plugins/private/presentation_panel/public/panel_component/presentation_panel_internal.tsx) uses a hook to consume `panelTitle` as React state. Changes to `panelTitle` publishing subject updates React state, which in turn, causes the UI to re-render with the current value. [CustomizePanelEditor React component](https://github.com/elastic/kibana/tree/main/src/platform/plugins/private/presentation_panel/public/panel_actions/customize_panel_action/customize_panel_editor.tsx) uses `api.setPanelTitle` to set the title on save. -#### Comparators -Comparators allow a page to track changes to an embeddable's state. For example, Dashboard uses comparators to display a UI notification for unsaved changes, to reset changes, and persist unsaved changes to session storage. - -A comparator must be provided for each property in an embeddable's RuntimeState. A comparator is a 3 element tuple: where the first element is a publishing subject providing the current value. The second element is a setter allowing the page to reset the value. The third element is an optional comparator function which provides logic to diff this property. - -For example, [initializeTitles](https://github.com/elastic/kibana/tree/main/src/platform/packages/shared/presentation/presentation_publishing/interfaces/titles/titles_api.ts) provides an implemenation for the titles publishing package. Comparitors are provided for each property from `SerializedTitles`. - ### Best practices #### Do not use Embeddables to share Components between plugins diff --git a/src/platform/plugins/shared/embeddable/public/index.ts b/src/platform/plugins/shared/embeddable/public/index.ts index 5f2b435411de1..1d8329b268c52 100644 --- a/src/platform/plugins/shared/embeddable/public/index.ts +++ b/src/platform/plugins/shared/embeddable/public/index.ts @@ -43,9 +43,9 @@ export type { EmbeddableSetup, EmbeddableStart } from './types'; export type { EnhancementRegistryDefinition } from './enhancements/types'; export { - ReactEmbeddableRenderer, + EmbeddableRenderer, type DefaultEmbeddableApi, - type ReactEmbeddableFactory, + type EmbeddableFactory, } from './react_embeddable_system'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/index.ts b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/index.ts index 6e7b6b468c6ab..8c569df3b4f3e 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/index.ts +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/index.ts @@ -10,5 +10,5 @@ export { PanelIncompatibleError } from './panel_incompatible_error'; export { PanelNotFoundError } from './panel_not_found_error'; export { registerReactEmbeddableFactory } from './react_embeddable_registry'; -export { ReactEmbeddableRenderer } from './react_embeddable_renderer'; -export type { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types'; +export { EmbeddableRenderer } from './react_embeddable_renderer'; +export type { DefaultEmbeddableApi, EmbeddableFactory } from './types'; diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx index 43f8541fbab7f..021a42a6db518 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx @@ -11,15 +11,14 @@ import { registerReactEmbeddableFactory, getReactEmbeddableFactory, } from './react_embeddable_registry'; -import { ReactEmbeddableFactory } from './types'; +import { EmbeddableFactory } from './types'; -describe('react embeddable registry', () => { +describe('embeddable registry', () => { const getTestEmbeddableFactory = () => Promise.resolve({ type: 'test', - deserializeState: jest.fn(), buildEmbeddable: jest.fn(), - } as ReactEmbeddableFactory); + } as EmbeddableFactory); it('throws an error if requested embeddable factory type is not registered', () => { expect(() => getReactEmbeddableFactory('notRegistered')).rejects.toThrow( diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.ts b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.ts index 046b63d5531ab..8d269532db311 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.ts +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_registry.ts @@ -8,9 +8,9 @@ */ import { i18n } from '@kbn/i18n'; -import { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types'; +import { DefaultEmbeddableApi, EmbeddableFactory } from './types'; -const registry: { [key: string]: () => Promise> } = {}; +const registry: { [key: string]: () => Promise> } = {}; /** * Registers a new React embeddable factory. This should be called at plugin start time. @@ -21,14 +21,10 @@ const registry: { [key: string]: () => Promise = DefaultEmbeddableApi< - SerializedState, - RuntimeState - > + Api extends DefaultEmbeddableApi = DefaultEmbeddableApi >( type: string, - getFactory: () => Promise> + getFactory: () => Promise> ) => { if (registry[type] !== undefined) throw new Error( @@ -42,14 +38,10 @@ export const registerReactEmbeddableFactory = < export const getReactEmbeddableFactory = async < SerializedState extends object = object, - RuntimeState extends object = SerializedState, - Api extends DefaultEmbeddableApi = DefaultEmbeddableApi< - SerializedState, - RuntimeState - > + Api extends DefaultEmbeddableApi = DefaultEmbeddableApi >( key: string -): Promise> => { +): Promise> => { if (registry[key] === undefined) throw new Error( i18n.translate('embeddableApi.reactEmbeddable.factoryNotFoundError', { 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 6cdf49249e9a9..1663f55f59432 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 @@ -14,31 +14,25 @@ import { render, waitFor, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { registerReactEmbeddableFactory } from './react_embeddable_registry'; -import { ReactEmbeddableRenderer } from './react_embeddable_renderer'; -import { ReactEmbeddableFactory } from './types'; +import { EmbeddableRenderer } from './react_embeddable_renderer'; +import { EmbeddableFactory } from './types'; -const testEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { +const testEmbeddableFactory: EmbeddableFactory<{ name: string; bork: string }> = { type: 'test', - deserializeState: jest.fn().mockImplementation((state) => state.rawState), - buildEmbeddable: async (state, registerApi) => { - const api = registerApi( - { - serializeState: () => ({ - rawState: { - name: state.name, - bork: state.bork, - }, - }), - }, - { - name: [new BehaviorSubject(state.name), () => {}], - bork: [new BehaviorSubject(state.bork), () => {}], - } - ); + buildEmbeddable: async ({ initialState, finalizeApi }) => { + const api = finalizeApi({ + serializeState: () => ({ + rawState: { + name: initialState.rawState.name, + bork: initialState.rawState.bork, + }, + }), + }); return { Component: () => (
- SUPER TEST COMPONENT, name: {state.name} bork: {state.bork} + SUPER TEST COMPONENT, name: {initialState.rawState.name} bork:{' '} + {initialState.rawState.bork}
), api, @@ -46,7 +40,7 @@ const testEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }, }; -describe('react embeddable renderer', () => { +describe('embeddable renderer', () => { const getTestEmbeddableFactory = async () => { return testEmbeddableFactory; }; @@ -56,30 +50,10 @@ describe('react embeddable renderer', () => { setupPresentationPanelServices(); }); - it('deserializes unsaved state provided by the parent', async () => { - render( - ({ - getSerializedStateForChild: () => ({ - rawState: { - bork: 'blorp?', - }, - }), - })} - /> - ); - await waitFor(() => { - expect(testEmbeddableFactory.deserializeState).toHaveBeenCalledWith({ - rawState: { bork: 'blorp?' }, - }); - }); - }); - it('builds the embeddable', async () => { const buildEmbeddableSpy = jest.spyOn(testEmbeddableFactory, 'buildEmbeddable'); render( - ({ getSerializedStateForChild: () => ({ @@ -91,21 +65,19 @@ describe('react embeddable renderer', () => { /> ); await waitFor(() => { - expect(buildEmbeddableSpy).toHaveBeenCalledWith( - { bork: 'blorp?' }, - expect.any(Function), - expect.any(String), - expect.any(Object), - expect.any(Function), - { bork: 'blorp?' } - ); + expect(buildEmbeddableSpy).toHaveBeenCalledWith({ + initialState: { rawState: { bork: 'blorp?' } }, + parentApi: expect.any(Object), + uuid: expect.any(String), + finalizeApi: expect.any(Function), + }); }); }); it('builds the embeddable, providing an id', async () => { const buildEmbeddableSpy = jest.spyOn(testEmbeddableFactory, 'buildEmbeddable'); render( - ({ @@ -118,14 +90,12 @@ describe('react embeddable renderer', () => { /> ); await waitFor(() => { - expect(buildEmbeddableSpy).toHaveBeenCalledWith( - { bork: 'blorp?' }, - expect.any(Function), - '12345', - expect.any(Object), - expect.any(Function), - { bork: 'blorp?' } - ); + expect(buildEmbeddableSpy).toHaveBeenCalledWith({ + initialState: { rawState: { bork: 'blorp?' } }, + parentApi: expect.any(Object), + uuid: '12345', + finalizeApi: expect.any(Function), + }); }); }); @@ -139,22 +109,20 @@ describe('react embeddable renderer', () => { }, }), }; - render( parentApi} />); + render( parentApi} />); await waitFor(() => { - expect(buildEmbeddableSpy).toHaveBeenCalledWith( - { bork: 'blorp?' }, - expect.any(Function), - expect.any(String), + expect(buildEmbeddableSpy).toHaveBeenCalledWith({ + initialState: { rawState: { bork: 'blorp?' } }, parentApi, - expect.any(Function), - { bork: 'blorp?' } - ); + uuid: expect.any(String), + finalizeApi: expect.any(Function), + }); }); }); it('renders the given component once it resolves', async () => { render( - ({ getSerializedStateForChild: () => ({ @@ -173,7 +141,7 @@ describe('react embeddable renderer', () => { it('publishes the API into the provided callback', async () => { const onApiAvailable = jest.fn(); render( - { type: 'test', uuid: '12345', parentApi: expect.any(Object), - unsavedChanges$: expect.any(Object), serializeState: expect.any(Function), - resetUnsavedChanges: expect.any(Function), - snapshotRuntimeState: expect.any(Function), phase$: expect.any(Object), hasLockedHoverActions$: expect.any(Object), lockHoverActions: expect.any(Function), @@ -203,7 +168,7 @@ describe('react embeddable renderer', () => { it('initializes a new ID when one is not given', async () => { const onApiAvailable = jest.fn(); render( - ({ @@ -220,25 +185,24 @@ describe('react embeddable renderer', () => { ); }); - it('catches error when thrown in deserialize', async () => { + it('catches error when thrown in buildEmbeddable', async () => { const buildEmbeddable = jest.fn(); - const errorInInitializeFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { + const errorInInitializeFactory: EmbeddableFactory<{ name: string; bork: string }> = { ...testEmbeddableFactory, - type: 'errorInDeserialize', - buildEmbeddable, - deserializeState: (state) => { - throw new Error('error in deserialize'); + type: 'errorInBuildEmbeddable', + buildEmbeddable: () => { + throw new Error('error in buildEmbeddable'); }, }; - registerReactEmbeddableFactory('errorInDeserialize', () => + registerReactEmbeddableFactory('errorInBuildEmbeddable', () => Promise.resolve(errorInInitializeFactory) ); setupPresentationPanelServices(); const onApiAvailable = jest.fn(); const embeddable = render( - ({ @@ -253,14 +217,14 @@ describe('react embeddable renderer', () => { expect(onApiAvailable).not.toBeCalled(); expect(buildEmbeddable).not.toBeCalled(); expect(embeddable.getByTestId('errorMessageMarkdown')).toHaveTextContent( - 'error in deserialize' + 'error in buildEmbeddable' ); }); }); describe('reactEmbeddable phase events', () => { it('publishes rendered phase immediately when dataLoading is not defined', async () => { - const immediateLoadEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { + const immediateLoadEmbeddableFactory: EmbeddableFactory<{ name: string; bork: string }> = { ...testEmbeddableFactory, type: 'immediateLoad', }; @@ -271,7 +235,7 @@ describe('reactEmbeddable phase events', () => { const renderedEvent = jest.fn(); render( - { @@ -292,31 +256,26 @@ describe('reactEmbeddable phase events', () => { }); it('publishes rendered phase event when dataLoading is complete', async () => { - const dataLoadingEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { + const dataLoadingEmbeddableFactory: EmbeddableFactory<{ name: string; bork: string }> = { ...testEmbeddableFactory, type: 'loadClicker', - buildEmbeddable: async (state, registerApi) => { + buildEmbeddable: async ({ initialState, finalizeApi }) => { const dataLoading$ = new BehaviorSubject(true); - const api = registerApi( - { - serializeState: () => ({ - rawState: { - name: state.name, - bork: state.bork, - }, - }), - dataLoading$, - }, - { - name: [new BehaviorSubject(state.name), () => {}], - bork: [new BehaviorSubject(state.bork), () => {}], - } - ); + const api = finalizeApi({ + serializeState: () => ({ + rawState: { + name: initialState.rawState.name, + bork: initialState.rawState.bork, + }, + }), + dataLoading$, + }); return { Component: () => ( <>
- SUPER TEST COMPONENT, name: {state.name} bork: {state.bork} + SUPER TEST COMPONENT, name: {initialState.rawState.name} bork:{' '} + {initialState.rawState.bork}