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/render_examples.tsx b/examples/embeddable_examples/public/app/render_examples.tsx index 8dcf6a128d4cd..604c392e46de4 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 = () => { @@ -80,7 +80,7 @@ export const RenderExamples = () => { {` - 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); 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 7318020d4f1e2..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 @@ -21,13 +21,13 @@ import { import React, { useEffect } from 'react'; import { BehaviorSubject, switchMap, tap } from 'rxjs'; import { initializeUnsavedChanges } from '@kbn/presentation-containers'; -import { SEARCH_EMBEDDABLE_ID } from './constants'; +import { SEARCH_EMBEDDABLE_TYPE } from './constants'; import { getCount } from './get_count'; import { SearchApi, Services, SearchSerializedState } from './types'; export const getSearchEmbeddableFactory = (services: Services) => { const factory: EmbeddableFactory = { - type: SEARCH_EMBEDDABLE_ID, + type: SEARCH_EMBEDDABLE_TYPE, buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { const timeRangeManager = initializeTimeRangeManager(initialState.rawState); const defaultDataView = await services.dataViews.getDefaultDataView(); 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 f34929ae376e6..e4039f32bb5e8 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 @@ -356,7 +356,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/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/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/unsaved_changes/initialize_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts index 8d13702ca65d5..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 @@ -19,27 +19,30 @@ import { apiHasLastSavedChildState } from '../last_saved_child_state'; const UNSAVED_CHANGES_DEBOUNCE = 100; -export const initializeUnsavedChanges = ({ +export const initializeUnsavedChanges = ({ uuid, onReset, parentApi, getComparators, + defaultState, serializeState, anyStateChange$, }: { uuid: string; parentApi: unknown; anyStateChange$: Observable; - serializeState: () => SerializedPanelState; - getComparators: () => StateComparators; - onReset: (lastSavedPanelState?: SerializedPanelState) => MaybePromise; + serializeState: () => SerializedPanelState; + getComparators: () => StateComparators; + defaultState?: Partial; + onReset: (lastSavedPanelState?: SerializedPanelState) => MaybePromise; }): PublishesUnsavedChanges => { - if (!apiHasLastSavedChildState(parentApi)) { + if (!apiHasLastSavedChildState(parentApi)) { return { hasUnsavedChanges$: of(false), resetUnsavedChanges: () => Promise.resolve(), }; } + const hasUnsavedChanges$ = anyStateChange$.pipe( combineLatestWith( parentApi.lastSavedStateForChild$(uuid).pipe(map((panelState) => panelState?.rawState)) @@ -47,7 +50,7 @@ export const initializeUnsavedChanges = { const currentState = serializeState().rawState; - return !areComparatorsEqual(getComparators(), lastSavedState, currentState); + return !areComparatorsEqual(getComparators(), lastSavedState, currentState, defaultState); }) ); diff --git a/src/platform/packages/shared/presentation/presentation_publishing/index.ts b/src/platform/packages/shared/presentation/presentation_publishing/index.ts index d4230e4ae03e0..9411865b8e76b 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/index.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/index.ts @@ -75,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 { 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/state_manager/state_comparators.ts b/src/platform/packages/shared/presentation/presentation_publishing/state_manager/state_comparators.ts index fd1904fba1932..76507e82003b9 100644 --- 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 @@ -56,12 +56,13 @@ export const diffComparators = ( export const areComparatorsEqual = ( comparators: StateComparators, lastSavedState?: StateType, - currentState?: 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]; - const currentValue = currentState?.[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]; const areEqual = runComparator( comparator, 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.tsx b/src/platform/plugins/shared/controls/public/control_group/control_group_renderer/control_group_renderer.tsx index c07501ed0dd4c..4a0e0e5debe6b 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 @@ -8,13 +8,13 @@ */ 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 { SerializedPanelState, useSearchApi, type ViewMode } from '@kbn/presentation-publishing'; import type { ControlGroupApi } from '../..'; import { @@ -24,13 +24,35 @@ import { type ControlGroupSerializedState, DEFAULT_CONTROL_CHAINING, DEFAULT_AUTO_APPLY_SELECTIONS, + DEFAULT_IGNORE_PARENT_SETTINGS, } 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'; + +const defaultRuntimeState = { + labelPosition: DEFAULT_CONTROL_LABEL_POSITION, + chainingSystem: DEFAULT_CONTROL_CHAINING, + autoApplySelections: DEFAULT_AUTO_APPLY_SELECTIONS, + ignoreParentSettings: DEFAULT_IGNORE_PARENT_SETTINGS, +}; + +function serializeState( + runtimeState: Partial +): SerializedPanelState { + return { + rawState: { + ...defaultRuntimeState, + ...omit(runtimeState, ['initialChildControlState']), + controls: Object.entries(runtimeState?.initialChildControlState ?? {}).map( + ([controlId, value]) => ({ ...value, id: controlId }) + ), + }, + }; +} export interface ControlGroupRendererProps { onApiAvailable: (api: ControlGroupRendererApi) => void; @@ -56,8 +78,9 @@ export const ControlGroupRenderer = ({ dataLoading, compressed, }: ControlGroupRendererProps) => { + const lastState$Ref = useRef(new BehaviorSubject(serializeState(defaultRuntimeState))); const id = useMemo(() => uuidv4(), []); - const [regenerateId, setRegenerateId] = useState(uuidv4()); + const [isStateLoaded, setIsStateLoaded] = useState(false); const [controlGroup, setControlGroup] = useState(); /** @@ -91,69 +114,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(serializeState(initialRuntimeState)); + setIsStateLoaded(true); + }) + .catch(); + return () => { cancelled = true; }; @@ -161,9 +154,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,23 +165,26 @@ 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={(controlGroupApi) => { const controlGroupRendererApi: ControlGroupRendererApi = { ...controlGroupApi, reload: () => reload$.next(), - updateInput: (newInput) => { - updateInput(newInput); - setRegenerateId(uuidv4()); // force remount + updateInput: (newInput: Partial) => { + lastState$Ref.current.next( + serializeState({ + ...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 ef2c83d39a3e1..239b72bb781af 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,20 @@ export const getControlGroupEmbeddableFactory = () => { controlFetch$( chaining$( controlUuid, - chainingSystem$, + editorStateManager.api.chainingSystem$, controlsManager.controlsInOrder$, controlsManager.api.children$ ), - controlGroupFetch$(ignoreParentSettings$, parentApi ? parentApi : {}) + controlGroupFetch$( + editorStateManager.api.ignoreParentSettings$, + parentApi ? parentApi : {} + ) ), - 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 +119,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 +144,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 +166,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 +197,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 d53a67908b150..059f376893f75 100644 --- a/src/platform/plugins/shared/controls/public/control_group/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/types.ts @@ -12,7 +12,11 @@ import type { Observable } from 'rxjs'; import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import { PublishesESQLVariables } from '@kbn/esql-types'; import { Filter } from '@kbn/es-query'; -import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers'; +import { + HasLastSavedChildState, + HasSerializedChildState, + PresentationContainer, +} from '@kbn/presentation-containers'; import { HasEditCapabilities, HasParentApi, @@ -51,16 +55,15 @@ export type ControlGroupApi = PresentationContainer & PublishesESQLVariables & HasSerializedChildState & HasEditCapabilities & - Pick, 'unsavedChanges$'> & + HasLastSavedChildState & PublishesTimeslice & PublishesDisabledActionIds & + PublishesUnsavedChanges & Partial & PublishesReload> & { allowExpensiveQueries$: PublishingSubject; autoApplySelections$: PublishingSubject; ignoreParentSettings$: PublishingSubject; labelPosition: PublishingSubject; - - asyncResetUnsavedChanges: () => Promise; controlFetch$: (controlUuid: string) => Observable; openAddDataControlFlyout: (options?: { controlStateTransform?: ControlStateTransform; @@ -70,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/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 75ac6b1d9e039..140eab5dd17a0 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,89 @@ 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, + }, + 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 +357,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 +370,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 +393,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 +442,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 cf845a9adef11..a60a1340a3006 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,35 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ import deepEqual from 'react-fast-compare'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest, map, merge } from 'rxjs'; import { ESQLVariableType, type ESQLControlVariable } from '@kbn/esql-types'; import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; -import type { ESQLControlState } from '@kbn/esql/public'; +import type { ESQLControlState, EsqlControlType } from '@kbn/esql/public'; + +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 ?? []); @@ -20,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); @@ -52,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 a63ba69c823e4..fc2489be8c072 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/public'; -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'], @@ -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 8cc864f1d37fc..cb0c17375c89a 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,19 +9,22 @@ 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 } 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 type { ESQLControlState } from '@kbn/esql/public'; 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', @@ -33,7 +36,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); @@ -45,63 +48,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 { @@ -112,8 +125,8 @@ export const getESQLControlFactory = (): ControlFactory { const [availableOptions, selectedOptions] = useBatchedPublishingSubjects( - selections.availableOptions$, - selections.selectedOptions$ + selections.internalApi.availableOptions$, + selections.internalApi.selectedOptions$ ); return ( @@ -141,7 +154,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/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/get_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts index 2552dee584a09..c24426c380019 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,7 +8,6 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; import { BehaviorSubject, debounceTime, merge } from 'rxjs'; import { v4 } from 'uuid'; @@ -40,6 +39,10 @@ import { 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, @@ -54,7 +57,6 @@ export function getDashboardApi({ 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); @@ -65,7 +67,11 @@ export function getDashboardApi({ }); 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 @@ -76,17 +82,21 @@ export function getDashboardApi({ incomingEmbeddable, initialState.panels, trackPanel, - getPanelReferences + 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(), @@ -95,13 +105,13 @@ export function getDashboardApi({ const unsavedChangesManager = initializeUnsavedChangesManager({ viewModeManager, creationOptions, - controlGroupApi$, + controlGroupManager, lastSavedState: savedObjectResult?.dashboardInput ?? DEFAULT_DASHBOARD_STATE, panelsManager, savedObjectId$, settingsManager, unifiedSearchManager, - getPanelReferences, + getReferences, }); function getState() { @@ -115,14 +125,9 @@ export function getDashboardApi({ 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, @@ -145,7 +150,7 @@ export function getDashboardApi({ ...unsavedChangesManager.api, ...trackOverlayApi, ...initializeTrackContentfulRender(), - controlGroupApi$, + ...controlGroupManager.api, executionContext: { type: 'dashboard', description: settingsManager.api.title$.value, @@ -212,6 +217,11 @@ export function getDashboardApi({ }, 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(), @@ -231,28 +241,7 @@ export function getDashboardApi({ internalApi: { ...panelsManager.internalApi, ...unifiedSearchManager.internalApi, - getStateForControlGroup: () => { - 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 ?? []), - }; - }, - setControlGroupApi: (controlGroupApi: ControlGroupApi) => - controlGroupApi$.next(controlGroupApi), + setControlGroupApi: controlGroupManager.internalApi.setControlGroupApi, } as DashboardInternalApi, cleanup: () => { dataLoadingManager.cleanup(); 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 7bb2f9d648467..6a37bfdcbbdbc 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 @@ -55,19 +55,20 @@ export function initializePanelsManager( incomingEmbeddable: EmbeddablePackageState | undefined, 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[] + 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 & HasSerializedChildState; + api: PresentationContainer & CanDuplicatePanels; } { // -------------------------------------------------------------------------------------- // Set up panel state manager @@ -85,7 +86,7 @@ export function initializePanelsManager( layout[uuid] = { type, gridData }; childState[uuid] = { rawState: explicitInput, - references: getReferencesForPanelId(uuid), + references: getReferences(uuid), }; }); return { layout, childState }; @@ -307,6 +308,7 @@ export function initializePanelsManager( return { internalApi: { + getSerializedStateForPanel: (uuid: string) => currentChildState[uuid], layout$, resetPanels, serializePanels, @@ -343,7 +345,6 @@ export function initializePanelsManager( replacePanel, duplicatePanel, getPanelCount: () => Object.keys(layout$.value).length, - getSerializedStateForChild: (uuid: string) => currentChildState[uuid], canRemovePanels: () => trackPanel.expandedPanelId$.value === undefined, }, }; 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 2e849fc97091b..c5f56e249148d 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts @@ -223,7 +223,6 @@ export interface DashboardInternalApi { controlGroupReload$: Subject; panelsReload$: Subject; layout$: BehaviorSubject; - getStateForControlGroup: () => SerializedPanelState; registerChildApi: (api: DefaultEmbeddableApi) => void; setControlGroupApi: (controlGroupApi: ControlGroupApi) => void; } 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 ed6f6da4dcd3c..f9057bd64d7c8 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 @@ -8,7 +8,6 @@ */ import type { Reference } from '@kbn/content-management-utils'; -import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { HasLastSavedChildState, childrenUnsavedChanges$ } from '@kbn/presentation-containers'; import { PublishesSavedObjectId, @@ -16,13 +15,26 @@ import { apiHasSerializableState, } from '@kbn/presentation-publishing'; import { omit } from 'lodash'; -import { BehaviorSubject, Observable, combineLatest, debounceTime, map, of, tap } from 'rxjs'; +import { + 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, DashboardState } from './types'; 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; @@ -33,15 +45,15 @@ export function initializeUnsavedChangesManager({ settingsManager, viewModeManager, creationOptions, - controlGroupApi$, - getPanelReferences, + controlGroupManager, + getReferences, unifiedSearchManager, }: { lastSavedState: DashboardState; creationOptions?: DashboardCreationOptions; - getPanelReferences: (id: string) => Reference[]; + getReferences: (id: string) => Reference[]; savedObjectId$: PublishesSavedObjectId['savedObjectId$']; - controlGroupApi$: PublishingSubject; + controlGroupManager: ReturnType; panelsManager: ReturnType; viewModeManager: ReturnType; settingsManager: ReturnType; @@ -89,7 +101,12 @@ export function initializeUnsavedChangesManager({ viewModeManager.api.viewMode$, dashboardStateChanges$, hasPanelChanges$, - of(false), // SERIALIZED STATE ONLY TODO reinstate Dashboard diff checking of Controls state + controlGroupManager.api.controlGroupApi$.pipe( + skipWhile((controlGroupApi) => !controlGroupApi), + switchMap((controlGroupApi) => { + return controlGroupApi!.hasUnsavedChanges$; + }) + ), ]) .pipe(debounceTime(DEBOUNCE_TIME)) .subscribe(([viewMode, dashboardChanges, hasPanelChanges, hasControlGroupChanges]) => { @@ -111,23 +128,38 @@ export function initializeUnsavedChangesManager({ dashboardStateToBackup.viewMode = viewMode; // Backup latest state from children that have unsaved changes - if (hasPanelChanges) { + if (hasPanelChanges || hasControlGroupChanges) { const { panels, references } = panelsManager.internalApi.serializePanels(); - dashboardStateToBackup.panels = panels; - dashboardStateToBackup.references = references; + 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; } - // SERIALIZED STATE ONLY TODO back up controls state. getDashboardBackupService().setState(savedObjectId$.value, dashboardStateToBackup); } }); - const getLastSavedStateForChild = (panelId: string) => { + const getLastSavedStateForChild = (childId: string) => { const lastSavedDashboardState = lastSavedState$.value; - if (!lastSavedDashboardState.panels[panelId]) return; + + 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[panelId].explicitInput, - references: getPanelReferences(panelId), + rawState: lastSavedDashboardState.panels[childId].explicitInput, + references: getReferences(childId), }; }; @@ -138,8 +170,7 @@ export function initializeUnsavedChangesManager({ unifiedSearchManager.internalApi.reset(lastSavedState$.value); settingsManager.internalApi.reset(lastSavedState$.value); - // SERIALIZED STATE ONLY TODO: Remove asyncResetUnsavedChanges because the reset function can now be async. - await controlGroupApi$.value?.asyncResetUnsavedChanges(); + await controlGroupManager.api.controlGroupApi$.value?.resetUnsavedChanges(); }, hasUnsavedChanges$, lastSavedStateForChild$: (panelId: string) => 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 1f5e7e670f0dd..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 @@ -14,13 +14,14 @@ import { EuiPortal } from '@elastic/eui'; import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen'; -import { ControlGroupApi, 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, @@ -102,17 +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.getStateForControlGroup, }; }} onApiAvailable={(api) => dashboardInternalApi.setControlGroupApi(api)} 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..26ba574cd7a16 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 @@ -90,11 +90,6 @@ describe('saved search embeddable', () => { type: factory.type, parentApi: mockedDashboardApi, phase$: new BehaviorSubject(undefined), - resetUnsavedChanges: jest.fn(), - snapshotRuntimeState: jest.fn(), - unsavedChanges: new BehaviorSubject | undefined>( - undefined - ), }); const getInitialRuntimeState = ({ diff --git a/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx b/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx index bbf0c4e85991e..1536070a1b07d 100644 --- a/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/platform/plugins/shared/visualizations/public/embeddable/visualize_embeddable.tsx @@ -62,18 +62,21 @@ export const getVisualizeEmbeddableFactory: (deps: { }) => ({ type: VISUALIZE_EMBEDDABLE_TYPE, buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => { - const runtimeState = await deserializeState(initialState); + // Runtime state may contain title loaded from saved object + // Initialize titleManager with serialized state + // to avoid tracking runtime state title as serialized state title + const titleManager = initializeTitleManager(initialState.rawState); // Initialize dynamic actions const dynamicActionsManager = embeddableEnhancedStart?.initializeEmbeddableDynamicActions( uuid, () => titleManager.api.title$.getValue(), - runtimeState + initialState.rawState ); // if it is provided, start the dynamic actions manager const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions(); - const titleManager = initializeTitleManager(runtimeState); + const runtimeState = await deserializeState(initialState); // Count renders; mostly used for testing. const renderCount$ = new BehaviorSubject(0); diff --git a/x-pack/platform/plugins/private/canvas/public/components/hooks/use_canvas_api.tsx b/x-pack/platform/plugins/private/canvas/public/components/hooks/use_canvas_api.tsx index 5706dd45b915a..a399c289cf621 100644 --- a/x-pack/platform/plugins/private/canvas/public/components/hooks/use_canvas_api.tsx +++ b/x-pack/platform/plugins/private/canvas/public/components/hooks/use_canvas_api.tsx @@ -7,7 +7,7 @@ import { useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject, Subject, of } from 'rxjs'; import { SerializedPanelState, ViewMode } from '@kbn/presentation-publishing'; @@ -73,7 +73,7 @@ export const useCanvasApi: () => CanvasContainerApi = () => { canEditInline: false, type: 'canvas', getSerializedStateForChild, - lastSavedStateForChild$: (childId: string) => panelStateMap[childId], + lastSavedStateForChild$: (childId: string) => panelStateMap[childId] ?? of(undefined), // Canvas auto saves so lastSavedState is the same as currentState getLastSavedStateForChild: getSerializedStateForChild, setSerializedStateForChild: ( diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx b/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx index 2866879ea455a..bacd97ca2a623 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/mocks/index.tsx @@ -74,7 +74,6 @@ function getDefaultLensApiMock() { /** New embeddable api inherited methods */ resetUnsavedChanges: jest.fn(), serializeState: jest.fn(), - snapshotRuntimeState: jest.fn(), saveToLibrary: jest.fn(async () => 'saved-id'), onEdit: jest.fn(), isEditingEnabled: jest.fn(() => true),