Control group state diffing (#189128)

<img width="800" alt="Screenshot 2024-07-29 at 3 48 24 PM"
src="https://github.com/user-attachments/assets/d1196ed3-f590-4415-8c32-8f39cc64a2a8">

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-08-02 08:23:12 -06:00 committed by GitHub
parent 718f6c3d08
commit cf1222f881
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 934 additions and 581 deletions

View file

@ -19,11 +19,10 @@ import {
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { ControlsExampleStartDeps } from '../plugin';
import { ControlGroupRendererExamples } from './control_group_renderer_examples';
import { ReactControlExample } from './react_control_example';
import { ReactControlExample } from './react_control_example/react_control_example';
const CONTROLS_AS_A_BUILDING_BLOCK = 'controls_as_a_building_block';
const CONTROLS_REFACTOR_TEST = 'controls_refactor_test';

View file

@ -7,10 +7,12 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiButtonGroup,
EuiCallOut,
EuiCodeBlock,
@ -18,6 +20,7 @@ import {
EuiFlexItem,
EuiSpacer,
EuiSuperDatePicker,
EuiToolTip,
OnTimeChangeProps,
} from '@elastic/eui';
import {
@ -39,12 +42,19 @@ import {
} from '@kbn/presentation-publishing';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { ControlGroupApi } from '../react_controls/control_group/types';
import { OPTIONS_LIST_CONTROL_TYPE } from '../react_controls/data_controls/options_list_control/constants';
import { RANGE_SLIDER_CONTROL_TYPE } from '../react_controls/data_controls/range_slider/types';
import { SEARCH_CONTROL_TYPE } from '../react_controls/data_controls/search_control/types';
import { TIMESLIDER_CONTROL_TYPE } from '../react_controls/timeslider_control/types';
import { openDataControlEditor } from '../react_controls/data_controls/open_data_control_editor';
import {
clearControlGroupSerializedState,
getControlGroupSerializedState,
setControlGroupSerializedState,
WEB_LOGS_DATA_VIEW_ID,
} from './serialized_control_group_state';
import {
clearControlGroupRuntimeState,
getControlGroupRuntimeState,
setControlGroupRuntimeState,
} from './runtime_control_group_state';
import { ControlGroupApi } from '../../react_controls/control_group/types';
import { openDataControlEditor } from '../../react_controls/data_controls/open_data_control_editor';
const toggleViewButtons = [
{
@ -59,67 +69,6 @@ const toggleViewButtons = [
},
];
const optionsListId = 'optionsList1';
const searchControlId = 'searchControl1';
const rangeSliderControlId = 'rangeSliderControl1';
const timesliderControlId = 'timesliderControl1';
const controlGroupPanels = {
[searchControlId]: {
type: SEARCH_CONTROL_TYPE,
order: 3,
grow: true,
width: 'medium',
explicitInput: {
id: searchControlId,
fieldName: 'message',
title: 'Message',
grow: true,
width: 'medium',
enhancements: {},
},
},
[rangeSliderControlId]: {
type: RANGE_SLIDER_CONTROL_TYPE,
order: 1,
grow: true,
width: 'medium',
explicitInput: {
id: rangeSliderControlId,
fieldName: 'bytes',
title: 'Bytes',
grow: true,
width: 'medium',
enhancements: {},
},
},
[timesliderControlId]: {
type: TIMESLIDER_CONTROL_TYPE,
order: 4,
grow: true,
width: 'medium',
explicitInput: {
id: timesliderControlId,
enhancements: {},
},
},
[optionsListId]: {
type: OPTIONS_LIST_CONTROL_TYPE,
order: 2,
grow: true,
width: 'medium',
explicitInput: {
id: searchControlId,
fieldName: 'agent.keyword',
title: 'Agent',
grow: true,
width: 'medium',
enhancements: {},
},
},
};
const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247';
export const ReactControlExample = ({
core,
dataViews: dataViewsService,
@ -151,6 +100,9 @@ export const ReactControlExample = ({
const viewMode$ = useMemo(() => {
return new BehaviorSubject<ViewModeType>(ViewMode.EDIT as ViewModeType);
}, []);
const saveNotification$ = useMemo(() => {
return new Subject<void>();
}, []);
const [dataLoading, timeRange, viewMode] = useBatchedPublishingSubjects(
dataLoading$,
timeRange$,
@ -188,6 +140,7 @@ export const ReactControlExample = ({
return Promise.resolve(undefined);
},
lastUsedDataViewId: new BehaviorSubject<string>(WEB_LOGS_DATA_VIEW_ID),
saveNotification$,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -277,16 +230,57 @@ export const ReactControlExample = ({
};
}, [controlGroupFilters$, filters$, unifiedSearchFilters$]);
const [unsavedChanges, setUnsavedChanges] = useState<string | undefined>(undefined);
useEffect(() => {
if (!controlGroupApi) {
return;
}
const subscription = controlGroupApi.unsavedChanges.subscribe((nextUnsavedChanges) => {
if (!nextUnsavedChanges) {
clearControlGroupRuntimeState();
setUnsavedChanges(undefined);
return;
}
setControlGroupRuntimeState(nextUnsavedChanges);
// JSON.stringify removes keys where value is `undefined`
// switch `undefined` to `null` to see when value has been cleared
const replacer = (key: unknown, value: unknown) =>
typeof value === 'undefined' ? null : value;
setUnsavedChanges(JSON.stringify(nextUnsavedChanges, replacer, ' '));
});
return () => {
subscription.unsubscribe();
};
}, [controlGroupApi]);
return (
<>
{dataViewNotFound && (
<>
<EuiCallOut color="warning" iconType="warning">
<p>{`Install "Sample web logs" to run example`}</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
<EuiCallOut color="warning" iconType="warning">
<p>{`Install "Sample web logs" to run example`}</p>
</EuiCallOut>
)}
{!dataViewNotFound && (
<EuiCallOut title="This example uses session storage to persist saved state and unsaved changes">
<EuiButton
color="accent"
size="s"
onClick={() => {
clearControlGroupSerializedState();
clearControlGroupRuntimeState();
window.location.reload();
}}
>
Reset example
</EuiButton>
</EuiCallOut>
)}
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
@ -358,6 +352,37 @@ export const ReactControlExample = ({
}}
/>
</EuiFlexItem>
{unsavedChanges !== undefined && viewMode === 'edit' && (
<>
<EuiFlexItem grow={false}>
<EuiToolTip content={<pre>{unsavedChanges}</pre>}>
<EuiBadge color="warning">Unsaved changes</EuiBadge>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
isDisabled={!controlGroupApi}
onClick={() => {
controlGroupApi?.resetUnsavedChanges();
}}
>
Reset
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={async () => {
if (controlGroupApi) {
saveNotification$.next();
setControlGroupSerializedState(await controlGroupApi.serializeState());
}
}}
>
Save
</EuiButton>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiSuperDatePicker
@ -381,33 +406,8 @@ export const ReactControlExample = ({
type={CONTROL_GROUP_TYPE}
getParentApi={() => ({
...dashboardApi,
getSerializedStateForChild: () => ({
rawState: {
controlStyle: 'oneLine',
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
panelsJSON: JSON.stringify(controlGroupPanels),
ignoreParentSettingsJSON:
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
} as object,
references: [
{
name: `controlGroup_${searchControlId}:${SEARCH_CONTROL_TYPE}DataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},
{
name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL_TYPE}DataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},
{
name: `controlGroup_${optionsListId}:optionsListControlDataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},
],
}),
getSerializedStateForChild: getControlGroupSerializedState,
getRuntimeStateForChild: getControlGroupRuntimeState,
})}
key={`control_group`}
/>

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ControlGroupRuntimeState } from '../../react_controls/control_group/types';
const RUNTIME_STATE_SESSION_STORAGE_KEY =
'kibana.examples.controls.reactControlExample.controlGroupRuntimeState';
export function clearControlGroupRuntimeState() {
sessionStorage.removeItem(RUNTIME_STATE_SESSION_STORAGE_KEY);
}
export function getControlGroupRuntimeState(): Partial<ControlGroupRuntimeState> {
const runtimeStateJSON = sessionStorage.getItem(RUNTIME_STATE_SESSION_STORAGE_KEY);
return runtimeStateJSON ? JSON.parse(runtimeStateJSON) : {};
}
export function setControlGroupRuntimeState(runtimeState: Partial<ControlGroupRuntimeState>) {
sessionStorage.setItem(RUNTIME_STATE_SESSION_STORAGE_KEY, JSON.stringify(runtimeState));
}

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { SerializedPanelState } from '@kbn/presentation-containers';
import { ControlGroupSerializedState } from '../../react_controls/control_group/types';
import { OPTIONS_LIST_CONTROL_TYPE } from '../../react_controls/data_controls/options_list_control/constants';
import { RANGE_SLIDER_CONTROL_TYPE } from '../../react_controls/data_controls/range_slider/types';
import { SEARCH_CONTROL_TYPE } from '../../react_controls/data_controls/search_control/types';
import { TIMESLIDER_CONTROL_TYPE } from '../../react_controls/timeslider_control/types';
const SERIALIZED_STATE_SESSION_STORAGE_KEY =
'kibana.examples.controls.reactControlExample.controlGroupSerializedState';
export const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247';
export function clearControlGroupSerializedState() {
sessionStorage.removeItem(SERIALIZED_STATE_SESSION_STORAGE_KEY);
}
export function getControlGroupSerializedState(): SerializedPanelState<ControlGroupSerializedState> {
const serializedStateJSON = sessionStorage.getItem(SERIALIZED_STATE_SESSION_STORAGE_KEY);
return serializedStateJSON ? JSON.parse(serializedStateJSON) : initialSerializedControlGroupState;
}
export function setControlGroupSerializedState(
serializedState: SerializedPanelState<ControlGroupSerializedState>
) {
sessionStorage.setItem(SERIALIZED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState));
}
const optionsListId = 'optionsList1';
const searchControlId = 'searchControl1';
const rangeSliderControlId = 'rangeSliderControl1';
const timesliderControlId = 'timesliderControl1';
const controlGroupPanels = {
[searchControlId]: {
type: SEARCH_CONTROL_TYPE,
order: 3,
grow: true,
width: 'medium',
explicitInput: {
id: searchControlId,
fieldName: 'message',
title: 'Message',
grow: true,
width: 'medium',
enhancements: {},
},
},
[rangeSliderControlId]: {
type: RANGE_SLIDER_CONTROL_TYPE,
order: 1,
grow: true,
width: 'medium',
explicitInput: {
id: rangeSliderControlId,
fieldName: 'bytes',
title: 'Bytes',
grow: true,
width: 'medium',
enhancements: {},
},
},
[timesliderControlId]: {
type: TIMESLIDER_CONTROL_TYPE,
order: 4,
grow: true,
width: 'medium',
explicitInput: {
id: timesliderControlId,
title: 'Time slider',
enhancements: {},
},
},
[optionsListId]: {
type: OPTIONS_LIST_CONTROL_TYPE,
order: 2,
grow: true,
width: 'medium',
explicitInput: {
id: searchControlId,
fieldName: 'agent.keyword',
title: 'Agent',
grow: true,
width: 'medium',
enhancements: {},
},
},
};
const initialSerializedControlGroupState = {
rawState: {
controlStyle: 'oneLine',
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
panelsJSON: JSON.stringify(controlGroupPanels),
ignoreParentSettingsJSON:
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
} as object,
references: [
{
name: `controlGroup_${searchControlId}:${SEARCH_CONTROL_TYPE}DataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},
{
name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL_TYPE}DataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},
{
name: `controlGroup_${optionsListId}:optionsListControlDataView`,
type: 'index-pattern',
id: WEB_LOGS_DATA_VIEW_ID,
},
],
};

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { omit } from 'lodash';
import {
childrenUnsavedChanges$,
initializeUnsavedChanges,
PresentationContainer,
} from '@kbn/presentation-containers';
import {
apiPublishesUnsavedChanges,
PublishesUnsavedChanges,
StateComparators,
} from '@kbn/presentation-publishing';
import { combineLatest, map } from 'rxjs';
import { ControlsInOrder, getControlsInOrder } from './init_controls_manager';
import { ControlGroupRuntimeState, ControlPanelsState } from './types';
export type ControlGroupComparatorState = Pick<
ControlGroupRuntimeState,
| 'autoApplySelections'
| 'chainingSystem'
| 'ignoreParentSettings'
| 'initialChildControlState'
| 'labelPosition'
> & {
controlsInOrder: ControlsInOrder;
};
export function initializeControlGroupUnsavedChanges(
children$: PresentationContainer['children$'],
comparators: StateComparators<ControlGroupComparatorState>,
snapshotControlsRuntimeState: () => ControlPanelsState,
parentApi: unknown,
lastSavedRuntimeState: ControlGroupRuntimeState
) {
const controlGroupUnsavedChanges = initializeUnsavedChanges<ControlGroupComparatorState>(
{
autoApplySelections: lastSavedRuntimeState.autoApplySelections,
chainingSystem: lastSavedRuntimeState.chainingSystem,
controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState),
ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings,
initialChildControlState: lastSavedRuntimeState.initialChildControlState,
labelPosition: lastSavedRuntimeState.labelPosition,
},
parentApi,
comparators
);
return {
api: {
unsavedChanges: combineLatest([
controlGroupUnsavedChanges.api.unsavedChanges,
childrenUnsavedChanges$(children$),
]).pipe(
map(([unsavedControlGroupState, unsavedControlsState]) => {
const unsavedChanges: Partial<ControlGroupRuntimeState> = unsavedControlGroupState
? omit(unsavedControlGroupState, 'controlsInOrder')
: {};
if (unsavedControlsState || unsavedControlGroupState?.controlsInOrder) {
unsavedChanges.initialChildControlState = snapshotControlsRuntimeState();
}
return Object.keys(unsavedChanges).length ? unsavedChanges : undefined;
})
),
resetUnsavedChanges: () => {
controlGroupUnsavedChanges.api.resetUnsavedChanges();
Object.values(children$.value).forEach((controlApi) => {
if (apiPublishesUnsavedChanges(controlApi)) controlApi.resetUnsavedChanges();
});
},
} as PublishesUnsavedChanges<ControlGroupRuntimeState>,
};
}

View file

@ -8,6 +8,7 @@
import React, { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import {
ControlGroupChainingSystem,
ControlWidth,
@ -22,7 +23,10 @@ import { DataView } from '@kbn/data-views-plugin/common';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
import {
apiHasSaveNotification,
combineCompatibleChildrenApis,
} from '@kbn/presentation-containers';
import {
apiPublishesDataViews,
PublishesDataViews,
@ -32,14 +36,10 @@ import { chaining$, controlFetch$, controlGroupFetch$ } from './control_fetch';
import { initControlsManager } from './init_controls_manager';
import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
import { deserializeControlGroup } from './serialization_utils';
import {
ControlGroupApi,
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlGroupUnsavedChanges,
} from './types';
import { ControlGroupApi, ControlGroupRuntimeState, ControlGroupSerializedState } from './types';
import { ControlGroup } from './components/control_group';
import { initSelectionsManager } from './selections_manager';
import { initializeControlGroupUnsavedChanges } from './control_group_unsaved_changes_api';
export const getControlGroupEmbeddableFactory = (services: {
core: CoreStart;
@ -52,7 +52,14 @@ export const getControlGroupEmbeddableFactory = (services: {
> = {
type: CONTROL_GROUP_TYPE,
deserializeState: (state) => deserializeControlGroup(state),
buildEmbeddable: async (initialState, buildApi, uuid, parentApi, setApi) => {
buildEmbeddable: async (
initialRuntimeState,
buildApi,
uuid,
parentApi,
setApi,
lastSavedRuntimeState
) => {
const {
initialChildControlState,
defaultControlGrow,
@ -61,7 +68,7 @@ export const getControlGroupEmbeddableFactory = (services: {
chainingSystem,
autoApplySelections,
ignoreParentSettings,
} = initialState;
} = initialRuntimeState;
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
const controlsManager = initControlsManager(initialChildControlState);
@ -88,20 +95,36 @@ export const getControlGroupEmbeddableFactory = (services: {
/** TODO: Handle loading; loading should be true if any child is loading */
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
/** TODO: Handle unsaved changes
* - Each child has an unsaved changed behaviour subject it pushes to
* - The control group listens to all of them (anyChildHasUnsavedChanges) and publishes its
* own unsaved changes if either one of its children has unsaved changes **or** one of
* the control group settings changed.
* - Children should **not** publish unsaved changes based on their output filters or selections.
* Instead, the control group will handle unsaved changes for filters.
*/
const unsavedChanges = new BehaviorSubject<Partial<ControlGroupUnsavedChanges> | undefined>(
undefined
const unsavedChanges = initializeControlGroupUnsavedChanges(
controlsManager.api.children$,
{
...controlsManager.comparators,
autoApplySelections: [
autoApplySelections$,
(next: boolean) => autoApplySelections$.next(next),
],
chainingSystem: [
chainingSystem$,
(next: ControlGroupChainingSystem) => chainingSystem$.next(next),
],
ignoreParentSettings: [
ignoreParentSettings$,
(next: ParentIgnoreSettings | undefined) => ignoreParentSettings$.next(next),
fastIsEqual,
],
labelPosition: [labelPosition$, (next: ControlStyle) => labelPosition$.next(next)],
},
controlsManager.snapshotControlsRuntimeState,
parentApi,
lastSavedRuntimeState
);
const api = setApi({
...controlsManager.api,
getLastSavedControlState: (controlUuid: string) => {
return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {};
},
...unsavedChanges.api,
...selectionsManager.api,
controlFetch$: (controlUuid: string) =>
controlFetch$(
@ -116,10 +139,6 @@ export const getControlGroupEmbeddableFactory = (services: {
ignoreParentSettings$,
autoApplySelections$,
allowExpensiveQueries$,
unsavedChanges,
resetUnsavedChanges: () => {
// TODO: Implement this
},
snapshotRuntimeState: () => {
// TODO: Remove this if it ends up being unnecessary
return {} as unknown as ControlGroupRuntimeState;
@ -159,6 +178,9 @@ export const getControlGroupEmbeddableFactory = (services: {
width,
dataViews,
labelPosition: labelPosition$,
saveNotification$: apiHasSaveNotification(parentApi)
? parentApi.saveNotification$
: undefined,
});
/** Subscribe to all children's output data views, combine them, and output them */

View file

@ -16,12 +16,12 @@ jest.mock('uuid', () => ({
describe('PresentationContainer api', () => {
test('addNewPanel should add control at end of controls', async () => {
const controlsManager = initControlsManager({
alpha: { type: 'whatever', order: 0 },
bravo: { type: 'whatever', order: 1 },
charlie: { type: 'whatever', order: 2 },
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
});
const addNewPanelPromise = controlsManager.api.addNewPanel({
panelType: 'whatever',
panelType: 'testControl',
initialState: {},
});
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
@ -36,9 +36,9 @@ describe('PresentationContainer api', () => {
test('removePanel should remove control', () => {
const controlsManager = initControlsManager({
alpha: { type: 'whatever', order: 0 },
bravo: { type: 'whatever', order: 1 },
charlie: { type: 'whatever', order: 2 },
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
});
controlsManager.api.removePanel('bravo');
expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([
@ -49,12 +49,12 @@ describe('PresentationContainer api', () => {
test('replacePanel should replace control', async () => {
const controlsManager = initControlsManager({
alpha: { type: 'whatever', order: 0 },
bravo: { type: 'whatever', order: 1 },
charlie: { type: 'whatever', order: 2 },
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
});
const replacePanelPromise = controlsManager.api.replacePanel('bravo', {
panelType: 'whatever',
panelType: 'testControl',
initialState: {},
});
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
@ -69,8 +69,8 @@ describe('PresentationContainer api', () => {
describe('untilInitialized', () => {
test('should not resolve until all controls are initialized', async () => {
const controlsManager = initControlsManager({
alpha: { type: 'whatever', order: 0 },
bravo: { type: 'whatever', order: 1 },
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
});
let isDone = false;
controlsManager.api.untilInitialized().then(() => {
@ -90,8 +90,8 @@ describe('PresentationContainer api', () => {
test('should resolve when all control already initialized ', async () => {
const controlsManager = initControlsManager({
alpha: { type: 'whatever', order: 0 },
bravo: { type: 'whatever', order: 1 },
alpha: { type: 'testControl', order: 0 },
bravo: { type: 'testControl', order: 1 },
});
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi);
@ -106,3 +106,34 @@ describe('PresentationContainer api', () => {
});
});
});
describe('snapshotControlsRuntimeState', () => {
test('should snapshot runtime state for all controls', async () => {
const controlsManager = initControlsManager({
alpha: { type: 'testControl', order: 1 },
bravo: { type: 'testControl', order: 0 },
});
controlsManager.setControlApi('alpha', {
snapshotRuntimeState: () => {
return { key1: 'alpha value' };
},
} as unknown as DefaultControlApi);
controlsManager.setControlApi('bravo', {
snapshotRuntimeState: () => {
return { key1: 'bravo value' };
},
} as unknown as DefaultControlApi);
expect(controlsManager.snapshotControlsRuntimeState()).toEqual({
alpha: {
key1: 'alpha value',
order: 1,
type: 'testControl',
},
bravo: {
key1: 'bravo value',
order: 0,
type: 'testControl',
},
});
});
});

View file

@ -7,6 +7,7 @@
*/
import { v4 as generateId } from 'uuid';
import fastIsEqual from 'fast-deep-equal';
import {
HasSerializedChildState,
PanelPackage,
@ -14,28 +15,35 @@ import {
} from '@kbn/presentation-containers';
import type { Reference } from '@kbn/content-management-utils';
import { BehaviorSubject, first, merge } from 'rxjs';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing';
import { omit } from 'lodash';
import { apiHasSnapshottableState } from '@kbn/presentation-containers/interfaces/serialized_state';
import { ControlPanelsState, ControlPanelState } from './types';
import { DefaultControlApi, DefaultControlState } from '../types';
import { ControlGroupComparatorState } from './control_group_unsaved_changes_api';
export type ControlsInOrder = Array<{ id: string; type: string }>;
export function getControlsInOrder(initialControlPanelsState: ControlPanelsState) {
return Object.keys(initialControlPanelsState)
.map((key) => ({
id: key,
order: initialControlPanelsState[key].order,
type: initialControlPanelsState[key].type,
}))
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map(({ id, type }) => ({ id, type })); // filter out `order`
}
export function initControlsManager(initialControlPanelsState: ControlPanelsState) {
const lastSavedControlsPanelState$ = new BehaviorSubject(initialControlPanelsState);
const initialControlIds = Object.keys(initialControlPanelsState);
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
const controlsPanelState: { [panelId: string]: DefaultControlState } = {
let controlsPanelState: { [panelId: string]: DefaultControlState } = {
...initialControlPanelsState,
};
const controlsInOrder$ = new BehaviorSubject<ControlsInOrder>(
Object.keys(initialControlPanelsState)
.map((key) => ({
id: key,
order: initialControlPanelsState[key].order,
type: initialControlPanelsState[key].type,
}))
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map(({ id, type }) => ({ id, type })) // filter out `order`
getControlsInOrder(initialControlPanelsState)
);
function untilControlLoaded(
@ -133,6 +141,20 @@ export function initControlsManager(initialControlPanelsState: ControlPanelsStat
references,
};
},
snapshotControlsRuntimeState: () => {
const controlsRuntimeState: ControlPanelsState = {};
controlsInOrder$.getValue().forEach(({ id, type }, index) => {
const controlApi = getControlApi(id);
if (controlApi && apiHasSnapshottableState(controlApi)) {
controlsRuntimeState[id] = {
order: index,
type,
...controlApi.snapshotRuntimeState(),
};
}
});
return controlsRuntimeState;
},
api: {
getSerializedStateForChild: (childId: string) => {
const controlPanelState = controlsPanelState[childId];
@ -175,5 +197,28 @@ export function initControlsManager(initialControlPanelsState: ControlPanelsStat
},
} as PresentationContainer &
HasSerializedChildState<ControlPanelState> & { untilInitialized: () => Promise<void> },
comparators: {
controlsInOrder: [
controlsInOrder$,
(next: ControlsInOrder) => controlsInOrder$.next(next),
fastIsEqual,
],
// Control state differences tracked by controlApi comparators
// Control ordering differences tracked by controlsInOrder comparator
// initialChildControlState comparatator exists to reset controls manager to last saved state
initialChildControlState: [
lastSavedControlsPanelState$,
(lastSavedControlPanelsState: ControlPanelsState) => {
lastSavedControlsPanelState$.next(lastSavedControlPanelsState);
controlsPanelState = {
...lastSavedControlPanelsState,
};
controlsInOrder$.next(getControlsInOrder(lastSavedControlPanelsState));
},
() => true,
],
} as StateComparators<
Pick<ControlGroupComparatorState, 'controlsInOrder' | 'initialChildControlState'>
>,
};
}

View file

@ -11,7 +11,11 @@ import { ParentIgnoreSettings } from '@kbn/controls-plugin/public';
import { ControlStyle, ControlWidth } from '@kbn/controls-plugin/public/types';
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import { Filter } from '@kbn/es-query';
import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers';
import {
HasSaveNotification,
HasSerializedChildState,
PresentationContainer,
} from '@kbn/presentation-containers';
import {
HasEditCapabilities,
HasParentApi,
@ -54,9 +58,10 @@ export type ControlGroupApi = PresentationContainer &
PublishesUnsavedChanges &
PublishesControlGroupDisplaySettings &
PublishesTimeslice &
Partial<HasParentApi<PublishesUnifiedSearch>> & {
Partial<HasParentApi<PublishesUnifiedSearch> & HasSaveNotification> & {
autoApplySelections$: PublishingSubject<boolean>;
controlFetch$: (controlUuid: string) => Observable<ControlFetchContext>;
getLastSavedControlState: (controlUuid: string) => object;
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>;
allowExpensiveQueries$: PublishingSubject<boolean>;
untilInitialized: () => Promise<void>;
@ -84,16 +89,8 @@ export type ControlGroupEditorState = Pick<
'chainingSystem' | 'labelPosition' | 'autoApplySelections' | 'ignoreParentSettings'
>;
export type ControlGroupSerializedState = Omit<
ControlGroupRuntimeState,
| 'labelPosition'
| 'ignoreParentSettings'
| 'defaultControlGrow'
| 'defaultControlWidth'
| 'anyChildHasUnsavedChanges'
| 'initialChildControlState'
| 'autoApplySelections'
> & {
export interface ControlGroupSerializedState {
chainingSystem: ControlGroupChainingSystem;
panelsJSON: string;
ignoreParentSettingsJSON: string;
// In runtime state, we refer to this property as `labelPosition`;
@ -102,4 +99,4 @@ export type ControlGroupSerializedState = Omit<
// In runtime state, we refer to the inverse of this property as `autoApplySelections`
// to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state
showApplySelections: boolean | undefined;
};
}

View file

@ -6,9 +6,10 @@
* Side Public License, v 1.
*/
import React, { useEffect, useImperativeHandle, useState } from 'react';
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
import { StateComparators } from '@kbn/presentation-publishing';
import { getControlFactory } from './control_factory_registry';
@ -35,6 +36,8 @@ export const ControlRenderer = <
onApiAvailable?: (api: ApiType) => void;
isControlGroupInitialized: boolean;
}) => {
const cleanupFunction = useRef<(() => void) | null>(null);
const [component, setComponent] = useState<undefined | React.FC<{ className: string }>>(
undefined
);
@ -48,25 +51,29 @@ export const ControlRenderer = <
const factory = getControlFactory<StateType, ApiType>(type);
const buildApi = (
apiRegistration: ControlApiRegistration<ApiType>,
comparators: StateComparators<StateType> // TODO: Use these to calculate unsaved changes
comparators: StateComparators<StateType>
): ApiType => {
const unsavedChanges = initializeUnsavedChanges<StateType>(
parentApi.getLastSavedControlState(uuid) as StateType,
parentApi,
comparators
);
cleanupFunction.current = () => unsavedChanges.cleanup();
return {
...apiRegistration,
...unsavedChanges.api,
uuid,
parentApi,
unsavedChanges: new BehaviorSubject<Partial<StateType> | undefined>(undefined),
resetUnsavedChanges: () => {},
type: factory.type,
} as unknown as ApiType;
};
const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? {};
return await factory.buildControl(
initialState as unknown as StateType,
buildApi,
uuid,
parentApi
);
const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? {
rawState: {},
};
return await factory.buildControl(initialState as StateType, buildApi, uuid, parentApi);
}
buildControl()
@ -118,6 +125,12 @@ export const ControlRenderer = <
[type]
);
useEffect(() => {
return () => {
cleanupFunction.current?.();
};
}, []);
return component && isControlGroupInitialized ? (
// @ts-expect-error
<ControlPanel<ApiType> Component={component} uuid={uuid} />

View file

@ -21,7 +21,7 @@ interface Props {
max: number | undefined;
min: number | undefined;
onChange: (value: RangeValue | undefined) => void;
step: number | undefined;
step: number;
value: RangeValue | undefined;
uuid: string;
controlPanelClassName?: string;

View file

@ -100,7 +100,11 @@ export const getRangesliderControlFactory = (
},
{
...dataControl.comparators,
step: [step$, (nextStep: number | undefined) => step$.next(nextStep)],
step: [
step$,
(nextStep: number | undefined) => step$.next(nextStep),
(a, b) => (a ?? 1) === (b ?? 1),
],
value: [value$, setValue],
}
);
@ -237,7 +241,7 @@ export const getRangesliderControlFactory = (
max={max}
min={min}
onChange={setValue}
step={step}
step={step ?? 1}
value={value}
uuid={uuid}
/>

View file

@ -122,6 +122,7 @@ export const getSearchControlFactory = (
searchTechnique,
(newTechnique: SearchControlTechniques | undefined) =>
searchTechnique.next(newTechnique),
(a, b) => (a ?? DEFAULT_SEARCH_TECHNIQUE) === (b ?? DEFAULT_SEARCH_TECHNIQUE),
],
searchString: [
searchString,

View file

@ -13,6 +13,7 @@ import { EuiInputPopover } from '@elastic/eui';
import {
apiHasParentApi,
apiPublishesDataLoading,
getUnchangingComparator,
getViewModeSubject,
useBatchedPublishingSubjects,
ViewMode,
@ -185,7 +186,6 @@ export const getTimesliderControlFactory = (
const viewModeSubject =
getViewModeSubject(controlGroupApi) ?? new BehaviorSubject('view' as ViewMode);
// overwrite the `width` attribute because time slider should always have a width of large
const defaultControl = initializeDefaultControlApi({ ...initialState, width: 'large' });
const dashboardDataLoading$ =
@ -243,6 +243,7 @@ export const getTimesliderControlFactory = (
},
{
...defaultControl.comparators,
width: getUnchangingComparator(),
...timeRangePercentage.comparators,
isAnchored: [isAnchored$, setIsAnchored],
}

View file

@ -13,6 +13,8 @@ export {
type HasRuntimeChildState,
type HasSerializedChildState,
} from './interfaces/child_state';
export { childrenUnsavedChanges$ } from './interfaces/unsaved_changes/children_unsaved_changes';
export { initializeUnsavedChanges } from './interfaces/unsaved_changes/initialize_unsaved_changes';
export {
apiHasSaveNotification,
type HasSaveNotification,

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject, skip } from 'rxjs';
import { childrenUnsavedChanges$, DEBOUNCE_TIME } from './children_unsaved_changes';
describe('childrenUnsavedChanges$', () => {
const child1Api = {
unsavedChanges: new BehaviorSubject<object | undefined>(undefined),
resetUnsavedChanges: () => undefined,
};
const child2Api = {
unsavedChanges: new BehaviorSubject<object | undefined>(undefined),
resetUnsavedChanges: () => undefined,
};
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
const onFireMock = jest.fn();
beforeEach(() => {
onFireMock.mockReset();
child1Api.unsavedChanges.next(undefined);
child2Api.unsavedChanges.next(undefined);
children$.next({
child1: child1Api,
child2: child2Api,
});
});
test('should emit on subscribe', async () => {
const subscription = childrenUnsavedChanges$(children$).subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1));
expect(onFireMock).toHaveBeenCalledTimes(1);
const childUnsavedChanges = onFireMock.mock.calls[0][0];
expect(childUnsavedChanges).toBeUndefined();
subscription.unsubscribe();
});
test('should emit when child has new unsaved changes', async () => {
const subscription = childrenUnsavedChanges$(children$).pipe(skip(1)).subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1));
expect(onFireMock).toHaveBeenCalledTimes(0);
child1Api.unsavedChanges.next({
key1: 'modified value',
});
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1));
expect(onFireMock).toHaveBeenCalledTimes(1);
const childUnsavedChanges = onFireMock.mock.calls[0][0];
expect(childUnsavedChanges).toEqual({
child1: {
key1: 'modified value',
},
});
subscription.unsubscribe();
});
test('should emit when children changes', async () => {
const subscription = childrenUnsavedChanges$(children$).pipe(skip(1)).subscribe(onFireMock);
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1));
expect(onFireMock).toHaveBeenCalledTimes(0);
// add child
children$.next({
...children$.value,
child3: {
unsavedChanges: new BehaviorSubject<object | undefined>({ key1: 'modified value' }),
resetUnsavedChanges: () => undefined,
},
});
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME + 1));
expect(onFireMock).toHaveBeenCalledTimes(1);
const childUnsavedChanges = onFireMock.mock.calls[0][0];
expect(childUnsavedChanges).toEqual({
child3: {
key1: 'modified value',
},
});
subscription.unsubscribe();
});
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { combineLatest, debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing';
import { PresentationContainer } from '../presentation_container';
export const DEBOUNCE_TIME = 100;
/**
* Create an observable stream of unsaved changes from all react embeddable children
*/
export function childrenUnsavedChanges$(children$: PresentationContainer['children$']) {
return children$.pipe(
map((children) => Object.keys(children)),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) => {
if (newChildIds.length === 0) return of([]);
const childrenThatPublishUnsavedChanges = Object.entries(children$.value).filter(
([childId, child]) => apiPublishesUnsavedChanges(child)
) as Array<[string, PublishesUnsavedChanges]>;
return childrenThatPublishUnsavedChanges.length === 0
? of([])
: combineLatest(
childrenThatPublishUnsavedChanges.map(([childId, child]) =>
child.unsavedChanges.pipe(map((unsavedChanges) => ({ childId, unsavedChanges })))
)
);
}),
debounceTime(DEBOUNCE_TIME),
map((unsavedChildStates) => {
const unsavedChildrenState: { [key: string]: object } = {};
unsavedChildStates.forEach(({ childId, unsavedChanges }) => {
if (unsavedChanges) {
unsavedChildrenState[childId] = unsavedChanges;
}
});
return Object.keys(unsavedChildrenState).length ? unsavedChildrenState : undefined;
})
);
}

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { BehaviorSubject, Subject } from 'rxjs';
import {
COMPARATOR_SUBJECTS_DEBOUNCE,
initializeUnsavedChanges,
} from './initialize_unsaved_changes';
import { PublishesUnsavedChanges, StateComparators } from '@kbn/presentation-publishing';
interface TestState {
key1: string;
key2: string;
}
describe('unsavedChanges api', () => {
const lastSavedState = {
key1: 'original key1 value',
key2: 'original key2 value',
} as TestState;
const key1$ = new BehaviorSubject(lastSavedState.key1);
const key2$ = new BehaviorSubject(lastSavedState.key2);
const comparators = {
key1: [key1$, (next: string) => key1$.next(next)],
key2: [key2$, (next: string) => key2$.next(next)],
} as StateComparators<TestState>;
const parentApi = {
saveNotification$: new Subject<void>(),
};
let api: undefined | PublishesUnsavedChanges;
beforeEach(() => {
key1$.next(lastSavedState.key1);
key2$.next(lastSavedState.key2);
({ api } = initializeUnsavedChanges<TestState>(lastSavedState, parentApi, comparators));
});
test('should have no unsaved changes after initialization', () => {
expect(api?.unsavedChanges.value).toBeUndefined();
});
test('should have unsaved changes when state changes', async () => {
key1$.next('modified key1 value');
await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1));
expect(api?.unsavedChanges.value).toEqual({
key1: 'modified key1 value',
});
});
test('should have no unsaved changes after save', async () => {
key1$.next('modified key1 value');
await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1));
expect(api?.unsavedChanges.value).not.toBeUndefined();
// trigger save
parentApi.saveNotification$.next();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(api?.unsavedChanges.value).toBeUndefined();
});
test('should have no unsaved changes after reset', async () => {
key1$.next('modified key1 value');
await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1));
expect(api?.unsavedChanges.value).not.toBeUndefined();
// trigger reset
api?.resetUnsavedChanges();
await new Promise((resolve) => setTimeout(resolve, COMPARATOR_SUBJECTS_DEBOUNCE + 1));
expect(api?.unsavedChanges.value).toBeUndefined();
});
});

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
BehaviorSubject,
combineLatest,
combineLatestWith,
debounceTime,
map,
skip,
Subscription,
} from 'rxjs';
import {
getInitialValuesFromComparators,
PublishesUnsavedChanges,
PublishingSubject,
runComparators,
StateComparators,
} from '@kbn/presentation-publishing';
import { HasSnapshottableState } from '../serialized_state';
import { apiHasSaveNotification } from '../has_save_notification';
export const COMPARATOR_SUBJECTS_DEBOUNCE = 100;
export const initializeUnsavedChanges = <RuntimeState extends {} = {}>(
initialLastSavedState: RuntimeState,
parentApi: unknown,
comparators: StateComparators<RuntimeState>
) => {
const subscriptions: Subscription[] = [];
const lastSavedState$ = new BehaviorSubject<RuntimeState | undefined>(initialLastSavedState);
const snapshotRuntimeState = () => {
const comparatorKeys = Object.keys(comparators) as Array<keyof RuntimeState>;
const snapshot = {} as RuntimeState;
comparatorKeys.forEach((key) => {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
snapshot[key] = comparatorSubject.value as RuntimeState[typeof key];
});
return snapshot;
};
if (apiHasSaveNotification(parentApi)) {
subscriptions.push(
// any time the parent saves, the current state becomes the last saved state...
parentApi.saveNotification$.subscribe(() => {
lastSavedState$.next(snapshotRuntimeState());
})
);
}
const comparatorSubjects: Array<PublishingSubject<unknown>> = [];
const comparatorKeys: Array<keyof RuntimeState> = []; // index maps comparator subject to comparator key
for (const key of Object.keys(comparators) as Array<keyof RuntimeState>) {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
comparatorSubjects.push(comparatorSubject as PublishingSubject<unknown>);
comparatorKeys.push(key);
}
const unsavedChanges = new BehaviorSubject<Partial<RuntimeState> | undefined>(
runComparators(
comparators,
comparatorKeys,
lastSavedState$.getValue() as RuntimeState,
getInitialValuesFromComparators(comparators, comparatorKeys)
)
);
subscriptions.push(
combineLatest(comparatorSubjects)
.pipe(
skip(1),
debounceTime(COMPARATOR_SUBJECTS_DEBOUNCE),
map((latestStates) =>
comparatorKeys.reduce((acc, key, index) => {
acc[key] = latestStates[index] as RuntimeState[typeof key];
return acc;
}, {} as Partial<RuntimeState>)
),
combineLatestWith(lastSavedState$)
)
.subscribe(([latestState, lastSavedState]) => {
unsavedChanges.next(
runComparators(comparators, comparatorKeys, lastSavedState, latestState)
);
})
);
return {
api: {
unsavedChanges,
resetUnsavedChanges: () => {
const lastSaved = lastSavedState$.getValue();
for (const key of comparatorKeys) {
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
setter(lastSaved?.[key] as RuntimeState[typeof key]);
}
},
snapshotRuntimeState,
} as PublishesUnsavedChanges<RuntimeState> & HasSnapshottableState<RuntimeState>,
cleanup: () => {
subscriptions.forEach((subscription) => subscription.unsubscribe());
},
};
};

View file

@ -8,8 +8,8 @@
import { PublishingSubject } from '../publishing_subject';
export interface PublishesUnsavedChanges {
unsavedChanges: PublishingSubject<object | undefined>;
export interface PublishesUnsavedChanges<Runtime extends object = object> {
unsavedChanges: PublishingSubject<Partial<Runtime> | undefined>;
resetUnsavedChanges: () => void;
}

View file

@ -6,12 +6,10 @@
* Side Public License, v 1.
*/
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing';
import deepEqual from 'fast-deep-equal';
import { childrenUnsavedChanges$ } from '@kbn/presentation-containers';
import { omit } from 'lodash';
import { AnyAction, Middleware } from 'redux';
import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs';
import { DashboardContainer, DashboardCreationOptions } from '../..';
import { DashboardContainerInput } from '../../../../common';
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
@ -84,32 +82,6 @@ export function startDiffingDashboardState(
this: DashboardContainer,
creationOptions?: DashboardCreationOptions
) {
/**
* Create an observable stream of unsaved changes from all react embeddable children
*/
const reactEmbeddableUnsavedChanges = this.children$.pipe(
map((children) => Object.keys(children)),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) => {
if (newChildIds.length === 0) return of([]);
const childrenThatPublishUnsavedChanges = Object.entries(this.children$.value).filter(
([childId, child]) => apiPublishesUnsavedChanges(child)
) as Array<[string, PublishesUnsavedChanges]>;
if (childrenThatPublishUnsavedChanges.length === 0) return of([]);
return combineLatest(
childrenThatPublishUnsavedChanges.map(([childId, child]) =>
child.unsavedChanges.pipe(map((unsavedChanges) => ({ childId, unsavedChanges })))
)
);
}),
debounceTime(CHANGE_CHECK_DEBOUNCE),
map((children) => children.filter((child) => Boolean(child.unsavedChanges)))
);
/**
* Create an observable stream that checks for unsaved changes in the Dashboard state
* and the state of all of its legacy embeddable children.
@ -138,30 +110,26 @@ export function startDiffingDashboardState(
this.diffingSubscription.add(
combineLatest([
dashboardUnsavedChanges,
reactEmbeddableUnsavedChanges,
childrenUnsavedChanges$(this.children$),
this.controlGroup?.unsavedChanges ??
(of(undefined) as Observable<PersistableControlGroupInput | undefined>),
]).subscribe(([dashboardChanges, reactEmbeddableChanges, controlGroupChanges]) => {
]).subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => {
// calculate unsaved changes
const hasUnsavedChanges =
Object.keys(omit(dashboardChanges, keysNotConsideredUnsavedChanges)).length > 0 ||
reactEmbeddableChanges.length > 0 ||
unsavedPanelState !== undefined ||
controlGroupChanges !== undefined;
if (hasUnsavedChanges !== this.getState().componentState.hasUnsavedChanges) {
this.dispatch.setHasUnsavedChanges(hasUnsavedChanges);
}
const unsavedPanelState = reactEmbeddableChanges.reduce<UnsavedPanelState>(
(acc, { childId, unsavedChanges }) => {
acc[childId] = unsavedChanges;
return acc;
},
{} as UnsavedPanelState
);
// backup unsaved changes if configured to do so
if (creationOptions?.useSessionStorageIntegration) {
backupUnsavedChanges.bind(this)(dashboardChanges, unsavedPanelState, controlGroupChanges);
backupUnsavedChanges.bind(this)(
dashboardChanges,
unsavedPanelState ? unsavedPanelState : {},
controlGroupChanges
);
}
})
);

View file

@ -123,12 +123,14 @@ describe('saved search embeddable', () => {
describe('search embeddable component', () => {
it('should render empty grid when empty data is returned', async () => {
const { search, resolveSearch } = createSearchFnMock(0);
const initialRuntimeState = getInitialRuntimeState({ searchMock: search });
const { Component, api } = await factory.buildEmbeddable(
getInitialRuntimeState({ searchMock: search }),
initialRuntimeState,
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
jest.fn().mockImplementation((newApi) => newApi),
initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState
);
await waitOneTick(); // wait for build to complete
const discoverComponent = render(<Component />);
@ -148,15 +150,17 @@ describe('saved search embeddable', () => {
it('should render field stats table in AGGREGATED_LEVEL view mode', async () => {
const { search, resolveSearch } = createSearchFnMock(0);
const initialRuntimeState = getInitialRuntimeState({
searchMock: search,
partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL },
});
const { Component, api } = await factory.buildEmbeddable(
getInitialRuntimeState({
searchMock: search,
partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL },
}),
initialRuntimeState,
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
jest.fn().mockImplementation((newApi) => newApi),
initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState
);
await waitOneTick(); // wait for build to complete
@ -178,15 +182,17 @@ describe('saved search embeddable', () => {
describe('search embeddable api', () => {
it('should not fetch data if only a new input title is set', async () => {
const { search, resolveSearch } = createSearchFnMock(1);
const initialRuntimeState = getInitialRuntimeState({
searchMock: search,
partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL },
});
const { api } = await factory.buildEmbeddable(
getInitialRuntimeState({
searchMock: search,
partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL },
}),
initialRuntimeState,
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
jest.fn().mockImplementation((newApi) => newApi),
initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState
);
await waitOneTick(); // wait for build to complete
@ -219,12 +225,14 @@ describe('saved search embeddable', () => {
discoverServiceMock.profilesManager,
'resolveRootProfile'
);
const initialRuntimeState = getInitialRuntimeState();
await factory.buildEmbeddable(
getInitialRuntimeState(),
initialRuntimeState,
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
jest.fn().mockImplementation((newApi) => newApi),
initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState
);
await waitOneTick(); // wait for build to complete
@ -238,12 +246,14 @@ describe('saved search embeddable', () => {
discoverServiceMock.profilesManager,
'resolveDataSourceProfile'
);
const initialRuntimeState = getInitialRuntimeState();
const { api } = await factory.buildEmbeddable(
getInitialRuntimeState(),
initialRuntimeState,
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
jest.fn().mockImplementation((newApi) => newApi),
initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState
);
await waitOneTick(); // wait for build to complete
@ -263,15 +273,17 @@ describe('saved search embeddable', () => {
it('should pass cell renderers from profile', async () => {
const { search, resolveSearch } = createSearchFnMock(1);
const initialRuntimeState = getInitialRuntimeState({
searchMock: search,
partialState: { columns: ['rootProfile', 'message', 'extension'] },
});
const { Component, api } = await factory.buildEmbeddable(
getInitialRuntimeState({
searchMock: search,
partialState: { columns: ['rootProfile', 'message', 'extension'] },
}),
initialRuntimeState,
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
jest.fn().mockImplementation((newApi) => newApi),
initialRuntimeState // initialRuntimeState only contains lastSavedRuntimeState
);
await waitOneTick(); // wait for build to complete

View file

@ -94,7 +94,8 @@ describe('react embeddable renderer', () => {
expect.any(Function),
expect.any(String),
expect.any(Object),
expect.any(Function)
expect.any(Function),
{ bork: 'blorp?' }
);
});
});
@ -120,7 +121,8 @@ describe('react embeddable renderer', () => {
expect.any(Function),
'12345',
expect.any(Object),
expect.any(Function)
expect.any(Function),
{ bork: 'blorp?' }
);
});
});
@ -142,7 +144,8 @@ describe('react embeddable renderer', () => {
expect.any(Function),
expect.any(String),
parentApi,
expect.any(Function)
expect.any(Function),
{ bork: 'blorp?' }
);
});
});

View file

@ -7,9 +7,11 @@
*/
import {
apiHasRuntimeChildState,
apiIsPresentationContainer,
HasSerializedChildState,
HasSnapshottableState,
initializeUnsavedChanges,
SerializedPanelState,
} from '@kbn/presentation-containers';
import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public';
@ -23,7 +25,6 @@ import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import { BehaviorSubject, combineLatest, debounceTime, skip, Subscription, switchMap } from 'rxjs';
import { v4 as generateId } from 'uuid';
import { getReactEmbeddableFactory } from './react_embeddable_registry';
import { initializeReactEmbeddableState } from './react_embeddable_state';
import {
BuildReactEmbeddableApiRegistration,
DefaultEmbeddableApi,
@ -115,11 +116,18 @@ export const ReactEmbeddableRenderer = <
};
const buildEmbeddable = async () => {
const { initialState, startStateDiffing } = await initializeReactEmbeddableState<
SerializedState,
RuntimeState,
Api
>(uuid, factory, parentApi);
const serializedState = parentApi.getSerializedStateForChild(uuid);
const lastSavedRuntimeState = serializedState
? await factory.deserializeState(serializedState)
: ({} as RuntimeState);
// If the parent provides runtime state for the child (usually as a state backup or cache),
// we merge it with the last saved runtime state.
const partialRuntimeState = apiHasRuntimeChildState<RuntimeState>(parentApi)
? parentApi.getRuntimeStateForChild(uuid) ?? ({} as Partial<RuntimeState>)
: ({} as Partial<RuntimeState>);
const initialRuntimeState = { ...lastSavedRuntimeState, ...partialRuntimeState };
const buildApi = (
apiRegistration: BuildReactEmbeddableApiRegistration<
@ -152,32 +160,34 @@ export const ReactEmbeddableRenderer = <
: Promise.resolve(apiRegistration.serializeState());
})
)
.subscribe((serializedState) => {
onAnyStateChange(serializedState);
.subscribe((nextSerializedState) => {
onAnyStateChange(nextSerializedState);
})
);
}
const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } =
startStateDiffing(comparators);
const unsavedChanges = initializeUnsavedChanges<RuntimeState>(
lastSavedRuntimeState,
parentApi,
comparators
);
const fullApi = setApi({
...apiRegistration,
unsavedChanges,
resetUnsavedChanges,
snapshotRuntimeState,
...unsavedChanges.api,
} as unknown as SetReactEmbeddableApiRegistration<SerializedState, RuntimeState, Api>);
cleanupFunction.current = () => cleanup();
cleanupFunction.current = () => unsavedChanges.cleanup();
return fullApi as Api & HasSnapshottableState<RuntimeState>;
};
const { api, Component } = await factory.buildEmbeddable(
initialState,
initialRuntimeState,
buildApi,
uuid,
parentApi,
setApi
setApi,
lastSavedRuntimeState
);
if (apiPublishesDataLoading(api)) {

View file

@ -1,193 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
HasRuntimeChildState,
HasSaveNotification,
HasSerializedChildState,
PresentationContainer,
} from '@kbn/presentation-containers';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { StateComparators } from '@kbn/presentation-publishing';
import { waitFor } from '@testing-library/react';
import { BehaviorSubject, Subject } from 'rxjs';
import { initializeReactEmbeddableState } from './react_embeddable_state';
import { ReactEmbeddableFactory } from './types';
interface SuperTestStateType {
name: string;
age: number;
tagline: string;
}
describe('react embeddable unsaved changes', () => {
let serializedStateForChild: SuperTestStateType;
let comparators: StateComparators<SuperTestStateType>;
let parentApi: PresentationContainer &
HasSerializedChildState<SuperTestStateType> &
Partial<HasRuntimeChildState<SuperTestStateType>> &
HasSaveNotification;
beforeEach(() => {
serializedStateForChild = {
name: 'Sir Testsalot',
age: 42,
tagline: `Oh he's a glutton for testing!`,
};
parentApi = {
saveNotification$: new Subject<void>(),
...getMockPresentationContainer(),
getSerializedStateForChild: () => ({ rawState: serializedStateForChild }),
getRuntimeStateForChild: () => undefined,
};
});
const initializeDefaultComparators = () => {
const latestState: SuperTestStateType = {
...serializedStateForChild,
...(parentApi.getRuntimeStateForChild?.('uuid') ?? {}),
};
const nameSubject = new BehaviorSubject<string>(latestState.name);
const ageSubject = new BehaviorSubject<number>(latestState.age);
const taglineSubject = new BehaviorSubject<string>(latestState.tagline);
const defaultComparators: StateComparators<SuperTestStateType> = {
name: [nameSubject, jest.fn((nextName) => nameSubject.next(nextName))],
age: [ageSubject, jest.fn((nextAge) => ageSubject.next(nextAge))],
tagline: [taglineSubject, jest.fn((nextTagline) => taglineSubject.next(nextTagline))],
};
return defaultComparators;
};
const startTrackingUnsavedChanges = async (
customComparators?: StateComparators<SuperTestStateType>
) => {
comparators = customComparators ?? initializeDefaultComparators();
const factory: ReactEmbeddableFactory<SuperTestStateType> = {
type: 'superTest',
deserializeState: jest.fn().mockImplementation((state) => state.rawState),
buildEmbeddable: async (runtimeState, buildApi) => {
const api = buildApi({ serializeState: jest.fn() }, comparators);
return { api, Component: () => null };
},
};
const { startStateDiffing } = await initializeReactEmbeddableState('uuid', factory, parentApi);
return startStateDiffing(comparators);
};
it('should return undefined unsaved changes when parent API does not provide runtime state', async () => {
const unsavedChangesApi = await startTrackingUnsavedChanges();
parentApi.getRuntimeStateForChild = undefined;
expect(unsavedChangesApi).toBeDefined();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
it('should return undefined unsaved changes when parent API does not have runtime state for this child', async () => {
const unsavedChangesApi = await startTrackingUnsavedChanges();
// no change here becuase getRuntimeStateForChild already returns undefined
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
it('should return unsaved changes subject initialized to undefined when no unsaved changes are detected', async () => {
parentApi.getRuntimeStateForChild = () => ({
name: 'Sir Testsalot',
age: 42,
tagline: `Oh he's a glutton for testing!`,
});
const unsavedChangesApi = await startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
it('should return unsaved changes subject initialized with diff when unsaved changes are detected', async () => {
parentApi.getRuntimeStateForChild = () => ({
tagline: 'Testing is my speciality!',
});
const unsavedChangesApi = await startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
tagline: 'Testing is my speciality!',
});
});
it('should detect unsaved changes when state changes during the lifetime of the component', async () => {
const unsavedChangesApi = await startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.tagline[1]('Testing is my speciality!');
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
tagline: 'Testing is my speciality!',
});
});
});
it('current runtime state should become last saved state when parent save notification is triggered', async () => {
const unsavedChangesApi = await startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.tagline[1]('Testing is my speciality!');
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
tagline: 'Testing is my speciality!',
});
});
parentApi.saveNotification$.next();
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
});
it('should reset unsaved changes, calling given setters with last saved values. This should remove all unsaved state', async () => {
const unsavedChangesApi = await startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.tagline[1]('Testing is my speciality!');
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
tagline: 'Testing is my speciality!',
});
});
unsavedChangesApi.resetUnsavedChanges();
expect(comparators.tagline[1]).toHaveBeenCalledWith(`Oh he's a glutton for testing!`);
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
});
it('uses a custom comparator when supplied', async () => {
serializedStateForChild.age = 20;
parentApi.getRuntimeStateForChild = () => ({
age: 50,
});
const ageSubject = new BehaviorSubject(50);
const customComparators: StateComparators<SuperTestStateType> = {
...initializeDefaultComparators(),
age: [
ageSubject,
jest.fn((nextAge) => ageSubject.next(nextAge)),
(lastAge, currentAge) => lastAge?.toString().length === currentAge?.toString().length,
],
};
const unsavedChangesApi = await startTrackingUnsavedChanges(customComparators);
// here we expect there to be no unsaved changes, both unsaved state and last saved state have two digits.
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.age[1](101);
await waitFor(() => {
// here we expect there to be unsaved changes, because now the latest state has three digits.
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
age: 101,
});
});
});
});

View file

@ -1,124 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
apiHasRuntimeChildState,
apiHasSaveNotification,
HasSerializedChildState,
} from '@kbn/presentation-containers';
import {
getInitialValuesFromComparators,
PublishingSubject,
runComparators,
StateComparators,
} from '@kbn/presentation-publishing';
import {
BehaviorSubject,
combineLatest,
combineLatestWith,
debounceTime,
map,
Subscription,
} from 'rxjs';
import { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types';
export const initializeReactEmbeddableState = async <
SerializedState extends object = object,
RuntimeState extends object = SerializedState,
Api extends DefaultEmbeddableApi<SerializedState, RuntimeState> = DefaultEmbeddableApi<
SerializedState,
RuntimeState
>
>(
uuid: string,
factory: ReactEmbeddableFactory<SerializedState, RuntimeState, Api>,
parentApi: HasSerializedChildState<SerializedState>
) => {
const serializedState = parentApi.getSerializedStateForChild(uuid);
const lastSavedRuntimeState = serializedState
? await factory.deserializeState(serializedState)
: ({} as RuntimeState);
// If the parent provides runtime state for the child (usually as a state backup or cache),
// we merge it with the last saved runtime state.
const partialRuntimeState = apiHasRuntimeChildState<RuntimeState>(parentApi)
? parentApi.getRuntimeStateForChild(uuid) ?? ({} as Partial<RuntimeState>)
: ({} as Partial<RuntimeState>);
const initialRuntimeState = { ...lastSavedRuntimeState, ...partialRuntimeState };
const startStateDiffing = (comparators: StateComparators<RuntimeState>) => {
const subscription = new Subscription();
const snapshotRuntimeState = () => {
const comparatorKeys = Object.keys(comparators) as Array<keyof RuntimeState>;
return comparatorKeys.reduce((acc, key) => {
acc[key] = comparators[key][0].value as RuntimeState[typeof key];
return acc;
}, {} as RuntimeState);
};
// the last saved state subject is always initialized with the deserialized state from the parent.
const lastSavedState$ = new BehaviorSubject<RuntimeState | undefined>(lastSavedRuntimeState);
if (apiHasSaveNotification(parentApi)) {
subscription.add(
// any time the parent saves, the current state becomes the last saved state...
parentApi.saveNotification$.subscribe(() => {
lastSavedState$.next(snapshotRuntimeState());
})
);
}
const comparatorSubjects: Array<PublishingSubject<unknown>> = [];
const comparatorKeys: Array<keyof RuntimeState> = [];
for (const key of Object.keys(comparators) as Array<keyof RuntimeState>) {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
comparatorSubjects.push(comparatorSubject as PublishingSubject<unknown>);
comparatorKeys.push(key);
}
const unsavedChanges = new BehaviorSubject<Partial<RuntimeState> | undefined>(
runComparators(
comparators,
comparatorKeys,
lastSavedState$.getValue() as RuntimeState,
getInitialValuesFromComparators(comparators, comparatorKeys)
)
);
subscription.add(
combineLatest(comparatorSubjects)
.pipe(
debounceTime(100),
map((latestStates) =>
comparatorKeys.reduce((acc, key, index) => {
acc[key] = latestStates[index] as RuntimeState[typeof key];
return acc;
}, {} as Partial<RuntimeState>)
),
combineLatestWith(lastSavedState$)
)
.subscribe(([latest, last]) => {
unsavedChanges.next(runComparators(comparators, comparatorKeys, last, latest));
})
);
return {
unsavedChanges,
resetUnsavedChanges: () => {
const lastSaved = lastSavedState$.getValue();
for (const key of comparatorKeys) {
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
setter(lastSaved?.[key] as RuntimeState[typeof key]);
}
},
snapshotRuntimeState,
cleanup: () => subscription.unsubscribe(),
};
};
return { initialState: initialRuntimeState, startStateDiffing };
};

View file

@ -106,7 +106,10 @@ export interface ReactEmbeddableFactory<
* function.
*/
buildEmbeddable: (
initialState: RuntimeState,
/**
* Initial runtime state. Composed from last saved state and previous sessions's unsaved changes
*/
initialRuntimeState: RuntimeState,
/**
* `buildApi` should be used by most embeddables that are used in dashboards, since it implements the unsaved
* changes logic that the dashboard expects using the provided comparators
@ -118,6 +121,11 @@ export interface ReactEmbeddableFactory<
uuid: string,
parentApi: unknown | undefined,
/** `setApi` should be used when the unsaved changes logic in `buildApi` is unnecessary */
setApi: (api: SetReactEmbeddableApiRegistration<SerializedState, RuntimeState, Api>) => Api
setApi: (api: SetReactEmbeddableApiRegistration<SerializedState, RuntimeState, Api>) => Api,
/**
* Last saved runtime state. Different from initialRuntimeState in that it does not contain previous sessions's unsaved changes
* Compare with initialRuntimeState to flag unsaved changes on load
*/
lastSavedRuntimeState: RuntimeState
) => Promise<{ Component: React.FC<{}>; api: Api }>;
}