mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
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:
parent
718f6c3d08
commit
cf1222f881
27 changed files with 934 additions and 581 deletions
|
@ -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';
|
||||
|
|
|
@ -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`}
|
||||
/>
|
|
@ -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));
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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>,
|
||||
};
|
||||
}
|
|
@ -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 */
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'>
|
||||
>,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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],
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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());
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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?' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue