mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[Embeddables] Serialized State Only (#215947)
Closes https://github.com/elastic/kibana/issues/205531 Closes #219877. Closes https://github.com/elastic/kibana/issues/213153 Closes https://github.com/elastic/kibana/issues/150920 Closes https://github.com/elastic/kibana/issues/203130 ### Overview The embeddable framework has two types of state: `SerializedState` and `RuntimeState`. `SerializedState` is the form of the state when saved into a Dashboard saved object. I.e. the References are extracted, and state saved externally (by reference) is removed. In contrast `RuntimeState` is an exact snapshot of the state used by the embeddable to render. <b>Exposing SerializedState and RuntimeState was a mistake</b> that caused numerous regressions and architectural complexities. This PR simplifies the embeddable framework by only exposing `SerializedState`. `RuntimeState` stays localized to the embeddable implementation and is never leaked to the embeddable framework. ### Whats changed * `ReactEmbeddableFactory<SerializedState, RuntimeState, Api>` => `EmbeddableFactory<SerializedState, Api>` * `deserializeState` removed from embeddable factory. Instead, `SerializedState` is passed directly into `buildEmbeddable`. * `buildEmbeddable` parameter `buildApi` replaced with `finalizeApi`. `buildApi({ api, comparators })` => `finalizeApi(api)`. * The embeddable framework previously used its knowledge of `RuntimeState` to setup and monitor unsaved changes. Now, unsaved changes setup is pushed down to the embeddable implementation since the embeddable framework no longer has knowledge of embeddable RuntimeState. ### Reviewer instructions <b>Please prioritize reviews.</b> This is a large effort from our team and is blocking many other initiatives. Getting this merged is a top priority. This is a large change that would best be reviewed by manually testing the changes * adding/editing your embeddable types * Ensuring dashboard shows unsaved changes as expected * Ensuring dashboard resets unsaved changes as expected * Ensuring dashboard does not show unsaved changes after save and reset * Returning to a dashboard with unsaved changes renders embeddables with those unsaved changes --------- Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com> Co-authored-by: Nathan Reese <reese.nathan@elastic.co> Co-authored-by: Nick Peihl <nick.peihl@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Catherine Liu <catherine.liu@elastic.co> Co-authored-by: Ola Pawlus <98127445+olapawlus@users.noreply.github.com>
This commit is contained in:
parent
2fd65fba64
commit
3e882d8cd9
295 changed files with 6901 additions and 6833 deletions
|
@ -68,7 +68,7 @@ export const EditExample = () => {
|
|||
localStorage.setItem(
|
||||
INPUT_KEY,
|
||||
JSON.stringify({
|
||||
...controlGroupAPI.snapshotRuntimeState(),
|
||||
...controlGroupAPI.getInput(),
|
||||
disabledActions: controlGroupAPI.disabledActionIds$.getValue(), // not part of runtime
|
||||
})
|
||||
);
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
|
||||
import { BehaviorSubject, combineLatest, of, Subject } from 'rxjs';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import {
|
||||
EuiBadge,
|
||||
|
@ -21,36 +21,25 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
EuiSuperDatePicker,
|
||||
EuiToolTip,
|
||||
OnTimeChangeProps,
|
||||
} from '@elastic/eui';
|
||||
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
|
||||
import { CONTROL_GROUP_TYPE, ControlGroupSerializedState } from '@kbn/controls-plugin/common';
|
||||
import { ControlGroupApi } from '@kbn/controls-plugin/public';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiPublishesDataLoading,
|
||||
HasUniqueId,
|
||||
PublishesDataLoading,
|
||||
SerializedPanelState,
|
||||
useBatchedPublishingSubjects,
|
||||
ViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
|
||||
import {
|
||||
clearControlGroupSerializedState,
|
||||
getControlGroupSerializedState,
|
||||
setControlGroupSerializedState,
|
||||
WEB_LOGS_DATA_VIEW_ID,
|
||||
} from './serialized_control_group_state';
|
||||
import {
|
||||
clearControlGroupRuntimeState,
|
||||
getControlGroupRuntimeState,
|
||||
setControlGroupRuntimeState,
|
||||
} from './runtime_control_group_state';
|
||||
import { savedStateManager, unsavedStateManager, WEB_LOGS_DATA_VIEW_ID } from './session_storage';
|
||||
|
||||
const toggleViewButtons = [
|
||||
{
|
||||
|
@ -65,6 +54,8 @@ const toggleViewButtons = [
|
|||
},
|
||||
];
|
||||
|
||||
const CONTROL_GROUP_EMBEDDABLE_ID = 'CONTROL_GROUP_EMBEDDABLE_ID';
|
||||
|
||||
export const ReactControlExample = ({
|
||||
core,
|
||||
dataViews: dataViewsService,
|
||||
|
@ -97,9 +88,6 @@ export const ReactControlExample = ({
|
|||
const viewMode$ = useMemo(() => {
|
||||
return new BehaviorSubject<ViewMode>('edit');
|
||||
}, []);
|
||||
const saveNotification$ = useMemo(() => {
|
||||
return new Subject<void>();
|
||||
}, []);
|
||||
const reload$ = useMemo(() => {
|
||||
return new Subject<void>();
|
||||
}, []);
|
||||
|
@ -114,9 +102,11 @@ export const ReactControlExample = ({
|
|||
const [dataViewNotFound, setDataViewNotFound] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const dashboardApi = useMemo(() => {
|
||||
const parentApi = useMemo(() => {
|
||||
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
|
||||
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
|
||||
const unsavedSavedControlGroupState = unsavedStateManager.get();
|
||||
const lastSavedControlGroupState = savedStateManager.get();
|
||||
const lastSavedControlGroupState$ = new BehaviorSubject(lastSavedControlGroupState);
|
||||
|
||||
return {
|
||||
dataLoading$,
|
||||
|
@ -126,29 +116,44 @@ export const ReactControlExample = ({
|
|||
query$,
|
||||
timeRange$,
|
||||
timeslice$,
|
||||
children$,
|
||||
publishFilters: (newFilters: Filter[] | undefined) => filters$.next(newFilters),
|
||||
setChild: (child: HasUniqueId) =>
|
||||
children$.next({ ...children$.getValue(), [child.uuid]: child }),
|
||||
removePanel: () => {},
|
||||
replacePanel: () => {
|
||||
return Promise.resolve('');
|
||||
},
|
||||
getPanelCount: () => {
|
||||
return 2;
|
||||
},
|
||||
addNewPanel: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
saveNotification$,
|
||||
reload$,
|
||||
getSerializedStateForChild: (childId: string) => {
|
||||
if (childId === CONTROL_GROUP_EMBEDDABLE_ID) {
|
||||
return unsavedSavedControlGroupState
|
||||
? unsavedSavedControlGroupState
|
||||
: lastSavedControlGroupState;
|
||||
}
|
||||
|
||||
return {
|
||||
rawState: {},
|
||||
references: [],
|
||||
};
|
||||
},
|
||||
lastSavedStateForChild$: (childId: string) => {
|
||||
return childId === CONTROL_GROUP_EMBEDDABLE_ID
|
||||
? lastSavedControlGroupState$
|
||||
: of(undefined);
|
||||
},
|
||||
getLastSavedStateForChild: (childId: string) => {
|
||||
return childId === CONTROL_GROUP_EMBEDDABLE_ID
|
||||
? lastSavedControlGroupState$.value
|
||||
: {
|
||||
rawState: {},
|
||||
references: [],
|
||||
};
|
||||
},
|
||||
setLastSavedControlGroupState: (
|
||||
savedState: SerializedPanelState<ControlGroupSerializedState>
|
||||
) => {
|
||||
lastSavedControlGroupState$.next(savedState);
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = combineCompatibleChildrenApis<PublishesDataLoading, boolean | undefined>(
|
||||
dashboardApi,
|
||||
parentApi,
|
||||
'dataLoading$',
|
||||
apiPublishesDataLoading,
|
||||
undefined,
|
||||
|
@ -163,7 +168,7 @@ export const ReactControlExample = ({
|
|||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [dashboardApi, dataLoading$]);
|
||||
}, [parentApi, dataLoading$]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
@ -244,25 +249,20 @@ export const ReactControlExample = ({
|
|||
};
|
||||
}, [controlGroupFilters$, filters$, unifiedSearchFilters$]);
|
||||
|
||||
const [unsavedChanges, setUnsavedChanges] = useState<string | undefined>(undefined);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!controlGroupApi) {
|
||||
return;
|
||||
}
|
||||
const subscription = controlGroupApi.unsavedChanges$.subscribe((nextUnsavedChanges) => {
|
||||
if (!nextUnsavedChanges) {
|
||||
clearControlGroupRuntimeState();
|
||||
setUnsavedChanges(undefined);
|
||||
const subscription = controlGroupApi.hasUnsavedChanges$.subscribe((nextHasUnsavedChanges) => {
|
||||
if (!nextHasUnsavedChanges) {
|
||||
unsavedStateManager.clear();
|
||||
setHasUnsavedChanges(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setControlGroupRuntimeState(nextUnsavedChanges);
|
||||
|
||||
// JSON.stringify removes keys where value is `undefined`
|
||||
// switch `undefined` to `null` to see when value has been cleared
|
||||
const replacer = (key: unknown, value: unknown) =>
|
||||
typeof value === 'undefined' ? null : value;
|
||||
setUnsavedChanges(JSON.stringify(nextUnsavedChanges, replacer, ' '));
|
||||
unsavedStateManager.set(controlGroupApi.serializeState());
|
||||
setHasUnsavedChanges(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
@ -283,8 +283,8 @@ export const ReactControlExample = ({
|
|||
color="accent"
|
||||
size="s"
|
||||
onClick={() => {
|
||||
clearControlGroupSerializedState();
|
||||
clearControlGroupRuntimeState();
|
||||
savedStateManager.clear();
|
||||
unsavedStateManager.clear();
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
|
@ -346,12 +346,10 @@ export const ReactControlExample = ({
|
|||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{unsavedChanges !== undefined && viewMode === 'edit' && (
|
||||
{hasUnsavedChanges && viewMode === 'edit' && (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip content={<pre>{unsavedChanges}</pre>}>
|
||||
<EuiBadge color="warning">Unsaved changes</EuiBadge>
|
||||
</EuiToolTip>
|
||||
<EuiBadge color="warning">Unsaved changes</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
|
@ -362,7 +360,7 @@ export const ReactControlExample = ({
|
|||
return;
|
||||
}
|
||||
setIsResetting(true);
|
||||
await controlGroupApi.asyncResetUnsavedChanges();
|
||||
await controlGroupApi.resetUnsavedChanges();
|
||||
if (isMounted()) setIsResetting(false);
|
||||
}}
|
||||
>
|
||||
|
@ -371,11 +369,15 @@ export const ReactControlExample = ({
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
disabled={!controlGroupApi}
|
||||
onClick={() => {
|
||||
if (controlGroupApi) {
|
||||
saveNotification$.next();
|
||||
setControlGroupSerializedState(controlGroupApi.serializeState());
|
||||
if (!controlGroupApi) {
|
||||
return;
|
||||
}
|
||||
const savedState = controlGroupApi.serializeState();
|
||||
parentApi.setLastSavedControlGroupState(savedState);
|
||||
savedStateManager.set(savedState);
|
||||
unsavedStateManager.clear();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
|
@ -400,37 +402,23 @@ export const ReactControlExample = ({
|
|||
}}
|
||||
/>
|
||||
{hasControls && <EuiSpacer size="m" />}
|
||||
<ReactEmbeddableRenderer
|
||||
<EmbeddableRenderer
|
||||
type={CONTROL_GROUP_TYPE}
|
||||
maybeId={CONTROL_GROUP_EMBEDDABLE_ID}
|
||||
onApiAvailable={(api) => {
|
||||
dashboardApi?.setChild(api);
|
||||
setControlGroupApi(api as ControlGroupApi);
|
||||
}}
|
||||
hidePanelChrome={true}
|
||||
type={CONTROL_GROUP_TYPE}
|
||||
getParentApi={() => ({
|
||||
...dashboardApi,
|
||||
getSerializedStateForChild: getControlGroupSerializedState,
|
||||
getRuntimeStateForChild: getControlGroupRuntimeState,
|
||||
})}
|
||||
getParentApi={() => parentApi}
|
||||
panelProps={{ hideLoader: true }}
|
||||
key={`control_group`}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
{isControlGroupInitialized && (
|
||||
<div style={{ height: '400px' }}>
|
||||
<ReactEmbeddableRenderer
|
||||
type={'data_table'}
|
||||
getParentApi={() => ({
|
||||
...dashboardApi,
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {},
|
||||
references: [],
|
||||
}),
|
||||
})}
|
||||
<EmbeddableRenderer
|
||||
type={'search_embeddable'}
|
||||
getParentApi={() => parentApi}
|
||||
hidePanelChrome={false}
|
||||
onApiAvailable={(api) => {
|
||||
dashboardApi?.setChild(api);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { ControlGroupRuntimeState } from '@kbn/controls-plugin/common';
|
||||
|
||||
const RUNTIME_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.controls.reactControlExample.controlGroupRuntimeState';
|
||||
|
||||
export function clearControlGroupRuntimeState() {
|
||||
sessionStorage.removeItem(RUNTIME_STATE_SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getControlGroupRuntimeState(): Partial<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));
|
||||
}
|
|
@ -15,80 +15,84 @@ import {
|
|||
TIME_SLIDER_CONTROL,
|
||||
} from '@kbn/controls-plugin/common';
|
||||
|
||||
const SERIALIZED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.controls.reactControlExample.controlGroupSerializedState';
|
||||
const SAVED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.controls.reactControlExample.controlGroupSavedState';
|
||||
const UNSAVED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.controls.reactControlExample.controlGroupUnsavedSavedState';
|
||||
export const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247';
|
||||
|
||||
export function clearControlGroupSerializedState() {
|
||||
sessionStorage.removeItem(SERIALIZED_STATE_SESSION_STORAGE_KEY);
|
||||
}
|
||||
export const savedStateManager = {
|
||||
clear: () => sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY),
|
||||
set: (serializedState: SerializedPanelState<ControlGroupSerializedState>) =>
|
||||
sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)),
|
||||
get: () => {
|
||||
const serializedStateJSON = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
return serializedStateJSON
|
||||
? JSON.parse(serializedStateJSON)
|
||||
: initialSerializedControlGroupState;
|
||||
},
|
||||
};
|
||||
|
||||
export function 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));
|
||||
}
|
||||
export const unsavedStateManager = {
|
||||
clear: () => sessionStorage.removeItem(UNSAVED_STATE_SESSION_STORAGE_KEY),
|
||||
set: (serializedState: SerializedPanelState<ControlGroupSerializedState>) =>
|
||||
sessionStorage.setItem(UNSAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)),
|
||||
get: () => {
|
||||
const serializedStateJSON = sessionStorage.getItem(UNSAVED_STATE_SESSION_STORAGE_KEY);
|
||||
return serializedStateJSON ? JSON.parse(serializedStateJSON) : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const optionsListId = 'optionsList1';
|
||||
const searchControlId = 'searchControl1';
|
||||
const rangeSliderControlId = 'rangeSliderControl1';
|
||||
const timesliderControlId = 'timesliderControl1';
|
||||
const controlGroupPanels = {
|
||||
[rangeSliderControlId]: {
|
||||
const controls = [
|
||||
{
|
||||
id: rangeSliderControlId,
|
||||
type: RANGE_SLIDER_CONTROL,
|
||||
order: 1,
|
||||
grow: true,
|
||||
width: 'medium',
|
||||
explicitInput: {
|
||||
id: rangeSliderControlId,
|
||||
controlConfig: {
|
||||
fieldName: 'bytes',
|
||||
title: 'Bytes',
|
||||
grow: true,
|
||||
width: 'medium',
|
||||
enhancements: {},
|
||||
},
|
||||
},
|
||||
[timesliderControlId]: {
|
||||
{
|
||||
id: timesliderControlId,
|
||||
type: TIME_SLIDER_CONTROL,
|
||||
order: 4,
|
||||
grow: true,
|
||||
width: 'medium',
|
||||
explicitInput: {
|
||||
id: timesliderControlId,
|
||||
title: 'Time slider',
|
||||
enhancements: {},
|
||||
},
|
||||
controlConfig: {},
|
||||
},
|
||||
[optionsListId]: {
|
||||
{
|
||||
id: optionsListId,
|
||||
type: OPTIONS_LIST_CONTROL,
|
||||
order: 2,
|
||||
grow: true,
|
||||
width: 'medium',
|
||||
explicitInput: {
|
||||
id: searchControlId,
|
||||
controlConfig: {
|
||||
fieldName: 'agent.keyword',
|
||||
title: 'Agent',
|
||||
grow: true,
|
||||
width: 'medium',
|
||||
enhancements: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
const initialSerializedControlGroupState = {
|
||||
rawState: {
|
||||
controlStyle: 'oneLine',
|
||||
labelPosition: 'oneLine',
|
||||
chainingSystem: 'HIERARCHICAL',
|
||||
showApplySelections: false,
|
||||
panelsJSON: JSON.stringify(controlGroupPanels),
|
||||
ignoreParentSettingsJSON:
|
||||
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
|
||||
} as object,
|
||||
autoApplySelections: true,
|
||||
controls,
|
||||
ignoreParentSettings: {
|
||||
ignoreFilters: false,
|
||||
ignoreQuery: false,
|
||||
ignoreTimerange: false,
|
||||
ignoreValidations: false,
|
||||
},
|
||||
} as ControlGroupSerializedState,
|
||||
references: [
|
||||
{
|
||||
name: `controlGroup_${rangeSliderControlId}:rangeSliderDataView`,
|
|
@ -17,7 +17,7 @@ import {
|
|||
EuiSuperDatePicker,
|
||||
} from '@elastic/eui';
|
||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { getPageApi } from '../page_api';
|
||||
import { AddButton } from './add_button';
|
||||
|
@ -36,9 +36,9 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
|
|||
};
|
||||
}, [cleanUp]);
|
||||
|
||||
const [dataLoading, panels, timeRange] = useBatchedPublishingSubjects(
|
||||
const [dataLoading, layout, timeRange] = useBatchedPublishingSubjects(
|
||||
pageApi.dataLoading$,
|
||||
componentApi.panels$,
|
||||
componentApi.layout$,
|
||||
pageApi.timeRange$
|
||||
);
|
||||
|
||||
|
@ -53,7 +53,7 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
|
|||
<p>
|
||||
New embeddable state is provided to the page by calling{' '}
|
||||
<strong>pageApi.addNewPanel</strong>. The page provides new embeddable state to the
|
||||
embeddable with <strong>pageApi.getRuntimeStateForChild</strong>.
|
||||
embeddable with <strong>pageApi.getSerializedStateForChild</strong>.
|
||||
</p>
|
||||
<p>
|
||||
This example uses session storage to persist saved state and unsaved changes while a
|
||||
|
@ -95,17 +95,17 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
|
|||
<TopNav
|
||||
onSave={componentApi.onSave}
|
||||
resetUnsavedChanges={pageApi.resetUnsavedChanges}
|
||||
unsavedChanges$={pageApi.unsavedChanges$}
|
||||
hasUnsavedChanges$={pageApi.hasUnsavedChanges$}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
{panels.map(({ id, type }) => {
|
||||
{layout.map(({ id, type }) => {
|
||||
return (
|
||||
<div key={id} style={{ height: '200' }}>
|
||||
<ReactEmbeddableRenderer
|
||||
<EmbeddableRenderer
|
||||
type={type}
|
||||
maybeId={id}
|
||||
getParentApi={() => pageApi}
|
||||
|
|
|
@ -14,8 +14,8 @@ import { PublishesUnsavedChanges } from '@kbn/presentation-publishing';
|
|||
|
||||
interface Props {
|
||||
onSave: () => Promise<void>;
|
||||
resetUnsavedChanges: () => void;
|
||||
unsavedChanges$: PublishesUnsavedChanges['unsavedChanges$'];
|
||||
resetUnsavedChanges: PublishesUnsavedChanges['resetUnsavedChanges'];
|
||||
hasUnsavedChanges$: PublishesUnsavedChanges['hasUnsavedChanges$'];
|
||||
}
|
||||
|
||||
export function TopNav(props: Props) {
|
||||
|
@ -23,14 +23,14 @@ export function TopNav(props: Props) {
|
|||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
useEffect(() => {
|
||||
const subscription = props.unsavedChanges$.subscribe((unsavedChanges) => {
|
||||
setHasUnsavedChanges(unsavedChanges !== undefined);
|
||||
const subscription = props.hasUnsavedChanges$.subscribe((nextHasUnsavedChanges) => {
|
||||
setHasUnsavedChanges(nextHasUnsavedChanges);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [props.unsavedChanges$]);
|
||||
}, [props.hasUnsavedChanges$]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject, Subject, combineLatest, map, merge } from 'rxjs';
|
||||
import { BehaviorSubject, Subject, combineLatest, map, merge, tap } from 'rxjs';
|
||||
import { v4 as generateId } from 'uuid';
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import {
|
||||
|
@ -19,54 +19,72 @@ import { isEqual, omit } from 'lodash';
|
|||
import {
|
||||
PublishesDataLoading,
|
||||
PublishingSubject,
|
||||
SerializedPanelState,
|
||||
ViewMode,
|
||||
apiHasSerializableState,
|
||||
apiPublishesDataLoading,
|
||||
apiPublishesUnsavedChanges,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { DEFAULT_STATE, lastSavedStateSessionStorage } from './session_storage/last_saved_state';
|
||||
import { lastSavedStateSessionStorage } from './session_storage/last_saved_state';
|
||||
import { unsavedChangesSessionStorage } from './session_storage/unsaved_changes';
|
||||
import { LastSavedState, PageApi, UnsavedChanges } from './types';
|
||||
import { PageApi, PageState } from './types';
|
||||
|
||||
function deserializePanels(panels: PageState['panels']) {
|
||||
const layout: Array<{ id: string; type: string }> = [];
|
||||
const childState: { [uuid: string]: SerializedPanelState | undefined } = {};
|
||||
panels.forEach(({ id, type, serializedState }) => {
|
||||
layout.push({ id, type });
|
||||
childState[id] = serializedState;
|
||||
});
|
||||
return { layout, childState };
|
||||
}
|
||||
|
||||
export function getPageApi() {
|
||||
const initialUnsavedChanges = unsavedChangesSessionStorage.load();
|
||||
const initialSavedState = lastSavedStateSessionStorage.load();
|
||||
let newPanels: Record<string, object> = {};
|
||||
const lastSavedState$ = new BehaviorSubject<
|
||||
LastSavedState & { panels: Array<{ id: string; type: string }> }
|
||||
>({
|
||||
...initialSavedState,
|
||||
panels: initialSavedState.panelsState.map(({ id, type }) => {
|
||||
return { id, type };
|
||||
}),
|
||||
});
|
||||
const initialUnsavedState = unsavedChangesSessionStorage.load();
|
||||
const initialState = lastSavedStateSessionStorage.load();
|
||||
const lastSavedState$ = new BehaviorSubject<PageState>(initialState);
|
||||
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
|
||||
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
|
||||
const panels$ = new BehaviorSubject<Array<{ id: string; type: string }>>(
|
||||
initialUnsavedChanges.panels ?? lastSavedState$.value.panels
|
||||
);
|
||||
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(
|
||||
initialUnsavedChanges.timeRange ?? initialSavedState.timeRange
|
||||
const { layout: initialLayout, childState: initialChildState } = deserializePanels(
|
||||
initialUnsavedState?.panels ?? lastSavedState$.value.panels
|
||||
);
|
||||
const layout$ = new BehaviorSubject<Array<{ id: string; type: string }>>(initialLayout);
|
||||
let currentChildState = initialChildState; // childState is the source of truth for the state of each panel.
|
||||
|
||||
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
|
||||
const timeRange$ = new BehaviorSubject<TimeRange>(
|
||||
initialUnsavedState?.timeRange ?? initialState.timeRange
|
||||
);
|
||||
const reload$ = new Subject<void>();
|
||||
|
||||
const saveNotification$ = new Subject<void>();
|
||||
function serializePage() {
|
||||
return {
|
||||
timeRange: timeRange$.value,
|
||||
panels: layout$.value.map((layout) => ({
|
||||
...layout,
|
||||
serializedState: currentChildState[layout.id],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function untilChildLoaded(childId: string): unknown | Promise<unknown | undefined> {
|
||||
function getLastSavedStateForChild(childId: string) {
|
||||
const panel = lastSavedState$.value.panels.find(({ id }) => id === childId);
|
||||
return panel?.serializedState;
|
||||
}
|
||||
|
||||
async function getChildApi(childId: string): Promise<unknown | undefined> {
|
||||
if (children$.value[childId]) {
|
||||
return children$.value[childId];
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const subscription = merge(children$, panels$).subscribe(() => {
|
||||
const subscription = merge(children$, layout$).subscribe(() => {
|
||||
if (children$.value[childId]) {
|
||||
subscription.unsubscribe();
|
||||
resolve(children$.value[childId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const panelExists = panels$.value.some(({ id }) => id === childId);
|
||||
const panelExists = layout$.value.some(({ id }) => id === childId);
|
||||
if (!panelExists) {
|
||||
// panel removed before finished loading.
|
||||
subscription.unsubscribe();
|
||||
|
@ -92,49 +110,57 @@ export function getPageApi() {
|
|||
dataLoading$.next(isAtLeastOneChildLoading);
|
||||
});
|
||||
|
||||
// One could use `initializeUnsavedChanges` to set up unsaved changes observable.
|
||||
// Instead, decided to manually setup unsaved changes observable
|
||||
// since only timeRange and panels array need to be monitored.
|
||||
const timeRangeUnsavedChanges$ = combineLatest([timeRange$, lastSavedState$]).pipe(
|
||||
const hasTimeRangeChanges$ = combineLatest([timeRange$, lastSavedState$]).pipe(
|
||||
map(([currentTimeRange, lastSavedState]) => {
|
||||
const hasChanges = !isEqual(currentTimeRange, lastSavedState.timeRange);
|
||||
return hasChanges ? { timeRange: currentTimeRange } : undefined;
|
||||
return !isEqual(currentTimeRange, lastSavedState.timeRange);
|
||||
})
|
||||
);
|
||||
const panelsUnsavedChanges$ = combineLatest([panels$, lastSavedState$]).pipe(
|
||||
map(([currentPanels, lastSavedState]) => {
|
||||
const hasChanges = !isEqual(currentPanels, lastSavedState.panels);
|
||||
return hasChanges ? { panels: currentPanels } : undefined;
|
||||
})
|
||||
);
|
||||
const unsavedChanges$ = combineLatest([
|
||||
timeRangeUnsavedChanges$,
|
||||
panelsUnsavedChanges$,
|
||||
childrenUnsavedChanges$(children$),
|
||||
const hasLayoutChanges$ = combineLatest([
|
||||
layout$,
|
||||
lastSavedState$.pipe(map((lastSavedState) => deserializePanels(lastSavedState.panels).layout)),
|
||||
]).pipe(
|
||||
map(([timeRangeUnsavedChanges, panelsChanges, childrenUnsavedChanges]) => {
|
||||
const nextUnsavedChanges: UnsavedChanges = {};
|
||||
if (timeRangeUnsavedChanges) {
|
||||
nextUnsavedChanges.timeRange = timeRangeUnsavedChanges.timeRange;
|
||||
map(([currentLayout, lastSavedLayout]) => {
|
||||
return !isEqual(currentLayout, lastSavedLayout);
|
||||
})
|
||||
);
|
||||
const hasPanelChanges$ = childrenUnsavedChanges$(children$).pipe(
|
||||
tap((childrenWithChanges) => {
|
||||
// propagate the latest serialized state back to currentChildState.
|
||||
for (const { uuid, hasUnsavedChanges } of childrenWithChanges) {
|
||||
const childApi = children$.value[uuid];
|
||||
if (hasUnsavedChanges && apiHasSerializableState(childApi)) {
|
||||
currentChildState[uuid] = childApi.serializeState();
|
||||
}
|
||||
}
|
||||
if (panelsChanges) {
|
||||
nextUnsavedChanges.panels = panelsChanges.panels;
|
||||
}
|
||||
if (childrenUnsavedChanges) {
|
||||
nextUnsavedChanges.panelUnsavedChanges = childrenUnsavedChanges;
|
||||
}
|
||||
return Object.keys(nextUnsavedChanges).length ? nextUnsavedChanges : undefined;
|
||||
}),
|
||||
map((childrenWithChanges) => {
|
||||
return childrenWithChanges.some(({ hasUnsavedChanges }) => hasUnsavedChanges);
|
||||
})
|
||||
);
|
||||
|
||||
const unsavedChangesSubscription = unsavedChanges$.subscribe((nextUnsavedChanges) => {
|
||||
unsavedChangesSessionStorage.save(nextUnsavedChanges ?? {});
|
||||
const hasUnsavedChanges$ = combineLatest([
|
||||
hasTimeRangeChanges$,
|
||||
hasLayoutChanges$,
|
||||
hasPanelChanges$,
|
||||
]).pipe(
|
||||
map(([hasTimeRangeChanges, hasLayoutChanges, hasPanelChanges]) => {
|
||||
return hasTimeRangeChanges || hasLayoutChanges || hasPanelChanges;
|
||||
})
|
||||
);
|
||||
|
||||
const hasUnsavedChangesSubscription = hasUnsavedChanges$.subscribe((hasUnsavedChanges) => {
|
||||
if (!hasUnsavedChanges) {
|
||||
unsavedChangesSessionStorage.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
unsavedChangesSessionStorage.save(serializePage());
|
||||
});
|
||||
|
||||
return {
|
||||
cleanUp: () => {
|
||||
childrenDataLoadingSubscripiton.unsubscribe();
|
||||
unsavedChangesSubscription.unsubscribe();
|
||||
hasUnsavedChangesSubscription.unsubscribe();
|
||||
},
|
||||
/**
|
||||
* api's needed by component that should not be shared with children
|
||||
|
@ -144,36 +170,14 @@ export function getPageApi() {
|
|||
reload$.next();
|
||||
},
|
||||
onSave: async () => {
|
||||
const panelsState: LastSavedState['panelsState'] = [];
|
||||
panels$.value.forEach(({ id, type }) => {
|
||||
try {
|
||||
const childApi = children$.value[id];
|
||||
if (apiHasSerializableState(childApi)) {
|
||||
panelsState.push({
|
||||
id,
|
||||
type,
|
||||
panelState: childApi.serializeState(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Unable to serialize panel state, just ignore since this is an example
|
||||
}
|
||||
});
|
||||
|
||||
const savedState = {
|
||||
timeRange: timeRange$.value ?? DEFAULT_STATE.timeRange,
|
||||
panelsState,
|
||||
};
|
||||
lastSavedState$.next({
|
||||
...savedState,
|
||||
panels: panelsState.map(({ id, type }) => {
|
||||
return { id, type };
|
||||
}),
|
||||
});
|
||||
lastSavedStateSessionStorage.save(savedState);
|
||||
saveNotification$.next();
|
||||
const serializedPage = serializePage();
|
||||
// simulate save await
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
lastSavedState$.next(serializedPage);
|
||||
lastSavedStateSessionStorage.save(serializedPage);
|
||||
unsavedChangesSessionStorage.clear();
|
||||
},
|
||||
panels$,
|
||||
layout$,
|
||||
setChild: (id: string, api: unknown) => {
|
||||
children$.next({
|
||||
...children$.value,
|
||||
|
@ -185,20 +189,21 @@ export function getPageApi() {
|
|||
},
|
||||
},
|
||||
pageApi: {
|
||||
addNewPanel: async ({ panelType, initialState }: PanelPackage) => {
|
||||
addNewPanel: async ({ panelType, serializedState }: PanelPackage) => {
|
||||
const id = generateId();
|
||||
panels$.next([...panels$.value, { id, type: panelType }]);
|
||||
newPanels[id] = initialState ?? {};
|
||||
return await untilChildLoaded(id);
|
||||
layout$.next([...layout$.value, { id, type: panelType }]);
|
||||
currentChildState[id] = serializedState;
|
||||
return await getChildApi(id);
|
||||
},
|
||||
canRemovePanels: () => true,
|
||||
getChildApi,
|
||||
children$,
|
||||
dataLoading$,
|
||||
executionContext: {
|
||||
type: 'presentationContainerEmbeddableExample',
|
||||
},
|
||||
getPanelCount: () => {
|
||||
return panels$.value.length;
|
||||
return layout$.value.length;
|
||||
},
|
||||
replacePanel: async (idToRemove: string, newPanel: PanelPackage<object>) => {
|
||||
// TODO remove method from interface? It should not be required
|
||||
|
@ -206,53 +211,41 @@ export function getPageApi() {
|
|||
},
|
||||
reload$: reload$ as unknown as PublishingSubject<void>,
|
||||
removePanel: (id: string) => {
|
||||
panels$.next(panels$.value.filter(({ id: panelId }) => panelId !== id));
|
||||
layout$.next(layout$.value.filter(({ id: panelId }) => panelId !== id));
|
||||
children$.next(omit(children$.value, id));
|
||||
delete currentChildState[id];
|
||||
},
|
||||
saveNotification$,
|
||||
viewMode$: new BehaviorSubject<ViewMode>('edit'),
|
||||
/**
|
||||
* return last saved embeddable state
|
||||
*/
|
||||
getSerializedStateForChild: (childId: string) => {
|
||||
const panel = initialSavedState.panelsState.find(({ id }) => {
|
||||
return id === childId;
|
||||
});
|
||||
return panel ? panel.panelState : undefined;
|
||||
},
|
||||
/**
|
||||
* return previous session's unsaved changes for embeddable
|
||||
*/
|
||||
getRuntimeStateForChild: (childId: string) => {
|
||||
return newPanels[childId] ?? initialUnsavedChanges.panelUnsavedChanges?.[childId];
|
||||
},
|
||||
getSerializedStateForChild: (childId: string) => currentChildState[childId],
|
||||
lastSavedStateForChild$: (panelId: string) =>
|
||||
lastSavedState$.pipe(map(() => getLastSavedStateForChild(panelId))),
|
||||
getLastSavedStateForChild,
|
||||
resetUnsavedChanges: () => {
|
||||
timeRange$.next(lastSavedState$.value.timeRange);
|
||||
panels$.next(lastSavedState$.value.panels);
|
||||
lastSavedState$.value.panels.forEach(({ id }) => {
|
||||
const childApi = children$.value[id];
|
||||
if (apiPublishesUnsavedChanges(childApi)) {
|
||||
childApi.resetUnsavedChanges();
|
||||
const lastSavedState = lastSavedState$.value;
|
||||
timeRange$.next(lastSavedState.timeRange);
|
||||
const { layout: lastSavedLayout, childState: lastSavedChildState } = deserializePanels(
|
||||
lastSavedState.panels
|
||||
);
|
||||
layout$.next(lastSavedLayout);
|
||||
currentChildState = lastSavedChildState;
|
||||
let childrenModified = false;
|
||||
const currentChildren = { ...children$.value };
|
||||
for (const uuid of Object.keys(currentChildren)) {
|
||||
const existsInLastSavedLayout = lastSavedLayout.some(({ id }) => id === uuid);
|
||||
if (existsInLastSavedLayout) {
|
||||
const child = currentChildren[uuid];
|
||||
if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges();
|
||||
} else {
|
||||
// if reset resulted in panel removal, we need to update the list of children
|
||||
delete currentChildren[uuid];
|
||||
delete currentChildState[uuid];
|
||||
childrenModified = true;
|
||||
}
|
||||
});
|
||||
const nextPanelIds = lastSavedState$.value.panels.map(({ id }) => id);
|
||||
const children = { ...children$.value };
|
||||
let modifiedChildren = false;
|
||||
Object.keys(children).forEach((controlId) => {
|
||||
if (!nextPanelIds.includes(controlId)) {
|
||||
// remove children that no longer exist after reset
|
||||
delete children[controlId];
|
||||
modifiedChildren = true;
|
||||
}
|
||||
});
|
||||
if (modifiedChildren) {
|
||||
children$.next(children);
|
||||
}
|
||||
newPanels = {};
|
||||
return true;
|
||||
if (childrenModified) children$.next(currentChildren);
|
||||
},
|
||||
timeRange$,
|
||||
unsavedChanges$: unsavedChanges$ as PublishingSubject<object | undefined>,
|
||||
hasUnsavedChanges$,
|
||||
} as PageApi,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -7,28 +7,28 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { LastSavedState } from '../types';
|
||||
import { PageState } from '../types';
|
||||
|
||||
const SAVED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.presentationContainerExample.savedState';
|
||||
|
||||
export const DEFAULT_STATE: LastSavedState = {
|
||||
export const DEFAULT_STATE: PageState = {
|
||||
timeRange: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
panelsState: [],
|
||||
panels: [],
|
||||
};
|
||||
|
||||
export const lastSavedStateSessionStorage = {
|
||||
clear: () => {
|
||||
sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
},
|
||||
load: (): LastSavedState => {
|
||||
load: (): PageState => {
|
||||
const savedState = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
return savedState ? JSON.parse(savedState) : { ...DEFAULT_STATE };
|
||||
},
|
||||
save: (state: LastSavedState) => {
|
||||
save: (state: PageState) => {
|
||||
sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(state));
|
||||
},
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { UnsavedChanges } from '../types';
|
||||
import { PageState } from '../types';
|
||||
|
||||
const UNSAVED_CHANGES_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.presentationContainerExample.unsavedChanges';
|
||||
|
@ -16,11 +16,11 @@ export const unsavedChangesSessionStorage = {
|
|||
clear: () => {
|
||||
sessionStorage.removeItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
|
||||
},
|
||||
load: (): UnsavedChanges => {
|
||||
load: (): PageState | undefined => {
|
||||
const unsavedChanges = sessionStorage.getItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
|
||||
return unsavedChanges ? JSON.parse(unsavedChanges) : {};
|
||||
return unsavedChanges ? JSON.parse(unsavedChanges) : undefined;
|
||||
},
|
||||
save: (unsavedChanges: UnsavedChanges) => {
|
||||
save: (unsavedChanges: PageState) => {
|
||||
sessionStorage.setItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY, JSON.stringify(unsavedChanges));
|
||||
},
|
||||
};
|
||||
|
|
|
@ -10,10 +10,9 @@
|
|||
import { TimeRange } from '@kbn/es-query';
|
||||
import {
|
||||
CanAddNewPanel,
|
||||
HasLastSavedChildState,
|
||||
HasSerializedChildState,
|
||||
HasRuntimeChildState,
|
||||
PresentationContainer,
|
||||
HasSaveNotification,
|
||||
} from '@kbn/presentation-containers';
|
||||
import {
|
||||
HasExecutionContext,
|
||||
|
@ -28,22 +27,15 @@ import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/p
|
|||
export type PageApi = PresentationContainer &
|
||||
CanAddNewPanel &
|
||||
HasExecutionContext &
|
||||
HasSaveNotification &
|
||||
HasLastSavedChildState &
|
||||
HasSerializedChildState &
|
||||
HasRuntimeChildState &
|
||||
PublishesDataLoading &
|
||||
PublishesViewMode &
|
||||
PublishesReload &
|
||||
PublishesTimeRange &
|
||||
PublishesUnsavedChanges;
|
||||
|
||||
export interface LastSavedState {
|
||||
export interface PageState {
|
||||
timeRange: TimeRange;
|
||||
panelsState: Array<{ id: string; type: string; panelState: SerializedPanelState }>;
|
||||
}
|
||||
|
||||
export interface UnsavedChanges {
|
||||
timeRange?: TimeRange;
|
||||
panels?: Array<{ id: string; type: string }>;
|
||||
panelUnsavedChanges?: Record<string, object>;
|
||||
panels: Array<{ id: string; type: string; serializedState: SerializedPanelState | undefined }>;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
EuiCodeBlock,
|
||||
EuiFlexGroup,
|
||||
|
@ -24,7 +24,7 @@ import { BehaviorSubject, Subject } from 'rxjs';
|
|||
import { TimeRange } from '@kbn/es-query';
|
||||
import { useBatchedOptionalPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { SearchEmbeddableRenderer } from '../react_embeddables/search/search_embeddable_renderer';
|
||||
import { SEARCH_EMBEDDABLE_ID } from '../react_embeddables/search/constants';
|
||||
import { SEARCH_EMBEDDABLE_TYPE } from '../react_embeddables/search/constants';
|
||||
import type { SearchApi, SearchSerializedState } from '../react_embeddables/search/types';
|
||||
|
||||
export const RenderExamples = () => {
|
||||
|
@ -74,13 +74,13 @@ export const RenderExamples = () => {
|
|||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<p>
|
||||
Use <strong>ReactEmbeddableRenderer</strong> to render embeddables.
|
||||
Use <strong>EmbeddableRenderer</strong> to render embeddables.
|
||||
</p>
|
||||
</EuiText>
|
||||
|
||||
<EuiCodeBlock language="jsx" fontSize="m" paddingSize="m">
|
||||
{`<ReactEmbeddableRenderer<State, Api>
|
||||
type={SEARCH_EMBEDDABLE_ID}
|
||||
{`<EmbeddableRenderer<State, Api>
|
||||
type={SEARCH_EMBEDDABLE_TYPE}
|
||||
getParentApi={() => parentApi}
|
||||
onApiAvailable={(newApi) => {
|
||||
setApi(newApi);
|
||||
|
@ -99,9 +99,9 @@ export const RenderExamples = () => {
|
|||
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<ReactEmbeddableRenderer<SearchSerializedState, SearchSerializedState, SearchApi>
|
||||
<EmbeddableRenderer<SearchSerializedState, SearchApi>
|
||||
key={hidePanelChrome ? 'hideChrome' : 'showChrome'}
|
||||
type={SEARCH_EMBEDDABLE_ID}
|
||||
type={SEARCH_EMBEDDABLE_TYPE}
|
||||
getParentApi={() => parentApi}
|
||||
onApiAvailable={(newApi) => {
|
||||
setApi(newApi);
|
||||
|
@ -112,7 +112,7 @@ export const RenderExamples = () => {
|
|||
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<p>To avoid leaking embeddable details, wrap ReactEmbeddableRenderer in a component.</p>
|
||||
<p>To avoid leaking embeddable details, wrap EmbeddableRenderer in a component.</p>
|
||||
</EuiText>
|
||||
|
||||
<EuiCodeBlock language="jsx" fontSize="m" paddingSize="m">
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { SerializedPanelState } from '@kbn/presentation-publishing';
|
||||
import { BookSerializedState } from '../../react_embeddables/saved_book/types';
|
||||
|
||||
const SAVED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.stateManagementExample.savedState';
|
||||
|
||||
export const lastSavedStateSessionStorage = {
|
||||
clear: () => {
|
||||
sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
},
|
||||
load: (): SerializedPanelState<BookSerializedState> | undefined => {
|
||||
const savedState = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
return savedState ? JSON.parse(savedState) : undefined;
|
||||
},
|
||||
save: (state: SerializedPanelState<BookSerializedState>) => {
|
||||
sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(state));
|
||||
},
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { SerializedPanelState } from '@kbn/presentation-publishing';
|
||||
import { BookSerializedState } from '../../react_embeddables/saved_book/types';
|
||||
|
||||
const SAVED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.stateManagementExample.savedState';
|
||||
const UNSAVED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.stateManagementExample.unsavedSavedState';
|
||||
export const WEB_LOGS_DATA_VIEW_ID = '90943e30-9a47-11e8-b64d-95841ca0b247';
|
||||
|
||||
export const savedStateManager = {
|
||||
clear: () => sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY),
|
||||
set: (serializedState: SerializedPanelState<BookSerializedState>) =>
|
||||
sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)),
|
||||
get: () => {
|
||||
const serializedStateJSON = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
return serializedStateJSON ? JSON.parse(serializedStateJSON) : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const unsavedStateManager = {
|
||||
clear: () => sessionStorage.removeItem(UNSAVED_STATE_SESSION_STORAGE_KEY),
|
||||
set: (serializedState: SerializedPanelState<BookSerializedState>) =>
|
||||
sessionStorage.setItem(UNSAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(serializedState)),
|
||||
get: () => {
|
||||
const serializedStateJSON = sessionStorage.getItem(UNSAVED_STATE_SESSION_STORAGE_KEY);
|
||||
return serializedStateJSON ? JSON.parse(serializedStateJSON) : undefined;
|
||||
},
|
||||
};
|
|
@ -18,34 +18,66 @@ import {
|
|||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { SerializedPanelState, ViewMode } from '@kbn/presentation-publishing';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { SAVED_BOOK_ID } from '../../react_embeddables/saved_book/constants';
|
||||
import {
|
||||
BookApi,
|
||||
BookRuntimeState,
|
||||
BookSerializedState,
|
||||
} from '../../react_embeddables/saved_book/types';
|
||||
import { lastSavedStateSessionStorage } from './last_saved_state';
|
||||
import { unsavedChangesSessionStorage } from './unsaved_changes';
|
||||
import { BookApi, BookSerializedState } from '../../react_embeddables/saved_book/types';
|
||||
import { savedStateManager, unsavedStateManager } from './session_storage';
|
||||
|
||||
const BOOK_EMBEDDABLE_ID = 'BOOK_EMBEDDABLE_ID';
|
||||
|
||||
export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStart }) => {
|
||||
const saveNotification$ = useMemo(() => {
|
||||
return new Subject<void>();
|
||||
}, []);
|
||||
|
||||
const [bookApi, setBookApi] = useState<BookApi | undefined>();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const parentApi = useMemo(() => {
|
||||
const unsavedSavedBookState = unsavedStateManager.get();
|
||||
const lastSavedbookState = savedStateManager.get();
|
||||
const lastSavedBookState$ = new BehaviorSubject(lastSavedbookState);
|
||||
|
||||
return {
|
||||
viewMode$: new BehaviorSubject<ViewMode>('edit'),
|
||||
getSerializedStateForChild: (childId: string) => {
|
||||
if (childId === BOOK_EMBEDDABLE_ID) {
|
||||
return unsavedSavedBookState ? unsavedSavedBookState : lastSavedbookState;
|
||||
}
|
||||
|
||||
return {
|
||||
rawState: {},
|
||||
references: [],
|
||||
};
|
||||
},
|
||||
lastSavedStateForChild$: (childId: string) => {
|
||||
return childId === BOOK_EMBEDDABLE_ID ? lastSavedBookState$ : of(undefined);
|
||||
},
|
||||
getLastSavedStateForChild: (childId: string) => {
|
||||
return childId === BOOK_EMBEDDABLE_ID
|
||||
? lastSavedBookState$.value
|
||||
: {
|
||||
rawState: {},
|
||||
references: [],
|
||||
};
|
||||
},
|
||||
setLastSavedBookState: (savedState: SerializedPanelState<BookSerializedState>) => {
|
||||
lastSavedBookState$.next(savedState);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookApi || !bookApi.unsavedChanges$) {
|
||||
if (!bookApi) {
|
||||
return;
|
||||
}
|
||||
const subscription = bookApi.unsavedChanges$.subscribe((unsavedChanges) => {
|
||||
setHasUnsavedChanges(unsavedChanges !== undefined);
|
||||
unsavedChangesSessionStorage.save(unsavedChanges ?? {});
|
||||
const subscription = bookApi.hasUnsavedChanges$.subscribe((nextHasUnsavedChanges) => {
|
||||
if (!nextHasUnsavedChanges) {
|
||||
unsavedStateManager.clear();
|
||||
setHasUnsavedChanges(false);
|
||||
return;
|
||||
}
|
||||
|
||||
unsavedStateManager.set(bookApi.serializeState());
|
||||
setHasUnsavedChanges(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
@ -58,38 +90,38 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar
|
|||
<EuiCallOut>
|
||||
<p>
|
||||
Each embeddable manages its own state. The page is only responsible for persisting and
|
||||
providing the last persisted state to the embeddable.
|
||||
providing the last saved state or last unsaved state to the embeddable.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The page renders the embeddable with <strong>ReactEmbeddableRenderer</strong> component.
|
||||
On mount, ReactEmbeddableRenderer component calls{' '}
|
||||
<strong>pageApi.getSerializedStateForChild</strong> to get the last saved state.
|
||||
ReactEmbeddableRenderer component then calls{' '}
|
||||
<strong>pageApi.getRuntimeStateForChild</strong> to get the last session's unsaved
|
||||
changes. ReactEmbeddableRenderer merges last saved state with unsaved changes and passes
|
||||
the merged state to the embeddable factory. ReactEmbeddableRender passes the embeddableApi
|
||||
to the page by calling <strong>onApiAvailable</strong>.
|
||||
The page renders the embeddable with <strong>EmbeddableRenderer</strong> component.
|
||||
EmbeddableRender passes the embeddableApi to the page by calling{' '}
|
||||
<strong>onApiAvailable</strong>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The page subscribes to <strong>embeddableApi.unsavedChanges</strong> to receive embeddable
|
||||
The page subscribes to <strong>embeddableApi.hasUnsavedChanges</strong> to by notified of
|
||||
unsaved changes. The page persists unsaved changes in session storage. The page provides
|
||||
unsaved changes to the embeddable with <strong>pageApi.getRuntimeStateForChild</strong>.
|
||||
unsaved changes to the embeddable with <strong>pageApi.getSerializedStateForChild</strong>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The page gets embeddable state by calling <strong>embeddableApi.serializeState</strong>.
|
||||
The page persists embeddable state in session storage. The page provides last saved state
|
||||
to the embeddable with <strong>pageApi.getSerializedStateForChild</strong>.
|
||||
The page persists embeddable state in session storage.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The page provides unsaved state or last saved state to the embeddable with{' '}
|
||||
<strong>pageApi.getSerializedStateForChild</strong>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<EuiButtonEmpty
|
||||
color={'warning'}
|
||||
onClick={() => {
|
||||
lastSavedStateSessionStorage.clear();
|
||||
unsavedChangesSessionStorage.clear();
|
||||
savedStateManager.clear();
|
||||
unsavedStateManager.clear();
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
|
@ -108,9 +140,9 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
disabled={isSaving || !bookApi}
|
||||
disabled={!bookApi}
|
||||
onClick={() => {
|
||||
bookApi?.resetUnsavedChanges?.();
|
||||
bookApi?.resetUnsavedChanges();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
|
@ -120,18 +152,16 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar
|
|||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
disabled={!hasUnsavedChanges}
|
||||
onClick={() => {
|
||||
if (!bookApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
const bookSerializedState = bookApi.serializeState();
|
||||
lastSavedStateSessionStorage.save(bookSerializedState);
|
||||
saveNotification$.next(); // signals embeddable unsaved change tracking to update last saved state
|
||||
setHasUnsavedChanges(false);
|
||||
setIsSaving(false);
|
||||
const savedState = bookApi.serializeState();
|
||||
parentApi.setLastSavedBookState(savedState);
|
||||
savedStateManager.set(savedState);
|
||||
unsavedStateManager.clear();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
|
@ -141,26 +171,10 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar
|
|||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<ReactEmbeddableRenderer<BookSerializedState, BookRuntimeState, BookApi>
|
||||
<EmbeddableRenderer<BookSerializedState, BookApi>
|
||||
type={SAVED_BOOK_ID}
|
||||
getParentApi={() => {
|
||||
return {
|
||||
/**
|
||||
* return last saved embeddable state
|
||||
*/
|
||||
getSerializedStateForChild: (childId: string) => {
|
||||
return lastSavedStateSessionStorage.load();
|
||||
},
|
||||
/**
|
||||
* return previous session's unsaved changes for embeddable
|
||||
*/
|
||||
getRuntimeStateForChild: (childId: string) => {
|
||||
return unsavedChangesSessionStorage.load();
|
||||
},
|
||||
saveNotification$,
|
||||
viewMode$: new BehaviorSubject<ViewMode>('edit'),
|
||||
};
|
||||
}}
|
||||
maybeId={BOOK_EMBEDDABLE_ID}
|
||||
getParentApi={() => parentApi}
|
||||
onApiAvailable={(api) => {
|
||||
setBookApi(api);
|
||||
}}
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BookRuntimeState } from '../../react_embeddables/saved_book/types';
|
||||
|
||||
const UNSAVED_CHANGES_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.stateManagementExample.unsavedChanges';
|
||||
|
||||
export const unsavedChangesSessionStorage = {
|
||||
clear: () => {
|
||||
sessionStorage.removeItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
|
||||
},
|
||||
load: (): Partial<BookRuntimeState> | undefined => {
|
||||
const unsavedChanges = sessionStorage.getItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
|
||||
return unsavedChanges ? JSON.parse(unsavedChanges) : undefined;
|
||||
},
|
||||
save: (unsavedChanges: Partial<BookRuntimeState>) => {
|
||||
sessionStorage.setItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY, JSON.stringify(unsavedChanges));
|
||||
},
|
||||
};
|
|
@ -55,7 +55,7 @@ export class EmbeddableExamplesPlugin implements Plugin<void, void, SetupDeps, S
|
|||
|
||||
embeddable.registerReactEmbeddableFactory(FIELD_LIST_ID, async () => {
|
||||
const { getFieldListFactory } = await import(
|
||||
'./react_embeddables/field_list/field_list_react_embeddable'
|
||||
'./react_embeddables/field_list/field_list_embeddable'
|
||||
);
|
||||
const [coreStart, deps] = await startServicesPromise;
|
||||
return getFieldListFactory(coreStart, deps);
|
||||
|
|
|
@ -16,7 +16,7 @@ import { listenForCompatibleApi } from '@kbn/presentation-containers';
|
|||
import { apiPublishesDataViews, fetch$ } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject, combineLatest, lastValueFrom, map, Subscription, switchMap } from 'rxjs';
|
||||
import { StartDeps } from '../../plugin';
|
||||
import { apiPublishesSelectedFields } from '../field_list/publishes_selected_fields';
|
||||
import { apiPublishesSelectedFields } from './publishes_selected_fields';
|
||||
import { DataTableApi } from './types';
|
||||
|
||||
export const initializeDataTableQueries = async (
|
||||
|
|
|
@ -11,37 +11,39 @@ import { EuiScreenReaderOnly } from '@elastic/eui';
|
|||
import { css } from '@emotion/react';
|
||||
import { CellActionsProvider } from '@kbn/cell-actions';
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import {
|
||||
initializeTimeRange,
|
||||
initializeTimeRangeManager,
|
||||
initializeTitleManager,
|
||||
timeRangeComparators,
|
||||
titleComparators,
|
||||
useBatchedPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { DataLoadingState, UnifiedDataTable, UnifiedDataTableProps } from '@kbn/unified-data-table';
|
||||
import React, { useEffect } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BehaviorSubject, merge } from 'rxjs';
|
||||
import { StartDeps } from '../../plugin';
|
||||
import { DATA_TABLE_ID } from './constants';
|
||||
import { initializeDataTableQueries } from './data_table_queries';
|
||||
import { DataTableApi, DataTableRuntimeState, DataTableSerializedState } from './types';
|
||||
import { DataTableApi, DataTableSerializedState } from './types';
|
||||
|
||||
export const getDataTableFactory = (
|
||||
core: CoreStart,
|
||||
services: StartDeps
|
||||
): ReactEmbeddableFactory<DataTableSerializedState, DataTableRuntimeState, DataTableApi> => ({
|
||||
): EmbeddableFactory<DataTableSerializedState, DataTableApi> => ({
|
||||
type: DATA_TABLE_ID,
|
||||
deserializeState: (state) => {
|
||||
return state.rawState as DataTableSerializedState;
|
||||
},
|
||||
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
|
||||
const storage = new Storage(localStorage);
|
||||
const timeRange = initializeTimeRange(state);
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => {
|
||||
const state = initialState.rawState;
|
||||
const timeRangeManager = initializeTimeRangeManager(state);
|
||||
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
|
||||
const titleManager = initializeTitleManager(state);
|
||||
|
||||
const storage = new Storage(localStorage);
|
||||
const allServices: UnifiedDataTableProps['services'] = {
|
||||
...services,
|
||||
storage,
|
||||
|
@ -50,19 +52,40 @@ export const getDataTableFactory = (
|
|||
toastNotifications: core.notifications.toasts,
|
||||
};
|
||||
|
||||
const api = buildApi(
|
||||
{
|
||||
...timeRange.api,
|
||||
...titleManager.api,
|
||||
dataLoading$,
|
||||
serializeState: () => {
|
||||
return {
|
||||
rawState: { ...titleManager.serialize(), ...timeRange.serialize() },
|
||||
};
|
||||
const serializeState = () => {
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.getLatestState(),
|
||||
...timeRangeManager.getLatestState(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const unsavedChangesApi = initializeUnsavedChanges<DataTableSerializedState>({
|
||||
uuid,
|
||||
parentApi,
|
||||
serializeState,
|
||||
anyStateChange$: merge(titleManager.anyStateChange$, timeRangeManager.anyStateChange$),
|
||||
getComparators: () => {
|
||||
return {
|
||||
...titleComparators,
|
||||
...timeRangeComparators,
|
||||
};
|
||||
},
|
||||
{ ...titleManager.comparators, ...timeRange.comparators }
|
||||
);
|
||||
onReset: (lastSaved) => {
|
||||
const lastSavedState = lastSaved?.rawState;
|
||||
timeRangeManager.reinitializeState(lastSavedState);
|
||||
titleManager.reinitializeState(lastSavedState);
|
||||
},
|
||||
});
|
||||
|
||||
const api = finalizeApi({
|
||||
...timeRangeManager.api,
|
||||
...titleManager.api,
|
||||
...unsavedChangesApi,
|
||||
dataLoading$,
|
||||
serializeState,
|
||||
});
|
||||
|
||||
const queryService = await initializeDataTableQueries(services, api, dataLoading$);
|
||||
|
||||
|
|
|
@ -16,6 +16,4 @@ import {
|
|||
|
||||
export type DataTableSerializedState = SerializedTitles & SerializedTimeRange;
|
||||
|
||||
export type DataTableRuntimeState = DataTableSerializedState;
|
||||
|
||||
export type DataTableApi = DefaultEmbeddableApi<DataTableSerializedState> & PublishesDataLoading;
|
||||
|
|
|
@ -36,7 +36,7 @@ export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => {
|
|||
embeddable.addNewPanel<MarkdownEditorSerializedState>(
|
||||
{
|
||||
panelType: EUI_MARKDOWN_ID,
|
||||
initialState: { content: '# hello world!' },
|
||||
serializedState: { rawState: { content: '# hello world!' } },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
|
|
@ -9,75 +9,98 @@
|
|||
|
||||
import { EuiMarkdownEditor, EuiMarkdownFormat, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import {
|
||||
StateComparators,
|
||||
WithAllKeys,
|
||||
getViewModeSubject,
|
||||
initializeStateManager,
|
||||
initializeTitleManager,
|
||||
titleComparators,
|
||||
useStateFromPublishingSubject,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BehaviorSubject, map, merge } from 'rxjs';
|
||||
import { EUI_MARKDOWN_ID } from './constants';
|
||||
import {
|
||||
MarkdownEditorApi,
|
||||
MarkdownEditorRuntimeState,
|
||||
MarkdownEditorSerializedState,
|
||||
} from './types';
|
||||
import { MarkdownEditorApi, MarkdownEditorSerializedState, MarkdownEditorState } from './types';
|
||||
|
||||
export const markdownEmbeddableFactory: ReactEmbeddableFactory<
|
||||
const defaultMarkdownState: WithAllKeys<MarkdownEditorState> = {
|
||||
content: '',
|
||||
};
|
||||
|
||||
const markdownComparators: StateComparators<MarkdownEditorState> = { content: 'referenceEquality' };
|
||||
|
||||
export const markdownEmbeddableFactory: EmbeddableFactory<
|
||||
MarkdownEditorSerializedState,
|
||||
MarkdownEditorRuntimeState,
|
||||
MarkdownEditorApi
|
||||
> = {
|
||||
type: EUI_MARKDOWN_ID,
|
||||
deserializeState: (state) => state.rawState,
|
||||
/**
|
||||
* The buildEmbeddable function is async so you can async import the component or load a saved
|
||||
* object here. The loading will be handed gracefully by the Presentation Container.
|
||||
*/
|
||||
buildEmbeddable: async (state, buildApi) => {
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => {
|
||||
/**
|
||||
* initialize state (source of truth)
|
||||
* Initialize state managers.
|
||||
*/
|
||||
const titleManager = initializeTitleManager(state);
|
||||
const content$ = new BehaviorSubject(state.content);
|
||||
|
||||
/**
|
||||
* Register the API for this embeddable. This API will be published into the imperative handle
|
||||
* of the React component. Methods on this API will be exposed to siblings, to registered actions
|
||||
* and to the parent api.
|
||||
*/
|
||||
const api = buildApi(
|
||||
{
|
||||
...titleManager.api,
|
||||
serializeState: () => {
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.serialize(),
|
||||
content: content$.getValue(),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide state comparators. Each comparator is 3 element tuple:
|
||||
* 1) current value (publishing subject)
|
||||
* 2) setter, allowing parent to reset value
|
||||
* 3) optional comparator which provides logic to diff lasted stored value and current value
|
||||
*/
|
||||
{
|
||||
content: [content$, (value) => content$.next(value)],
|
||||
...titleManager.comparators,
|
||||
}
|
||||
const titleManager = initializeTitleManager(initialState.rawState);
|
||||
const markdownStateManager = initializeStateManager(
|
||||
initialState.rawState,
|
||||
defaultMarkdownState
|
||||
);
|
||||
|
||||
/**
|
||||
* if this embeddable had a difference between its runtime and serialized state, we could define and run a
|
||||
* "deserializeState" function here. If this embeddable could be by reference, we could load the saved object
|
||||
* in the deserializeState function.
|
||||
*/
|
||||
|
||||
function serializeState() {
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.getLatestState(),
|
||||
...markdownStateManager.getLatestState(),
|
||||
},
|
||||
// references: if this embeddable had any references - this is where we would extract them.
|
||||
};
|
||||
}
|
||||
|
||||
const unsavedChangesApi = initializeUnsavedChanges({
|
||||
uuid,
|
||||
parentApi,
|
||||
serializeState,
|
||||
anyStateChange$: merge(
|
||||
titleManager.anyStateChange$,
|
||||
markdownStateManager.anyStateChange$
|
||||
).pipe(map(() => undefined)),
|
||||
getComparators: () => {
|
||||
/**
|
||||
* comparators are provided in a callback to allow embeddables to change how their state is compared based
|
||||
* on the values of other state. For instance, if a saved object ID is present (by reference), the embeddable
|
||||
* may want to skip comparison of certain state.
|
||||
*/
|
||||
return { ...titleComparators, ...markdownComparators };
|
||||
},
|
||||
onReset: (lastSaved) => {
|
||||
/**
|
||||
* if this embeddable had a difference between its runtime and serialized state, we could run the 'deserializeState'
|
||||
* function here before resetting. onReset can be async so to support a potential async deserialize function.
|
||||
*/
|
||||
|
||||
titleManager.reinitializeState(lastSaved?.rawState);
|
||||
markdownStateManager.reinitializeState(lastSaved?.rawState);
|
||||
},
|
||||
});
|
||||
|
||||
const api = finalizeApi({
|
||||
...unsavedChangesApi,
|
||||
...titleManager.api,
|
||||
serializeState,
|
||||
});
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
// get state for rendering
|
||||
const content = useStateFromPublishingSubject(content$);
|
||||
const content = useStateFromPublishingSubject(markdownStateManager.api.content$);
|
||||
const viewMode = useStateFromPublishingSubject(
|
||||
getViewModeSubject(api) ?? new BehaviorSubject('view')
|
||||
);
|
||||
|
@ -89,7 +112,7 @@ export const markdownEmbeddableFactory: ReactEmbeddableFactory<
|
|||
width: 100%;
|
||||
`}
|
||||
value={content ?? ''}
|
||||
onChange={(value) => content$.next(value)}
|
||||
onChange={(value) => markdownStateManager.api.setContent(value)}
|
||||
aria-label={i18n.translate('embeddableExamples.euiMarkdownEditor.embeddableAriaLabel', {
|
||||
defaultMessage: 'Dashboard markdown editor',
|
||||
})}
|
||||
|
|
|
@ -8,12 +8,20 @@
|
|||
*/
|
||||
|
||||
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
|
||||
import { SerializedTitles } from '@kbn/presentation-publishing';
|
||||
import { PublishesUnsavedChanges, SerializedTitles } from '@kbn/presentation-publishing';
|
||||
|
||||
export type MarkdownEditorSerializedState = SerializedTitles & {
|
||||
/**
|
||||
* The markdown editor's own state. Every embeddable type should separate out its own self-managed state, from state
|
||||
* supplied by other common managers.
|
||||
*/
|
||||
export interface MarkdownEditorState {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type MarkdownEditorRuntimeState = MarkdownEditorSerializedState;
|
||||
/**
|
||||
* Markdown serialized state includes all state that the parent should provide to this embeddable.
|
||||
*/
|
||||
export type MarkdownEditorSerializedState = SerializedTitles & MarkdownEditorState;
|
||||
|
||||
export type MarkdownEditorApi = DefaultEmbeddableApi<MarkdownEditorSerializedState>;
|
||||
export type MarkdownEditorApi = DefaultEmbeddableApi<MarkdownEditorSerializedState> &
|
||||
PublishesUnsavedChanges;
|
||||
|
|
|
@ -14,7 +14,7 @@ import { IncompatibleActionError, ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plug
|
|||
import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
|
||||
import { embeddableExamplesGrouping } from '../embeddable_examples_grouping';
|
||||
import { ADD_FIELD_LIST_ACTION_ID, FIELD_LIST_ID } from './constants';
|
||||
import { FieldListSerializedStateState } from './types';
|
||||
import { FieldListSerializedState } from './types';
|
||||
|
||||
export const registerCreateFieldListAction = (uiActions: UiActionsPublicStart) => {
|
||||
uiActions.registerAction<EmbeddableApiContext>({
|
||||
|
@ -26,7 +26,7 @@ export const registerCreateFieldListAction = (uiActions: UiActionsPublicStart) =
|
|||
},
|
||||
execute: async ({ embeddable }) => {
|
||||
if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError();
|
||||
embeddable.addNewPanel<FieldListSerializedStateState>({
|
||||
embeddable.addNewPanel<FieldListSerializedState>({
|
||||
panelType: FIELD_LIST_ID,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -0,0 +1,240 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import type { Reference } from '@kbn/content-management-utils';
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import {
|
||||
DATA_VIEW_SAVED_OBJECT_TYPE,
|
||||
type DataViewsPublicPluginStart,
|
||||
} from '@kbn/data-views-plugin/public';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
type SerializedPanelState,
|
||||
type WithAllKeys,
|
||||
initializeStateManager,
|
||||
initializeTitleManager,
|
||||
titleComparators,
|
||||
useBatchedPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { LazyDataViewPicker, withSuspense } from '@kbn/presentation-util-plugin/public';
|
||||
import {
|
||||
UnifiedFieldListSidebarContainer,
|
||||
type UnifiedFieldListSidebarContainerProps,
|
||||
} from '@kbn/unified-field-list';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
import { merge, skip, Subscription, switchMap } from 'rxjs';
|
||||
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import { FIELD_LIST_DATA_VIEW_REF_NAME, FIELD_LIST_ID } from './constants';
|
||||
import { FieldListApi, Services, FieldListSerializedState, FieldListRuntimeState } from './types';
|
||||
|
||||
const DataViewPicker = withSuspense(LazyDataViewPicker, null);
|
||||
|
||||
const defaultFieldListState: WithAllKeys<FieldListRuntimeState> = {
|
||||
dataViewId: undefined,
|
||||
selectedFieldNames: undefined,
|
||||
dataViews: undefined,
|
||||
};
|
||||
|
||||
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
|
||||
return {
|
||||
originatingApp: '',
|
||||
localStorageKeyPrefix: 'examples',
|
||||
timeRangeUpdatesType: 'timefilter',
|
||||
compressed: true,
|
||||
showSidebarToggleButton: false,
|
||||
disablePopularFields: true,
|
||||
};
|
||||
};
|
||||
|
||||
const deserializeState = async (
|
||||
dataViews: DataViewsPublicPluginStart,
|
||||
serializedState?: SerializedPanelState<FieldListSerializedState>
|
||||
): Promise<FieldListRuntimeState> => {
|
||||
const state = serializedState?.rawState ? cloneDeep(serializedState?.rawState) : {};
|
||||
// inject the reference
|
||||
const dataViewIdRef = (serializedState?.references ?? []).find(
|
||||
(ref) => ref.name === FIELD_LIST_DATA_VIEW_REF_NAME
|
||||
);
|
||||
// if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this)
|
||||
if (dataViewIdRef && state && !state.dataViewId) {
|
||||
state.dataViewId = dataViewIdRef?.id;
|
||||
}
|
||||
|
||||
const [allDataViews, defaultDataViewId] = await Promise.all([
|
||||
dataViews.getIdsWithTitle(),
|
||||
dataViews.getDefaultId(),
|
||||
]);
|
||||
if (!defaultDataViewId || allDataViews.length === 0) {
|
||||
throw new Error(
|
||||
i18n.translate('embeddableExamples.unifiedFieldList.noDefaultDataViewErrorMessage', {
|
||||
defaultMessage: 'The field list must be used with at least one Data View present',
|
||||
})
|
||||
);
|
||||
}
|
||||
const initialDataViewId = state.dataViewId ?? defaultDataViewId;
|
||||
const initialDataView = await dataViews.get(initialDataViewId);
|
||||
return {
|
||||
dataViewId: initialDataViewId,
|
||||
selectedFieldNames: state.selectedFieldNames ?? [],
|
||||
dataViews: [initialDataView],
|
||||
};
|
||||
};
|
||||
|
||||
export const getFieldListFactory = (
|
||||
core: CoreStart,
|
||||
{ dataViews, data, charts, fieldFormats }: Services
|
||||
) => {
|
||||
const fieldListEmbeddableFactory: EmbeddableFactory<FieldListSerializedState, FieldListApi> = {
|
||||
type: FIELD_LIST_ID,
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => {
|
||||
const state = await deserializeState(dataViews, initialState);
|
||||
const allDataViews = await dataViews.getIdsWithTitle();
|
||||
const subscriptions = new Subscription();
|
||||
const titleManager = initializeTitleManager(initialState?.rawState ?? {});
|
||||
const fieldListStateManager = initializeStateManager(state, defaultFieldListState);
|
||||
|
||||
// Whenever the data view changes, we want to update the data views and reset the selectedFields in the field list state manager.
|
||||
subscriptions.add(
|
||||
fieldListStateManager.api.dataViewId$
|
||||
.pipe(
|
||||
skip(1),
|
||||
switchMap((dataViewId) =>
|
||||
dataViewId ? dataViews.get(dataViewId) : dataViews.getDefaultDataView()
|
||||
)
|
||||
)
|
||||
.subscribe((nextSelectedDataView) => {
|
||||
fieldListStateManager.api.setDataViews(
|
||||
nextSelectedDataView ? [nextSelectedDataView] : undefined
|
||||
);
|
||||
fieldListStateManager.api.setSelectedFieldNames([]);
|
||||
})
|
||||
);
|
||||
|
||||
function serializeState() {
|
||||
const { dataViewId, selectedFieldNames } = fieldListStateManager.getLatestState();
|
||||
const references: Reference[] = dataViewId
|
||||
? [
|
||||
{
|
||||
type: DATA_VIEW_SAVED_OBJECT_TYPE,
|
||||
name: FIELD_LIST_DATA_VIEW_REF_NAME,
|
||||
id: dataViewId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.getLatestState(),
|
||||
// here we skip serializing the dataViewId, because the reference contains that information.
|
||||
selectedFieldNames,
|
||||
},
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
const unsavedChangesApi = initializeUnsavedChanges({
|
||||
uuid,
|
||||
parentApi,
|
||||
serializeState,
|
||||
anyStateChange$: merge(titleManager.anyStateChange$, fieldListStateManager.anyStateChange$),
|
||||
getComparators: () => ({
|
||||
...titleComparators,
|
||||
selectedFieldNames: (a, b) => {
|
||||
return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? '');
|
||||
},
|
||||
}),
|
||||
onReset: async (lastSaved) => {
|
||||
const lastState = await deserializeState(dataViews, lastSaved);
|
||||
fieldListStateManager.reinitializeState(lastState);
|
||||
titleManager.reinitializeState(lastSaved?.rawState);
|
||||
},
|
||||
});
|
||||
|
||||
const api = finalizeApi({
|
||||
...titleManager.api,
|
||||
...unsavedChangesApi,
|
||||
serializeState,
|
||||
});
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
const [selectedFieldNames, renderDataViews] = useBatchedPublishingSubjects(
|
||||
fieldListStateManager.api.selectedFieldNames$,
|
||||
fieldListStateManager.api.dataViews$
|
||||
);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const selectedDataView = renderDataViews?.[0];
|
||||
|
||||
// On destroy
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
subscriptions.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
css={css`
|
||||
padding: ${euiTheme.size.s};
|
||||
`}
|
||||
>
|
||||
<DataViewPicker
|
||||
dataViews={allDataViews}
|
||||
selectedDataViewId={selectedDataView?.id}
|
||||
onChangeDataViewId={(nextSelection) =>
|
||||
fieldListStateManager.api.setDataViewId(nextSelection)
|
||||
}
|
||||
trigger={{
|
||||
label:
|
||||
selectedDataView?.getName() ??
|
||||
i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', {
|
||||
defaultMessage: 'Please select a data view',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{selectedDataView ? (
|
||||
<UnifiedFieldListSidebarContainer
|
||||
fullWidth={true}
|
||||
variant="list-always"
|
||||
dataView={selectedDataView}
|
||||
allFields={selectedDataView.fields}
|
||||
getCreationOptions={getCreationOptions}
|
||||
workspaceSelectedFieldNames={selectedFieldNames}
|
||||
services={{ dataViews, data, fieldFormats, charts, core }}
|
||||
onAddFieldToWorkspace={(field) =>
|
||||
fieldListStateManager.api.setSelectedFieldNames([
|
||||
...(selectedFieldNames ?? []),
|
||||
field.name,
|
||||
])
|
||||
}
|
||||
onRemoveFieldFromWorkspace={(field) => {
|
||||
fieldListStateManager.api.setSelectedFieldNames(
|
||||
(selectedFieldNames ?? []).filter((name) => name !== field.name)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return fieldListEmbeddableFactory;
|
||||
};
|
|
@ -1,217 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import type { Reference } from '@kbn/content-management-utils';
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/public';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { initializeTitleManager, useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { LazyDataViewPicker, withSuspense } from '@kbn/presentation-util-plugin/public';
|
||||
import {
|
||||
UnifiedFieldListSidebarContainer,
|
||||
type UnifiedFieldListSidebarContainerProps,
|
||||
} from '@kbn/unified-field-list';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
import { BehaviorSubject, skip, Subscription, switchMap } from 'rxjs';
|
||||
import { FIELD_LIST_DATA_VIEW_REF_NAME, FIELD_LIST_ID } from './constants';
|
||||
import {
|
||||
FieldListApi,
|
||||
Services,
|
||||
FieldListSerializedStateState,
|
||||
FieldListRuntimeState,
|
||||
} from './types';
|
||||
|
||||
const DataViewPicker = withSuspense(LazyDataViewPicker, null);
|
||||
|
||||
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
|
||||
return {
|
||||
originatingApp: '',
|
||||
localStorageKeyPrefix: 'examples',
|
||||
timeRangeUpdatesType: 'timefilter',
|
||||
compressed: true,
|
||||
showSidebarToggleButton: false,
|
||||
disablePopularFields: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFieldListFactory = (
|
||||
core: CoreStart,
|
||||
{ dataViews, data, charts, fieldFormats }: Services
|
||||
) => {
|
||||
const fieldListEmbeddableFactory: ReactEmbeddableFactory<
|
||||
FieldListSerializedStateState,
|
||||
FieldListRuntimeState,
|
||||
FieldListApi
|
||||
> = {
|
||||
type: FIELD_LIST_ID,
|
||||
deserializeState: (state) => {
|
||||
const serializedState = cloneDeep(state.rawState);
|
||||
// inject the reference
|
||||
const dataViewIdRef = state.references?.find(
|
||||
(ref) => ref.name === FIELD_LIST_DATA_VIEW_REF_NAME
|
||||
);
|
||||
// if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this)
|
||||
if (dataViewIdRef && serializedState && !serializedState.dataViewId) {
|
||||
serializedState.dataViewId = dataViewIdRef?.id;
|
||||
}
|
||||
return serializedState;
|
||||
},
|
||||
buildEmbeddable: async (initialState, buildApi) => {
|
||||
const subscriptions = new Subscription();
|
||||
const titleManager = initializeTitleManager(initialState);
|
||||
|
||||
// set up data views
|
||||
const [allDataViews, defaultDataViewId] = await Promise.all([
|
||||
dataViews.getIdsWithTitle(),
|
||||
dataViews.getDefaultId(),
|
||||
]);
|
||||
if (!defaultDataViewId || allDataViews.length === 0) {
|
||||
throw new Error(
|
||||
i18n.translate('embeddableExamples.unifiedFieldList.noDefaultDataViewErrorMessage', {
|
||||
defaultMessage: 'The field list must be used with at least one Data View present',
|
||||
})
|
||||
);
|
||||
}
|
||||
const initialDataViewId = initialState.dataViewId ?? defaultDataViewId;
|
||||
const initialDataView = await dataViews.get(initialDataViewId);
|
||||
const selectedDataViewId$ = new BehaviorSubject<string | undefined>(initialDataViewId);
|
||||
const dataViews$ = new BehaviorSubject<DataView[] | undefined>([initialDataView]);
|
||||
const selectedFieldNames$ = new BehaviorSubject<string[] | undefined>(
|
||||
initialState.selectedFieldNames
|
||||
);
|
||||
|
||||
subscriptions.add(
|
||||
selectedDataViewId$
|
||||
.pipe(
|
||||
skip(1),
|
||||
switchMap((dataViewId) => dataViews.get(dataViewId ?? defaultDataViewId))
|
||||
)
|
||||
.subscribe((nextSelectedDataView) => {
|
||||
dataViews$.next([nextSelectedDataView]);
|
||||
selectedFieldNames$.next([]);
|
||||
})
|
||||
);
|
||||
|
||||
const api = buildApi(
|
||||
{
|
||||
...titleManager.api,
|
||||
dataViews$,
|
||||
selectedFields: selectedFieldNames$,
|
||||
serializeState: () => {
|
||||
const dataViewId = selectedDataViewId$.getValue();
|
||||
const references: Reference[] = dataViewId
|
||||
? [
|
||||
{
|
||||
type: DATA_VIEW_SAVED_OBJECT_TYPE,
|
||||
name: FIELD_LIST_DATA_VIEW_REF_NAME,
|
||||
id: dataViewId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.serialize(),
|
||||
// here we skip serializing the dataViewId, because the reference contains that information.
|
||||
selectedFieldNames: selectedFieldNames$.getValue(),
|
||||
},
|
||||
references,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
...titleManager.comparators,
|
||||
dataViewId: [selectedDataViewId$, (value) => selectedDataViewId$.next(value)],
|
||||
selectedFieldNames: [
|
||||
selectedFieldNames$,
|
||||
(value) => selectedFieldNames$.next(value),
|
||||
(a, b) => {
|
||||
return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? '');
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
const [renderDataViews, selectedFieldNames] = useBatchedPublishingSubjects(
|
||||
dataViews$,
|
||||
selectedFieldNames$
|
||||
);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const selectedDataView = renderDataViews?.[0];
|
||||
|
||||
// On destroy
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
subscriptions.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
css={css`
|
||||
padding: ${euiTheme.size.s};
|
||||
`}
|
||||
>
|
||||
<DataViewPicker
|
||||
dataViews={allDataViews}
|
||||
selectedDataViewId={selectedDataView?.id}
|
||||
onChangeDataViewId={(nextSelection) => {
|
||||
selectedDataViewId$.next(nextSelection);
|
||||
}}
|
||||
trigger={{
|
||||
label:
|
||||
selectedDataView?.getName() ??
|
||||
i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', {
|
||||
defaultMessage: 'Please select a data view',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
{selectedDataView ? (
|
||||
<UnifiedFieldListSidebarContainer
|
||||
fullWidth={true}
|
||||
variant="list-always"
|
||||
dataView={selectedDataView}
|
||||
allFields={selectedDataView.fields}
|
||||
getCreationOptions={getCreationOptions}
|
||||
workspaceSelectedFieldNames={selectedFieldNames}
|
||||
services={{ dataViews, data, fieldFormats, charts, core }}
|
||||
onAddFieldToWorkspace={(field) =>
|
||||
selectedFieldNames$.next([
|
||||
...(selectedFieldNames$.getValue() ?? []),
|
||||
field.name,
|
||||
])
|
||||
}
|
||||
onRemoveFieldFromWorkspace={(field) => {
|
||||
selectedFieldNames$.next(
|
||||
(selectedFieldNames$.getValue() ?? []).filter((name) => name !== field.name)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return fieldListEmbeddableFactory;
|
||||
};
|
|
@ -9,9 +9,8 @@
|
|||
|
||||
import { DashboardStart, PanelPlacementStrategy } from '@kbn/dashboard-plugin/public';
|
||||
import { FIELD_LIST_ID } from './constants';
|
||||
import { FieldListSerializedStateState } from './types';
|
||||
|
||||
const getPanelPlacementSetting = (serializedState?: FieldListSerializedStateState) => {
|
||||
const getPanelPlacementSetting = () => {
|
||||
// Consider using the serialized state to determine the width, height, and strategy
|
||||
return {
|
||||
width: 12,
|
||||
|
|
|
@ -7,27 +7,26 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { ChartsPluginStart } from '@kbn/charts-plugin/public';
|
||||
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
|
||||
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
import { PublishesDataViews, SerializedTitles } from '@kbn/presentation-publishing';
|
||||
import { PublishesSelectedFields } from './publishes_selected_fields';
|
||||
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
|
||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
import type { PublishesUnsavedChanges, SerializedTitles } from '@kbn/presentation-publishing';
|
||||
|
||||
export type FieldListSerializedStateState = SerializedTitles & {
|
||||
export interface FieldListState {
|
||||
dataViewId?: string;
|
||||
selectedFieldNames?: string[];
|
||||
}
|
||||
|
||||
export type FieldListRuntimeState = FieldListState & {
|
||||
dataViews?: DataView[];
|
||||
};
|
||||
|
||||
export type FieldListRuntimeState = FieldListSerializedStateState;
|
||||
export type FieldListSerializedState = SerializedTitles & FieldListState;
|
||||
|
||||
export type FieldListApi = DefaultEmbeddableApi<
|
||||
FieldListSerializedStateState,
|
||||
FieldListSerializedStateState
|
||||
> &
|
||||
PublishesSelectedFields &
|
||||
PublishesDataViews;
|
||||
export type FieldListApi = DefaultEmbeddableApi<FieldListSerializedState> & PublishesUnsavedChanges;
|
||||
|
||||
export interface Services {
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
|
|
|
@ -18,7 +18,9 @@ export const registerMyEmbeddableSavedObject = (embeddableSetup: EmbeddableSetup
|
|||
onAdd: (container, savedObject) => {
|
||||
container.addNewPanel({
|
||||
panelType: MY_EMBEDDABLE_TYPE,
|
||||
initialState: savedObject.attributes,
|
||||
serializedState: {
|
||||
rawState: savedObject.attributes,
|
||||
},
|
||||
});
|
||||
},
|
||||
savedObjectType: MY_SAVED_OBJECT_TYPE,
|
||||
|
|
|
@ -7,40 +7,13 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BookAttributes, BookAttributesManager } from './types';
|
||||
import { WithAllKeys } from '@kbn/presentation-publishing';
|
||||
import { BookAttributes } from './types';
|
||||
|
||||
export const defaultBookAttributes: BookAttributes = {
|
||||
export const defaultBookAttributes: WithAllKeys<BookAttributes> = {
|
||||
bookTitle: 'Pillars of the earth',
|
||||
authorName: 'Ken follett',
|
||||
numberOfPages: 973,
|
||||
bookSynopsis:
|
||||
'A spellbinding epic set in 12th-century England, The Pillars of the Earth tells the story of the struggle to build the greatest Gothic cathedral the world has known.',
|
||||
};
|
||||
|
||||
export const stateManagerFromAttributes = (attributes: BookAttributes): BookAttributesManager => {
|
||||
const bookTitle = new BehaviorSubject<string>(attributes.bookTitle);
|
||||
const authorName = new BehaviorSubject<string>(attributes.authorName);
|
||||
const numberOfPages = new BehaviorSubject<number>(attributes.numberOfPages);
|
||||
const bookSynopsis = new BehaviorSubject<string | undefined>(attributes.bookSynopsis);
|
||||
|
||||
return {
|
||||
bookTitle,
|
||||
authorName,
|
||||
numberOfPages,
|
||||
bookSynopsis,
|
||||
comparators: {
|
||||
bookTitle: [bookTitle, (val) => bookTitle.next(val)],
|
||||
authorName: [authorName, (val) => authorName.next(val)],
|
||||
numberOfPages: [numberOfPages, (val) => numberOfPages.next(val)],
|
||||
bookSynopsis: [bookSynopsis, (val) => bookSynopsis.next(val)],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const serializeBookAttributes = (stateManager: BookAttributesManager): BookAttributes => ({
|
||||
bookTitle: stateManager.bookTitle.value,
|
||||
authorName: stateManager.authorName.value,
|
||||
numberOfPages: stateManager.numberOfPages.value,
|
||||
bookSynopsis: stateManager.bookSynopsis.value,
|
||||
});
|
||||
|
|
|
@ -10,18 +10,14 @@
|
|||
import { CoreStart } from '@kbn/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { apiCanAddNewPanel } from '@kbn/presentation-containers';
|
||||
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
|
||||
import { EmbeddableApiContext, initializeStateManager } from '@kbn/presentation-publishing';
|
||||
import { ADD_PANEL_TRIGGER, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
|
||||
import { embeddableExamplesGrouping } from '../embeddable_examples_grouping';
|
||||
import {
|
||||
defaultBookAttributes,
|
||||
serializeBookAttributes,
|
||||
stateManagerFromAttributes,
|
||||
} from './book_state';
|
||||
import { defaultBookAttributes } from './book_state';
|
||||
import { ADD_SAVED_BOOK_ACTION_ID, SAVED_BOOK_ID } from './constants';
|
||||
import { openSavedBookEditor } from './saved_book_editor';
|
||||
import { BookRuntimeState } from './types';
|
||||
import { BookAttributes, BookSerializedState } from './types';
|
||||
|
||||
export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, core: CoreStart) => {
|
||||
uiActions.registerAction<EmbeddableApiContext>({
|
||||
|
@ -33,7 +29,10 @@ export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, c
|
|||
},
|
||||
execute: async ({ embeddable }) => {
|
||||
if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError();
|
||||
const newPanelStateManager = stateManagerFromAttributes(defaultBookAttributes);
|
||||
const newPanelStateManager = initializeStateManager<BookAttributes>(
|
||||
defaultBookAttributes,
|
||||
defaultBookAttributes
|
||||
);
|
||||
|
||||
const { savedBookId } = await openSavedBookEditor({
|
||||
attributesManager: newPanelStateManager,
|
||||
|
@ -42,14 +41,14 @@ export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, c
|
|||
core,
|
||||
});
|
||||
|
||||
const bookAttributes = serializeBookAttributes(newPanelStateManager);
|
||||
const initialState: BookRuntimeState = savedBookId
|
||||
? { savedBookId, ...bookAttributes }
|
||||
: { ...bookAttributes };
|
||||
const bookAttributes = newPanelStateManager.getLatestState();
|
||||
const initialState: BookSerializedState = savedBookId
|
||||
? { savedBookId }
|
||||
: { attributes: bookAttributes };
|
||||
|
||||
embeddable.addNewPanel<BookRuntimeState>({
|
||||
embeddable.addNewPanel<BookSerializedState>({
|
||||
panelType: SAVED_BOOK_ID,
|
||||
initialState,
|
||||
serializedState: { rawState: initialState },
|
||||
});
|
||||
},
|
||||
getDisplayName: () =>
|
||||
|
|
|
@ -26,11 +26,11 @@ import { CoreStart } from '@kbn/core-lifecycle-browser';
|
|||
import { OverlayRef } from '@kbn/core-mount-utils-browser';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { tracksOverlays } from '@kbn/presentation-containers';
|
||||
import { apiHasUniqueId, useBatchedOptionalPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { apiHasUniqueId, useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import React, { useState } from 'react';
|
||||
import { serializeBookAttributes } from './book_state';
|
||||
import { BookApi, BookAttributesManager } from './types';
|
||||
import { StateManager } from '@kbn/presentation-publishing/state_manager/types';
|
||||
import { BookApi, BookAttributes } from './types';
|
||||
import { saveBookAttributes } from './saved_book_library';
|
||||
|
||||
export const openSavedBookEditor = ({
|
||||
|
@ -40,7 +40,7 @@ export const openSavedBookEditor = ({
|
|||
parent,
|
||||
api,
|
||||
}: {
|
||||
attributesManager: BookAttributesManager;
|
||||
attributesManager: StateManager<BookAttributes>;
|
||||
isCreate: boolean;
|
||||
core: CoreStart;
|
||||
parent?: unknown;
|
||||
|
@ -52,7 +52,7 @@ export const openSavedBookEditor = ({
|
|||
overlayRef.close();
|
||||
};
|
||||
|
||||
const initialState = serializeBookAttributes(attributesManager);
|
||||
const initialState = attributesManager.getLatestState();
|
||||
const overlay = core.overlays.openFlyout(
|
||||
toMountPoint(
|
||||
<SavedBookEditor
|
||||
|
@ -61,18 +61,12 @@ export const openSavedBookEditor = ({
|
|||
attributesManager={attributesManager}
|
||||
onCancel={() => {
|
||||
// set the state back to the initial state and reject
|
||||
attributesManager.authorName.next(initialState.authorName);
|
||||
attributesManager.bookSynopsis.next(initialState.bookSynopsis);
|
||||
attributesManager.bookTitle.next(initialState.bookTitle);
|
||||
attributesManager.numberOfPages.next(initialState.numberOfPages);
|
||||
attributesManager.reinitializeState(initialState);
|
||||
closeOverlay(overlay);
|
||||
}}
|
||||
onSubmit={async (addToLibrary: boolean) => {
|
||||
const savedBookId = addToLibrary
|
||||
? await saveBookAttributes(
|
||||
api?.getSavedBookId(),
|
||||
serializeBookAttributes(attributesManager)
|
||||
)
|
||||
? await saveBookAttributes(api?.getSavedBookId(), attributesManager.getLatestState())
|
||||
: undefined;
|
||||
|
||||
closeOverlay(overlay);
|
||||
|
@ -104,17 +98,17 @@ export const SavedBookEditor = ({
|
|||
onCancel,
|
||||
api,
|
||||
}: {
|
||||
attributesManager: BookAttributesManager;
|
||||
attributesManager: StateManager<BookAttributes>;
|
||||
isCreate: boolean;
|
||||
onSubmit: (addToLibrary: boolean) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
api?: BookApi;
|
||||
}) => {
|
||||
const [authorName, synopsis, bookTitle, numberOfPages] = useBatchedOptionalPublishingSubjects(
|
||||
attributesManager.authorName,
|
||||
attributesManager.bookSynopsis,
|
||||
attributesManager.bookTitle,
|
||||
attributesManager.numberOfPages
|
||||
const [authorName, synopsis, bookTitle, numberOfPages] = useBatchedPublishingSubjects(
|
||||
attributesManager.api.authorName$,
|
||||
attributesManager.api.bookSynopsis$,
|
||||
attributesManager.api.bookTitle$,
|
||||
attributesManager.api.numberOfPages$
|
||||
);
|
||||
const [addToLibrary, setAddToLibrary] = useState(Boolean(api?.getSavedBookId()));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
@ -143,7 +137,7 @@ export const SavedBookEditor = ({
|
|||
<EuiFieldText
|
||||
disabled={saving}
|
||||
value={authorName ?? ''}
|
||||
onChange={(e) => attributesManager.authorName.next(e.target.value)}
|
||||
onChange={(e) => attributesManager.api.setAuthorName(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
|
@ -154,7 +148,7 @@ export const SavedBookEditor = ({
|
|||
<EuiFieldText
|
||||
disabled={saving}
|
||||
value={bookTitle ?? ''}
|
||||
onChange={(e) => attributesManager.bookTitle.next(e.target.value)}
|
||||
onChange={(e) => attributesManager.api.setBookTitle(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
|
@ -165,7 +159,7 @@ export const SavedBookEditor = ({
|
|||
<EuiFieldNumber
|
||||
disabled={saving}
|
||||
value={numberOfPages ?? ''}
|
||||
onChange={(e) => attributesManager.numberOfPages.next(+e.target.value)}
|
||||
onChange={(e) => attributesManager.api.setNumberOfPages(+e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
|
@ -176,7 +170,7 @@ export const SavedBookEditor = ({
|
|||
<EuiTextArea
|
||||
disabled={saving}
|
||||
value={synopsis ?? ''}
|
||||
onChange={(e) => attributesManager.bookSynopsis.next(e.target.value)}
|
||||
onChange={(e) => attributesManager.api.setBookSynopsis(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlyoutBody>
|
||||
|
|
|
@ -18,19 +18,23 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
apiHasParentApi,
|
||||
getUnchangingComparator,
|
||||
initializeTitleManager,
|
||||
SerializedTitles,
|
||||
SerializedPanelState,
|
||||
useBatchedPublishingSubjects,
|
||||
initializeStateManager,
|
||||
titleComparators,
|
||||
StateComparators,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import React from 'react';
|
||||
import { PresentationContainer, apiIsPresentationContainer } from '@kbn/presentation-containers';
|
||||
import { serializeBookAttributes, stateManagerFromAttributes } from './book_state';
|
||||
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import { merge } from 'rxjs';
|
||||
import { defaultBookAttributes } from './book_state';
|
||||
import { SAVED_BOOK_ID } from './constants';
|
||||
import { openSavedBookEditor } from './saved_book_editor';
|
||||
import { loadBookAttributes, saveBookAttributes } from './saved_book_library';
|
||||
|
@ -49,40 +53,50 @@ const bookSerializedStateIsByReference = (
|
|||
return Boolean(state && (state as BookByReferenceSerializedState).savedBookId);
|
||||
};
|
||||
|
||||
const bookAttributeComparators: StateComparators<BookAttributes> = {
|
||||
bookTitle: 'referenceEquality',
|
||||
authorName: 'referenceEquality',
|
||||
bookSynopsis: 'referenceEquality',
|
||||
numberOfPages: 'referenceEquality',
|
||||
};
|
||||
|
||||
const deserializeState = async (
|
||||
serializedState: SerializedPanelState<BookSerializedState>
|
||||
): Promise<BookRuntimeState> => {
|
||||
// panel state is always stored with the parent.
|
||||
const titlesState: SerializedTitles = {
|
||||
title: serializedState.rawState.title,
|
||||
hidePanelTitles: serializedState.rawState.hidePanelTitles,
|
||||
description: serializedState.rawState.description,
|
||||
};
|
||||
|
||||
const savedBookId = bookSerializedStateIsByReference(serializedState.rawState)
|
||||
? serializedState.rawState.savedBookId
|
||||
: undefined;
|
||||
|
||||
const attributes: BookAttributes = bookSerializedStateIsByReference(serializedState.rawState)
|
||||
? await loadBookAttributes(serializedState.rawState.savedBookId)!
|
||||
: serializedState.rawState.attributes;
|
||||
|
||||
// Combine the serialized state from the parent with the state from the
|
||||
// external store to build runtime state.
|
||||
return {
|
||||
...titlesState,
|
||||
...attributes,
|
||||
savedBookId,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSavedBookEmbeddableFactory = (core: CoreStart) => {
|
||||
const savedBookEmbeddableFactory: ReactEmbeddableFactory<
|
||||
BookSerializedState,
|
||||
BookRuntimeState,
|
||||
BookApi
|
||||
> = {
|
||||
const savedBookEmbeddableFactory: EmbeddableFactory<BookSerializedState, BookApi> = {
|
||||
type: SAVED_BOOK_ID,
|
||||
deserializeState: async (serializedState) => {
|
||||
// panel state is always stored with the parent.
|
||||
const titlesState: SerializedTitles = {
|
||||
title: serializedState.rawState.title,
|
||||
hidePanelTitles: serializedState.rawState.hidePanelTitles,
|
||||
description: serializedState.rawState.description,
|
||||
};
|
||||
|
||||
const savedBookId = bookSerializedStateIsByReference(serializedState.rawState)
|
||||
? serializedState.rawState.savedBookId
|
||||
: undefined;
|
||||
|
||||
const attributes: BookAttributes = bookSerializedStateIsByReference(serializedState.rawState)
|
||||
? await loadBookAttributes(serializedState.rawState.savedBookId)!
|
||||
: serializedState.rawState.attributes;
|
||||
|
||||
// Combine the serialized state from the parent with the state from the
|
||||
// external store to build runtime state.
|
||||
return {
|
||||
...titlesState,
|
||||
...attributes,
|
||||
savedBookId,
|
||||
};
|
||||
},
|
||||
buildEmbeddable: async (state, buildApi) => {
|
||||
const titleManager = initializeTitleManager(state);
|
||||
const bookAttributesManager = stateManagerFromAttributes(state);
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => {
|
||||
const state = await deserializeState(initialState);
|
||||
const titleManager = initializeTitleManager(initialState.rawState);
|
||||
const bookAttributesManager = initializeStateManager<BookAttributes>(
|
||||
state,
|
||||
defaultBookAttributes
|
||||
);
|
||||
const isByReference = Boolean(state.savedBookId);
|
||||
|
||||
const serializeBook = (byReference: boolean, newId?: string) => {
|
||||
|
@ -90,74 +104,83 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => {
|
|||
// if this book is currently by reference, we serialize the reference only.
|
||||
const bookByReferenceState: BookByReferenceSerializedState = {
|
||||
savedBookId: newId ?? state.savedBookId!,
|
||||
...titleManager.serialize(),
|
||||
...titleManager.getLatestState(),
|
||||
};
|
||||
return { rawState: bookByReferenceState };
|
||||
}
|
||||
// if this book is currently by value, we serialize the entire state.
|
||||
const bookByValueState: BookByValueSerializedState = {
|
||||
attributes: serializeBookAttributes(bookAttributesManager),
|
||||
...titleManager.serialize(),
|
||||
...titleManager.getLatestState(),
|
||||
attributes: bookAttributesManager.getLatestState(),
|
||||
};
|
||||
return { rawState: bookByValueState };
|
||||
};
|
||||
|
||||
const api = buildApi(
|
||||
{
|
||||
...titleManager.api,
|
||||
onEdit: async () => {
|
||||
openSavedBookEditor({
|
||||
attributesManager: bookAttributesManager,
|
||||
parent: api.parentApi,
|
||||
isCreate: false,
|
||||
core,
|
||||
api,
|
||||
}).then((result) => {
|
||||
const nextIsByReference = Boolean(result.savedBookId);
|
||||
const serializeState = () => serializeBook(isByReference);
|
||||
|
||||
// if the by reference state has changed during this edit, reinitialize the panel.
|
||||
if (
|
||||
nextIsByReference !== isByReference &&
|
||||
apiIsPresentationContainer(api.parentApi)
|
||||
) {
|
||||
api.parentApi.replacePanel<BookSerializedState>(api.uuid, {
|
||||
serializedState: serializeBook(nextIsByReference, result.savedBookId),
|
||||
panelType: api.type,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('embeddableExamples.savedbook.editBook.displayName', {
|
||||
defaultMessage: 'book',
|
||||
}),
|
||||
serializeState: () => serializeBook(isByReference),
|
||||
|
||||
// library transforms
|
||||
getSavedBookId: () => state.savedBookId,
|
||||
saveToLibrary: async (newTitle: string) => {
|
||||
bookAttributesManager.bookTitle.next(newTitle);
|
||||
const newId = await saveBookAttributes(
|
||||
undefined,
|
||||
serializeBookAttributes(bookAttributesManager)
|
||||
);
|
||||
return newId;
|
||||
},
|
||||
checkForDuplicateTitle: async (title) => {},
|
||||
getSerializedStateByValue: () =>
|
||||
serializeBook(false) as SerializedPanelState<BookByValueSerializedState>,
|
||||
getSerializedStateByReference: (newId) =>
|
||||
serializeBook(true, newId) as SerializedPanelState<BookByReferenceSerializedState>,
|
||||
canLinkToLibrary: async () => !isByReference,
|
||||
canUnlinkFromLibrary: async () => isByReference,
|
||||
const unsavedChangesApi = initializeUnsavedChanges<BookSerializedState>({
|
||||
uuid,
|
||||
parentApi,
|
||||
serializeState,
|
||||
anyStateChange$: merge(titleManager.anyStateChange$, bookAttributesManager.anyStateChange$),
|
||||
getComparators: () => {
|
||||
return {
|
||||
...titleComparators,
|
||||
...bookAttributeComparators,
|
||||
savedBookId: 'skip', // saved book id will not change over the lifetime of the embeddable.
|
||||
};
|
||||
},
|
||||
{
|
||||
savedBookId: getUnchangingComparator(), // saved book id will not change over the lifetime of the embeddable.
|
||||
...bookAttributesManager.comparators,
|
||||
...titleManager.comparators,
|
||||
}
|
||||
);
|
||||
onReset: async (lastSaved) => {
|
||||
const lastRuntimeState = lastSaved ? await deserializeState(lastSaved) : {};
|
||||
titleManager.reinitializeState(lastRuntimeState);
|
||||
bookAttributesManager.reinitializeState(lastRuntimeState);
|
||||
},
|
||||
});
|
||||
|
||||
const api = finalizeApi({
|
||||
...unsavedChangesApi,
|
||||
...titleManager.api,
|
||||
onEdit: async () => {
|
||||
openSavedBookEditor({
|
||||
attributesManager: bookAttributesManager,
|
||||
parent: api.parentApi,
|
||||
isCreate: false,
|
||||
core,
|
||||
api,
|
||||
}).then((result) => {
|
||||
const nextIsByReference = Boolean(result.savedBookId);
|
||||
|
||||
// if the by reference state has changed during this edit, reinitialize the panel.
|
||||
if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) {
|
||||
api.parentApi.replacePanel<BookSerializedState>(api.uuid, {
|
||||
serializedState: serializeBook(nextIsByReference, result.savedBookId),
|
||||
panelType: api.type,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('embeddableExamples.savedbook.editBook.displayName', {
|
||||
defaultMessage: 'book',
|
||||
}),
|
||||
serializeState,
|
||||
|
||||
// library transforms
|
||||
getSavedBookId: () => state.savedBookId,
|
||||
saveToLibrary: async (newTitle: string) => {
|
||||
bookAttributesManager.api.setBookTitle(newTitle);
|
||||
const newId = await saveBookAttributes(undefined, bookAttributesManager.getLatestState());
|
||||
return newId;
|
||||
},
|
||||
checkForDuplicateTitle: async (title) => {},
|
||||
getSerializedStateByValue: () =>
|
||||
serializeBook(false) as SerializedPanelState<BookByValueSerializedState>,
|
||||
getSerializedStateByReference: (newId) =>
|
||||
serializeBook(true, newId) as SerializedPanelState<BookByReferenceSerializedState>,
|
||||
canLinkToLibrary: async () => !isByReference,
|
||||
canUnlinkFromLibrary: async () => isByReference,
|
||||
});
|
||||
|
||||
const showLibraryCallout =
|
||||
apiHasParentApi(api) &&
|
||||
|
@ -167,10 +190,10 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => {
|
|||
api,
|
||||
Component: () => {
|
||||
const [authorName, numberOfPages, bookTitle, synopsis] = useBatchedPublishingSubjects(
|
||||
bookAttributesManager.authorName,
|
||||
bookAttributesManager.numberOfPages,
|
||||
bookAttributesManager.bookTitle,
|
||||
bookAttributesManager.bookSynopsis
|
||||
bookAttributesManager.api.authorName$,
|
||||
bookAttributesManager.api.numberOfPages$,
|
||||
bookAttributesManager.api.bookTitle$,
|
||||
bookAttributesManager.api.bookSynopsis$
|
||||
);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
|
|
|
@ -11,10 +11,9 @@ import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
|
|||
import {
|
||||
HasEditCapabilities,
|
||||
HasLibraryTransforms,
|
||||
PublishesUnsavedChanges,
|
||||
SerializedTitles,
|
||||
StateComparators,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export interface BookAttributes {
|
||||
bookTitle: string;
|
||||
|
@ -23,10 +22,6 @@ export interface BookAttributes {
|
|||
bookSynopsis?: string;
|
||||
}
|
||||
|
||||
export type BookAttributesManager = {
|
||||
[key in keyof Required<BookAttributes>]: BehaviorSubject<BookAttributes[key]>;
|
||||
} & { comparators: StateComparators<BookAttributes> };
|
||||
|
||||
export interface BookByValueSerializedState {
|
||||
attributes: BookAttributes;
|
||||
}
|
||||
|
@ -50,7 +45,8 @@ export interface BookRuntimeState
|
|||
Partial<BookByReferenceSerializedState>,
|
||||
SerializedTitles {}
|
||||
|
||||
export type BookApi = DefaultEmbeddableApi<BookSerializedState, BookRuntimeState> &
|
||||
export type BookApi = DefaultEmbeddableApi<BookSerializedState> &
|
||||
HasEditCapabilities &
|
||||
HasLibraryTransforms<BookByReferenceSerializedState, BookByValueSerializedState> &
|
||||
HasSavedBookId;
|
||||
HasSavedBookId &
|
||||
PublishesUnsavedChanges;
|
||||
|
|
|
@ -7,5 +7,5 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export const SEARCH_EMBEDDABLE_ID = 'searchEmbeddableDemo';
|
||||
export const SEARCH_EMBEDDABLE_TYPE = 'search_embeddable';
|
||||
export const ADD_SEARCH_ACTION_ID = 'create_search_demo';
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
ADD_PANEL_TRIGGER,
|
||||
} from '@kbn/ui-actions-plugin/public';
|
||||
import { embeddableExamplesGrouping } from '../embeddable_examples_grouping';
|
||||
import { ADD_SEARCH_ACTION_ID, SEARCH_EMBEDDABLE_ID } from './constants';
|
||||
import { ADD_SEARCH_ACTION_ID, SEARCH_EMBEDDABLE_TYPE } from './constants';
|
||||
import { SearchSerializedState } from './types';
|
||||
|
||||
export const registerAddSearchPanelAction = (uiActions: UiActionsStart) => {
|
||||
|
@ -31,8 +31,7 @@ export const registerAddSearchPanelAction = (uiActions: UiActionsStart) => {
|
|||
if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError();
|
||||
embeddable.addNewPanel<SearchSerializedState>(
|
||||
{
|
||||
panelType: SEARCH_EMBEDDABLE_ID,
|
||||
initialState: {},
|
||||
panelType: SEARCH_EMBEDDABLE_TYPE,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
*/
|
||||
|
||||
import { EmbeddableSetup } from '@kbn/embeddable-plugin/public';
|
||||
import { SEARCH_EMBEDDABLE_ID } from './constants';
|
||||
import { SEARCH_EMBEDDABLE_TYPE } from './constants';
|
||||
import { Services } from './types';
|
||||
|
||||
export function registerSearchEmbeddable(embeddable: EmbeddableSetup, services: Promise<Services>) {
|
||||
embeddable.registerReactEmbeddableFactory(SEARCH_EMBEDDABLE_ID, async () => {
|
||||
embeddable.registerReactEmbeddableFactory(SEARCH_EMBEDDABLE_TYPE, async () => {
|
||||
const { getSearchEmbeddableFactory } = await import('./search_react_embeddable');
|
||||
return getSearchEmbeddableFactory(await services);
|
||||
});
|
||||
|
|
|
@ -9,10 +9,10 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { useSearchApi } from '@kbn/presentation-publishing';
|
||||
import type { SearchApi, SearchSerializedState } from './types';
|
||||
import { SEARCH_EMBEDDABLE_ID } from './constants';
|
||||
import { SEARCH_EMBEDDABLE_TYPE } from './constants';
|
||||
|
||||
interface Props {
|
||||
timeRange?: TimeRange;
|
||||
|
@ -32,8 +32,8 @@ export function SearchEmbeddableRenderer(props: Props) {
|
|||
const searchApi = useSearchApi({ timeRange: props.timeRange });
|
||||
|
||||
return (
|
||||
<ReactEmbeddableRenderer<SearchSerializedState, SearchApi>
|
||||
type={SEARCH_EMBEDDABLE_ID}
|
||||
<EmbeddableRenderer<SearchSerializedState, SearchApi>
|
||||
type={SEARCH_EMBEDDABLE_TYPE}
|
||||
getParentApi={() => ({
|
||||
...searchApi,
|
||||
getSerializedStateForChild: () => initialState,
|
||||
|
|
|
@ -10,25 +10,26 @@
|
|||
import { EuiBadge, EuiStat, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
fetch$,
|
||||
initializeTimeRange,
|
||||
initializeTimeRangeManager,
|
||||
timeRangeComparators,
|
||||
useBatchedPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import React, { useEffect } from 'react';
|
||||
import { BehaviorSubject, switchMap, tap } from 'rxjs';
|
||||
import { SEARCH_EMBEDDABLE_ID } from './constants';
|
||||
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import { SEARCH_EMBEDDABLE_TYPE } from './constants';
|
||||
import { getCount } from './get_count';
|
||||
import { SearchApi, Services, SearchSerializedState, SearchRuntimeState } from './types';
|
||||
import { SearchApi, Services, SearchSerializedState } from './types';
|
||||
|
||||
export const getSearchEmbeddableFactory = (services: Services) => {
|
||||
const factory: ReactEmbeddableFactory<SearchSerializedState, SearchRuntimeState, SearchApi> = {
|
||||
type: SEARCH_EMBEDDABLE_ID,
|
||||
deserializeState: (state) => state.rawState,
|
||||
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
|
||||
const timeRange = initializeTimeRange(state);
|
||||
const factory: EmbeddableFactory<SearchSerializedState, SearchApi> = {
|
||||
type: SEARCH_EMBEDDABLE_TYPE,
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, parentApi, uuid }) => {
|
||||
const timeRangeManager = initializeTimeRangeManager(initialState.rawState);
|
||||
const defaultDataView = await services.dataViews.getDefaultDataView();
|
||||
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(
|
||||
defaultDataView ? [defaultDataView] : undefined
|
||||
|
@ -46,25 +47,46 @@ export const getSearchEmbeddableFactory = (services: Services) => {
|
|||
);
|
||||
}
|
||||
|
||||
const api = buildApi(
|
||||
{
|
||||
...timeRange.api,
|
||||
blockingError$,
|
||||
dataViews$,
|
||||
dataLoading$,
|
||||
serializeState: () => {
|
||||
return {
|
||||
rawState: {
|
||||
...timeRange.serialize(),
|
||||
},
|
||||
references: [],
|
||||
};
|
||||
function serializeState() {
|
||||
return {
|
||||
rawState: {
|
||||
...timeRangeManager.getLatestState(),
|
||||
},
|
||||
// references: if this embeddable had any references - this is where we would extract them.
|
||||
};
|
||||
}
|
||||
|
||||
const unsavedChangesApi = initializeUnsavedChanges({
|
||||
uuid,
|
||||
parentApi,
|
||||
serializeState,
|
||||
anyStateChange$: timeRangeManager.anyStateChange$,
|
||||
getComparators: () => {
|
||||
/**
|
||||
* comparators are provided in a callback to allow embeddables to change how their state is compared based
|
||||
* on the values of other state. For instance, if a saved object ID is present (by reference), the embeddable
|
||||
* may want to skip comparison of certain state.
|
||||
*/
|
||||
return timeRangeComparators;
|
||||
},
|
||||
{
|
||||
...timeRange.comparators,
|
||||
}
|
||||
);
|
||||
onReset: (lastSaved) => {
|
||||
/**
|
||||
* if this embeddable had a difference between its runtime and serialized state, we could run the 'deserializeState'
|
||||
* function here before resetting. onReset can be async so to support a potential async deserialize function.
|
||||
*/
|
||||
|
||||
timeRangeManager.reinitializeState(lastSaved?.rawState);
|
||||
},
|
||||
});
|
||||
|
||||
const api = finalizeApi({
|
||||
blockingError$,
|
||||
dataViews$,
|
||||
dataLoading$,
|
||||
...unsavedChangesApi,
|
||||
...timeRangeManager.api,
|
||||
serializeState,
|
||||
});
|
||||
|
||||
const count$ = new BehaviorSubject<number>(0);
|
||||
let prevRequestAbortController: AbortController | undefined;
|
||||
|
|
|
@ -21,8 +21,6 @@ import {
|
|||
|
||||
export type SearchSerializedState = SerializedTimeRange;
|
||||
|
||||
export type SearchRuntimeState = SearchSerializedState;
|
||||
|
||||
export type SearchApi = DefaultEmbeddableApi<SearchSerializedState> &
|
||||
PublishesDataViews &
|
||||
PublishesDataLoading &
|
||||
|
|
|
@ -30,7 +30,7 @@ import { css } from '@emotion/react';
|
|||
import { AppMountParameters } from '@kbn/core-application-browser';
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { AddEmbeddableButton } from '@kbn/embeddable-examples-plugin/public';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { GridLayout, GridLayoutData, GridSettings } from '@kbn/grid-layout';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
|
@ -118,7 +118,7 @@ export const GridExample = ({
|
|||
const currentPanels = mockDashboardApi.panels$.getValue();
|
||||
|
||||
return (
|
||||
<ReactEmbeddableRenderer
|
||||
<EmbeddableRenderer
|
||||
key={id}
|
||||
maybeId={id}
|
||||
type={currentPanels[id].type}
|
||||
|
|
|
@ -79,7 +79,7 @@ export const useMockDashboardApi = ({
|
|||
const newId = v4();
|
||||
otherPanels[newId] = {
|
||||
...oldPanel,
|
||||
explicitInput: { ...newPanel.initialState, id: newId },
|
||||
explicitInput: { ...(newPanel.serializedState?.rawState ?? {}), id: newId },
|
||||
};
|
||||
mockDashboardApi.panels$.next(otherPanels);
|
||||
return newId;
|
||||
|
@ -107,13 +107,16 @@ export const useMockDashboardApi = ({
|
|||
i: newId,
|
||||
},
|
||||
explicitInput: {
|
||||
...panelPackage.initialState,
|
||||
...(panelPackage.serializedState?.rawState ?? {}),
|
||||
id: newId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
canRemovePanels: () => true,
|
||||
getChildApi: () => {
|
||||
throw new Error('getChildApi implemenation not provided');
|
||||
},
|
||||
};
|
||||
// only run onMount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
|
@ -29,7 +29,6 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
|
|||
.addNewPanel(
|
||||
{
|
||||
panelType: FILTER_DEBUGGER_EMBEDDABLE_ID,
|
||||
initialState: {},
|
||||
},
|
||||
true
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { DefaultEmbeddableApi, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { DefaultEmbeddableApi, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
PublishesUnifiedSearch,
|
||||
useStateFromPublishingSubject,
|
||||
|
@ -19,23 +19,17 @@ import { FILTER_DEBUGGER_EMBEDDABLE_ID } from './constants';
|
|||
|
||||
export type Api = DefaultEmbeddableApi<{}>;
|
||||
|
||||
export const factory: ReactEmbeddableFactory<{}, {}, Api> = {
|
||||
export const factory: EmbeddableFactory<{}, Api> = {
|
||||
type: FILTER_DEBUGGER_EMBEDDABLE_ID,
|
||||
deserializeState: () => {
|
||||
return {};
|
||||
},
|
||||
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
|
||||
const api = buildApi(
|
||||
{
|
||||
serializeState: () => {
|
||||
return {
|
||||
rawState: {},
|
||||
references: [],
|
||||
};
|
||||
},
|
||||
buildEmbeddable: async ({ finalizeApi, parentApi }) => {
|
||||
const api = finalizeApi({
|
||||
serializeState: () => {
|
||||
return {
|
||||
rawState: {},
|
||||
references: [],
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
api,
|
||||
|
|
|
@ -90,7 +90,7 @@ pageLoadAssetSize:
|
|||
lens: 76079
|
||||
licenseManagement: 41817
|
||||
licensing: 29004
|
||||
links: 8200
|
||||
links: 9000
|
||||
lists: 22900
|
||||
logsDataAccess: 16759
|
||||
logsShared: 281060
|
||||
|
|
|
@ -41,7 +41,7 @@ const controlGroupMock = getControlGroupMock();
|
|||
|
||||
const updateControlGroupInputMock = (newState: ControlGroupRuntimeState) => {
|
||||
act(() => {
|
||||
controlGroupMock.snapshotRuntimeState.mockReturnValue(newState);
|
||||
controlGroupMock.getInput.mockReturnValue(newState);
|
||||
controlGroupFilterStateMock$.next(newState);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -342,7 +342,7 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
|
|||
|
||||
const upsertPersistableControls = useCallback(async () => {
|
||||
if (!controlGroup) return;
|
||||
const currentPanels = getFilterItemObjListFromControlState(controlGroup.snapshotRuntimeState());
|
||||
const currentPanels = getFilterItemObjListFromControlState(controlGroup.getInput());
|
||||
|
||||
const reorderedControls = reorderControlsWithDefaultControls({
|
||||
controls: currentPanels,
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import type { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
interface UseControlGroupSyncToLocalStorageArgs {
|
||||
|
@ -41,9 +41,9 @@ export const useControlGroupSyncToLocalStorage: UseControlGroupSyncToLocalStorag
|
|||
}
|
||||
}, [shouldSync, controlGroupState, storageKey]);
|
||||
|
||||
const getStoredControlGroupState = () => {
|
||||
const getStoredControlGroupState = useCallback(() => {
|
||||
return (storage.current.get(storageKey) as ControlGroupRuntimeState) ?? undefined;
|
||||
};
|
||||
}, [storageKey]);
|
||||
|
||||
return {
|
||||
controlGroupState,
|
||||
|
|
|
@ -25,6 +25,6 @@ export const getControlGroupMock = () => {
|
|||
openAddDataControlFlyout: jest.fn(),
|
||||
filters$: controlGroupFilterOutputMock$,
|
||||
setChainingSystem: jest.fn(),
|
||||
snapshotRuntimeState: jest.fn(),
|
||||
getInput: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -8,11 +8,10 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils';
|
||||
import type {
|
||||
SearchEmbeddableSerializedState,
|
||||
SearchEmbeddableRuntimeState,
|
||||
SearchEmbeddableApi,
|
||||
} from '@kbn/discover-plugin/public';
|
||||
import { SerializedPanelState } from '@kbn/presentation-publishing';
|
||||
|
@ -200,11 +199,7 @@ const SavedSearchComponentTable: React.FC<
|
|||
);
|
||||
|
||||
return (
|
||||
<ReactEmbeddableRenderer<
|
||||
SearchEmbeddableSerializedState,
|
||||
SearchEmbeddableRuntimeState,
|
||||
SearchEmbeddableApi
|
||||
>
|
||||
<EmbeddableRenderer<SearchEmbeddableSerializedState, SearchEmbeddableApi>
|
||||
maybeId={undefined}
|
||||
type={SEARCH_EMBEDDABLE_TYPE}
|
||||
getParentApi={() => parentApi}
|
||||
|
|
|
@ -8,18 +8,9 @@
|
|||
*/
|
||||
|
||||
export { apiCanAddNewPanel, type CanAddNewPanel } from './interfaces/can_add_new_panel';
|
||||
export {
|
||||
apiHasRuntimeChildState,
|
||||
apiHasSerializedChildState,
|
||||
type HasRuntimeChildState,
|
||||
type HasSerializedChildState,
|
||||
} from './interfaces/child_state';
|
||||
export { apiHasSerializedChildState, type HasSerializedChildState } from './interfaces/child_state';
|
||||
export { childrenUnsavedChanges$ } from './interfaces/unsaved_changes/children_unsaved_changes';
|
||||
export { initializeUnsavedChanges } from './interfaces/unsaved_changes/initialize_unsaved_changes';
|
||||
export {
|
||||
apiHasSaveNotification,
|
||||
type HasSaveNotification,
|
||||
} from './interfaces/has_save_notification';
|
||||
export {
|
||||
apiCanDuplicatePanels,
|
||||
apiCanExpandPanels,
|
||||
|
@ -30,6 +21,10 @@ export {
|
|||
canTrackContentfulRender,
|
||||
type TrackContentfulRender,
|
||||
} from './interfaces/performance_trackers';
|
||||
export {
|
||||
type HasLastSavedChildState,
|
||||
apiHasLastSavedChildState,
|
||||
} from './interfaces/last_saved_child_state';
|
||||
export {
|
||||
apiIsPresentationContainer,
|
||||
combineCompatibleChildrenApis,
|
||||
|
|
|
@ -13,8 +13,8 @@ import { PanelPackage } from './presentation_container';
|
|||
* This API can add a new panel as a child.
|
||||
*/
|
||||
export interface CanAddNewPanel {
|
||||
addNewPanel: <SerializedState extends object, ApiType extends unknown = unknown>(
|
||||
panel: PanelPackage<SerializedState>,
|
||||
addNewPanel: <StateType extends object, ApiType extends unknown = unknown>(
|
||||
panel: PanelPackage<StateType>,
|
||||
displaySuccessMessage?: boolean
|
||||
) => Promise<ApiType | undefined>;
|
||||
}
|
||||
|
|
|
@ -15,23 +15,8 @@ export interface HasSerializedChildState<SerializedState extends object = object
|
|||
) => SerializedPanelState<SerializedState> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state.
|
||||
*/
|
||||
export interface HasRuntimeChildState<RuntimeState extends object = object> {
|
||||
getRuntimeStateForChild: (childId: string) => Partial<RuntimeState> | undefined;
|
||||
}
|
||||
|
||||
export const apiHasSerializedChildState = <SerializedState extends object = object>(
|
||||
api: unknown
|
||||
): api is HasSerializedChildState<SerializedState> => {
|
||||
return Boolean(api && (api as HasSerializedChildState).getSerializedStateForChild);
|
||||
};
|
||||
/**
|
||||
* @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state.
|
||||
*/
|
||||
export const apiHasRuntimeChildState = <RuntimeState extends object = object>(
|
||||
api: unknown
|
||||
): api is HasRuntimeChildState<RuntimeState> => {
|
||||
return Boolean(api && (api as HasRuntimeChildState).getRuntimeStateForChild);
|
||||
};
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export interface HasSaveNotification {
|
||||
saveNotification$: Subject<void>; // a notification that state has been saved
|
||||
}
|
||||
|
||||
export const apiHasSaveNotification = (api: unknown): api is HasSaveNotification => {
|
||||
return Boolean(api && (api as HasSaveNotification).saveNotification$);
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { SerializedPanelState } from '@kbn/presentation-publishing';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface HasLastSavedChildState<SerializedState extends object = object> {
|
||||
lastSavedStateForChild$: (
|
||||
childId: string
|
||||
) => Observable<SerializedPanelState<SerializedState> | undefined>;
|
||||
getLastSavedStateForChild: (childId: string) => SerializedPanelState<SerializedState> | undefined;
|
||||
}
|
||||
|
||||
export const apiHasLastSavedChildState = <SerializedState extends object = object>(
|
||||
api: unknown
|
||||
): api is HasLastSavedChildState<SerializedState> => {
|
||||
return Boolean(
|
||||
api &&
|
||||
(api as HasLastSavedChildState).lastSavedStateForChild$ &&
|
||||
(api as HasLastSavedChildState).getLastSavedStateForChild
|
||||
);
|
||||
};
|
|
@ -16,24 +16,16 @@ import {
|
|||
import { BehaviorSubject, combineLatest, isObservable, map, Observable, of, switchMap } from 'rxjs';
|
||||
import { apiCanAddNewPanel, CanAddNewPanel } from './can_add_new_panel';
|
||||
|
||||
export interface PanelPackage<
|
||||
SerializedStateType extends object = object,
|
||||
RuntimeStateType extends object = object
|
||||
> {
|
||||
export interface PanelPackage<SerializedStateType extends object = object> {
|
||||
panelType: string;
|
||||
|
||||
/**
|
||||
* The serialized state of this panel.
|
||||
*/
|
||||
serializedState?: SerializedPanelState<SerializedStateType>;
|
||||
|
||||
/**
|
||||
* The runtime state of this panel. @deprecated Use `serializedState` instead.
|
||||
*/
|
||||
initialState?: RuntimeStateType;
|
||||
}
|
||||
|
||||
export interface PresentationContainer extends CanAddNewPanel {
|
||||
export interface PresentationContainer<ApiType extends unknown = unknown> extends CanAddNewPanel {
|
||||
/**
|
||||
* Removes a panel from the container.
|
||||
*/
|
||||
|
@ -57,12 +49,19 @@ export interface PresentationContainer extends CanAddNewPanel {
|
|||
*/
|
||||
getPanelCount: () => number;
|
||||
|
||||
/**
|
||||
* Gets a child API for the given ID. This is asynchronous and should await for the
|
||||
* child API to be available. It is best practice to retrieve a child API using this method
|
||||
*/
|
||||
getChildApi: (uuid: string) => Promise<ApiType | undefined>;
|
||||
|
||||
/**
|
||||
* A publishing subject containing the child APIs of the container. Note that
|
||||
* children are created asynchronously. This means that the children$ observable might
|
||||
* contain fewer children than the actual number of panels in the container.
|
||||
* contain fewer children than the actual number of panels in the container. Use getChildApi
|
||||
* to retrieve the child API for a specific panel.
|
||||
*/
|
||||
children$: PublishingSubject<{ [key: string]: unknown }>;
|
||||
children$: PublishingSubject<{ [key: string]: ApiType }>;
|
||||
}
|
||||
|
||||
export const apiIsPresentationContainer = (api: unknown | null): api is PresentationContainer => {
|
||||
|
|
|
@ -13,20 +13,22 @@ import { waitFor } from '@testing-library/react';
|
|||
|
||||
describe('childrenUnsavedChanges$', () => {
|
||||
const child1Api = {
|
||||
unsavedChanges$: new BehaviorSubject<object | undefined>(undefined),
|
||||
resetUnsavedChanges: () => true,
|
||||
uuid: 'child1',
|
||||
hasUnsavedChanges$: new BehaviorSubject<boolean>(false),
|
||||
resetUnsavedChanges: () => undefined,
|
||||
};
|
||||
const child2Api = {
|
||||
unsavedChanges$: new BehaviorSubject<object | undefined>(undefined),
|
||||
resetUnsavedChanges: () => true,
|
||||
uuid: 'child2',
|
||||
hasUnsavedChanges$: new BehaviorSubject<boolean>(false),
|
||||
resetUnsavedChanges: () => undefined,
|
||||
};
|
||||
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
|
||||
const onFireMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onFireMock.mockReset();
|
||||
child1Api.unsavedChanges$.next(undefined);
|
||||
child2Api.unsavedChanges$.next(undefined);
|
||||
child1Api.hasUnsavedChanges$.next(false);
|
||||
child2Api.hasUnsavedChanges$.next(false);
|
||||
children$.next({
|
||||
child1: child1Api,
|
||||
child2: child2Api,
|
||||
|
@ -40,7 +42,18 @@ describe('childrenUnsavedChanges$', () => {
|
|||
() => {
|
||||
expect(onFireMock).toHaveBeenCalledTimes(1);
|
||||
const childUnsavedChanges = onFireMock.mock.calls[0][0];
|
||||
expect(childUnsavedChanges).toBeUndefined();
|
||||
expect(childUnsavedChanges).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"hasUnsavedChanges": false,
|
||||
"uuid": "child1",
|
||||
},
|
||||
Object {
|
||||
"hasUnsavedChanges": false,
|
||||
"uuid": "child2",
|
||||
},
|
||||
]
|
||||
`);
|
||||
},
|
||||
{
|
||||
interval: DEBOUNCE_TIME + 1,
|
||||
|
@ -61,19 +74,24 @@ describe('childrenUnsavedChanges$', () => {
|
|||
}
|
||||
);
|
||||
|
||||
child1Api.unsavedChanges$.next({
|
||||
key1: 'modified value',
|
||||
});
|
||||
child1Api.hasUnsavedChanges$.next(true);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(onFireMock).toHaveBeenCalledTimes(2);
|
||||
const childUnsavedChanges = onFireMock.mock.calls[1][0];
|
||||
expect(childUnsavedChanges).toEqual({
|
||||
child1: {
|
||||
key1: 'modified value',
|
||||
},
|
||||
});
|
||||
expect(childUnsavedChanges).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"hasUnsavedChanges": true,
|
||||
"uuid": "child1",
|
||||
},
|
||||
Object {
|
||||
"hasUnsavedChanges": false,
|
||||
"uuid": "child2",
|
||||
},
|
||||
]
|
||||
`);
|
||||
},
|
||||
{
|
||||
interval: DEBOUNCE_TIME + 1,
|
||||
|
@ -98,8 +116,9 @@ describe('childrenUnsavedChanges$', () => {
|
|||
children$.next({
|
||||
...children$.value,
|
||||
child3: {
|
||||
unsavedChanges$: new BehaviorSubject<object | undefined>({ key1: 'modified value' }),
|
||||
resetUnsavedChanges: () => true,
|
||||
uuid: 'child3',
|
||||
hasUnsavedChanges$: new BehaviorSubject<boolean>(true),
|
||||
resetUnsavedChanges: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -107,11 +126,22 @@ describe('childrenUnsavedChanges$', () => {
|
|||
() => {
|
||||
expect(onFireMock).toHaveBeenCalledTimes(2);
|
||||
const childUnsavedChanges = onFireMock.mock.calls[1][0];
|
||||
expect(childUnsavedChanges).toEqual({
|
||||
child3: {
|
||||
key1: 'modified value',
|
||||
},
|
||||
});
|
||||
expect(childUnsavedChanges).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"hasUnsavedChanges": false,
|
||||
"uuid": "child1",
|
||||
},
|
||||
Object {
|
||||
"hasUnsavedChanges": false,
|
||||
"uuid": "child2",
|
||||
},
|
||||
Object {
|
||||
"hasUnsavedChanges": true,
|
||||
"uuid": "child3",
|
||||
},
|
||||
]
|
||||
`);
|
||||
},
|
||||
{
|
||||
interval: DEBOUNCE_TIME + 1,
|
||||
|
|
|
@ -9,15 +9,22 @@
|
|||
|
||||
import { combineLatest, debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing';
|
||||
import { PresentationContainer } from '../presentation_container';
|
||||
import {
|
||||
apiHasUniqueId,
|
||||
apiPublishesUnsavedChanges,
|
||||
HasUniqueId,
|
||||
PublishesUnsavedChanges,
|
||||
PublishingSubject,
|
||||
} from '@kbn/presentation-publishing';
|
||||
|
||||
export const DEBOUNCE_TIME = 100;
|
||||
|
||||
/**
|
||||
* Create an observable stream of unsaved changes from all react embeddable children
|
||||
*/
|
||||
export function childrenUnsavedChanges$(children$: PresentationContainer['children$']) {
|
||||
export function childrenUnsavedChanges$<Api extends unknown = unknown>(
|
||||
children$: PublishingSubject<{ [key: string]: Api }>
|
||||
) {
|
||||
return children$.pipe(
|
||||
map((children) => Object.keys(children)),
|
||||
distinctUntilChanged(deepEqual),
|
||||
|
@ -25,27 +32,20 @@ export function childrenUnsavedChanges$(children$: PresentationContainer['childr
|
|||
// children may change, so make sure we subscribe/unsubscribe with switchMap
|
||||
switchMap((newChildIds: string[]) => {
|
||||
if (newChildIds.length === 0) return of([]);
|
||||
const childrenThatPublishUnsavedChanges = Object.entries(children$.value).filter(
|
||||
([childId, child]) => apiPublishesUnsavedChanges(child)
|
||||
) as Array<[string, PublishesUnsavedChanges]>;
|
||||
const childrenThatPublishUnsavedChanges = Object.values(children$.value).filter(
|
||||
(child) => apiPublishesUnsavedChanges(child) && apiHasUniqueId(child)
|
||||
) as Array<PublishesUnsavedChanges & HasUniqueId>;
|
||||
|
||||
return childrenThatPublishUnsavedChanges.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
childrenThatPublishUnsavedChanges.map(([childId, child]) =>
|
||||
child.unsavedChanges$.pipe(map((unsavedChanges) => ({ childId, unsavedChanges })))
|
||||
childrenThatPublishUnsavedChanges.map((child) =>
|
||||
child.hasUnsavedChanges$.pipe(
|
||||
map((hasUnsavedChanges) => ({ uuid: child.uuid, hasUnsavedChanges }))
|
||||
)
|
||||
)
|
||||
);
|
||||
}),
|
||||
debounceTime(DEBOUNCE_TIME),
|
||||
map((unsavedChildStates) => {
|
||||
const unsavedChildrenState: { [key: string]: object } = {};
|
||||
unsavedChildStates.forEach(({ childId, unsavedChanges }) => {
|
||||
if (unsavedChanges) {
|
||||
unsavedChildrenState[childId] = unsavedChanges;
|
||||
}
|
||||
});
|
||||
return Object.keys(unsavedChildrenState).length ? unsavedChildrenState : undefined;
|
||||
})
|
||||
debounceTime(DEBOUNCE_TIME)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import {
|
||||
COMPARATOR_SUBJECTS_DEBOUNCE,
|
||||
initializeUnsavedChanges,
|
||||
} from './initialize_unsaved_changes';
|
||||
import { PublishesUnsavedChanges, StateComparators } from '@kbn/presentation-publishing';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
interface TestState {
|
||||
key1: string;
|
||||
key2: string;
|
||||
}
|
||||
|
||||
describe('unsavedChanges api', () => {
|
||||
const lastSavedState = {
|
||||
key1: 'original key1 value',
|
||||
key2: 'original key2 value',
|
||||
} as TestState;
|
||||
const key1$ = new BehaviorSubject(lastSavedState.key1);
|
||||
const key2$ = new BehaviorSubject(lastSavedState.key2);
|
||||
const comparators = {
|
||||
key1: [key1$, (next: string) => key1$.next(next)],
|
||||
key2: [key2$, (next: string) => key2$.next(next)],
|
||||
} as StateComparators<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 waitFor(
|
||||
() =>
|
||||
expect(api?.unsavedChanges$.value).toEqual({
|
||||
key1: 'modified key1 value',
|
||||
}),
|
||||
{
|
||||
interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should have no unsaved changes after save', async () => {
|
||||
key1$.next('modified key1 value');
|
||||
await waitFor(() => expect(api?.unsavedChanges$.value).not.toBeUndefined(), {
|
||||
interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1,
|
||||
});
|
||||
|
||||
// trigger save
|
||||
parentApi.saveNotification$.next();
|
||||
|
||||
await waitFor(() => expect(api?.unsavedChanges$.value).toBeUndefined(), {
|
||||
interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should have no unsaved changes after reset', async () => {
|
||||
key1$.next('modified key1 value');
|
||||
await waitFor(() => expect(api?.unsavedChanges$.value).not.toBeUndefined(), {
|
||||
interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1,
|
||||
});
|
||||
|
||||
// trigger reset
|
||||
api?.resetUnsavedChanges();
|
||||
|
||||
await waitFor(() => expect(api?.unsavedChanges$.value).toBeUndefined(), {
|
||||
interval: COMPARATOR_SUBJECTS_DEBOUNCE + 1,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -8,111 +8,56 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
combineLatestWith,
|
||||
debounceTime,
|
||||
map,
|
||||
Subscription,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
getInitialValuesFromComparators,
|
||||
PublishesUnsavedChanges,
|
||||
PublishingSubject,
|
||||
runComparators,
|
||||
SerializedPanelState,
|
||||
StateComparators,
|
||||
HasSnapshottableState,
|
||||
areComparatorsEqual,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { apiHasSaveNotification } from '../has_save_notification';
|
||||
import { MaybePromise } from '@kbn/utility-types';
|
||||
import { Observable, combineLatestWith, debounceTime, map, of } from 'rxjs';
|
||||
import { apiHasLastSavedChildState } from '../last_saved_child_state';
|
||||
|
||||
export const COMPARATOR_SUBJECTS_DEBOUNCE = 100;
|
||||
const UNSAVED_CHANGES_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());
|
||||
})
|
||||
);
|
||||
export const initializeUnsavedChanges = <StateType extends object = object>({
|
||||
uuid,
|
||||
onReset,
|
||||
parentApi,
|
||||
getComparators,
|
||||
defaultState,
|
||||
serializeState,
|
||||
anyStateChange$,
|
||||
}: {
|
||||
uuid: string;
|
||||
parentApi: unknown;
|
||||
anyStateChange$: Observable<void>;
|
||||
serializeState: () => SerializedPanelState<StateType>;
|
||||
getComparators: () => StateComparators<StateType>;
|
||||
defaultState?: Partial<StateType>;
|
||||
onReset: (lastSavedPanelState?: SerializedPanelState<StateType>) => MaybePromise<void>;
|
||||
}): PublishesUnsavedChanges => {
|
||||
if (!apiHasLastSavedChildState<StateType>(parentApi)) {
|
||||
return {
|
||||
hasUnsavedChanges$: of(false),
|
||||
resetUnsavedChanges: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
const hasUnsavedChanges$ = anyStateChange$.pipe(
|
||||
combineLatestWith(
|
||||
parentApi.lastSavedStateForChild$(uuid).pipe(map((panelState) => panelState?.rawState))
|
||||
),
|
||||
debounceTime(UNSAVED_CHANGES_DEBOUNCE),
|
||||
map(([, lastSavedState]) => {
|
||||
const currentState = serializeState().rawState;
|
||||
return !areComparatorsEqual(getComparators(), lastSavedState, currentState, defaultState);
|
||||
})
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
combineLatest(comparatorSubjects)
|
||||
.pipe(
|
||||
debounceTime(COMPARATOR_SUBJECTS_DEBOUNCE),
|
||||
map((latestStates) =>
|
||||
comparatorKeys.reduce((acc, key, index) => {
|
||||
acc[key] = latestStates[index] as RuntimeState[typeof key];
|
||||
return acc;
|
||||
}, {} as Partial<RuntimeState>)
|
||||
),
|
||||
combineLatestWith(lastSavedState$)
|
||||
)
|
||||
.subscribe(([latestState, lastSavedState]) => {
|
||||
unsavedChanges$.next(
|
||||
runComparators(comparators, comparatorKeys, lastSavedState, latestState)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
api: {
|
||||
unsavedChanges$,
|
||||
resetUnsavedChanges: () => {
|
||||
const lastSaved = lastSavedState$.getValue();
|
||||
|
||||
// Do not reset to undefined or empty last saved state
|
||||
// Temporary fix for https://github.com/elastic/kibana/issues/201627
|
||||
// TODO remove when architecture fix resolves issue.
|
||||
if (comparatorKeys.length && (!lastSaved || Object.keys(lastSaved).length === 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of comparatorKeys) {
|
||||
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
|
||||
setter(lastSaved?.[key] as RuntimeState[typeof key]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
snapshotRuntimeState,
|
||||
} as PublishesUnsavedChanges<RuntimeState> & HasSnapshottableState<RuntimeState>,
|
||||
cleanup: () => {
|
||||
subscriptions.forEach((subscription) => subscription.unsubscribe());
|
||||
},
|
||||
const resetUnsavedChanges = async () => {
|
||||
const lastSavedState = parentApi.getLastSavedStateForChild(uuid);
|
||||
await onReset(lastSavedState);
|
||||
};
|
||||
|
||||
return { hasUnsavedChanges$, resetUnsavedChanges };
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ export const getMockPresentationContainer = (): PresentationContainer => {
|
|||
removePanel: jest.fn(),
|
||||
addNewPanel: jest.fn(),
|
||||
replacePanel: jest.fn(),
|
||||
getChildApi: jest.fn(),
|
||||
getPanelCount: jest.fn(),
|
||||
children$: new BehaviorSubject<{ [key: string]: unknown }>({}),
|
||||
};
|
||||
|
|
|
@ -9,5 +9,6 @@
|
|||
"kbn_references": [
|
||||
"@kbn/presentation-publishing",
|
||||
"@kbn/core-mount-utils-browser",
|
||||
"@kbn/utility-types",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { PublishingSubject } from '../publishing_subject';
|
||||
import { ComparatorDefinition } from './types';
|
||||
|
||||
/**
|
||||
* Comparators are required for every runtime state key. Occasionally, a comparator may
|
||||
* actually be optional. In those cases, implementors can fall back to this blank definition
|
||||
* which will always return 'true'.
|
||||
*/
|
||||
export const getUnchangingComparator = <
|
||||
State extends object,
|
||||
Key extends keyof State
|
||||
>(): ComparatorDefinition<State, Key> => {
|
||||
const subj = new BehaviorSubject<never>(null as never);
|
||||
return [subj as unknown as PublishingSubject<State[Key]>, () => {}, () => true];
|
||||
};
|
|
@ -1,45 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { StateComparators } from './types';
|
||||
|
||||
const defaultComparator = <T>(a: T, b: T) => a === b;
|
||||
|
||||
export const getInitialValuesFromComparators = <StateType extends object = object>(
|
||||
comparators: StateComparators<StateType>,
|
||||
comparatorKeys: Array<keyof StateType>
|
||||
) => {
|
||||
const initialValues: Partial<StateType> = {};
|
||||
for (const key of comparatorKeys) {
|
||||
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
|
||||
initialValues[key] = comparatorSubject?.value;
|
||||
}
|
||||
return initialValues;
|
||||
};
|
||||
|
||||
export const runComparators = <StateType extends object = object>(
|
||||
comparators: StateComparators<StateType>,
|
||||
comparatorKeys: Array<keyof StateType>,
|
||||
lastSavedState: StateType | undefined,
|
||||
latestState: Partial<StateType>
|
||||
) => {
|
||||
if (!lastSavedState || Object.keys(latestState).length === 0) {
|
||||
// if we have no last saved state, everything is considered a change
|
||||
return latestState;
|
||||
}
|
||||
const latestChanges: Partial<StateType> = {};
|
||||
for (const key of comparatorKeys) {
|
||||
const customComparator = comparators[key]?.[2]; // 2nd element of the tuple is the custom comparator
|
||||
const comparator = customComparator ?? defaultComparator;
|
||||
if (!comparator(lastSavedState?.[key], latestState[key], lastSavedState, latestState)) {
|
||||
latestChanges[key] = latestState[key];
|
||||
}
|
||||
}
|
||||
return Object.keys(latestChanges).length > 0 ? latestChanges : undefined;
|
||||
};
|
|
@ -1,27 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { PublishingSubject } from '../publishing_subject';
|
||||
|
||||
export type ComparatorFunction<StateType, KeyType extends keyof StateType> = (
|
||||
last: StateType[KeyType] | undefined,
|
||||
current: StateType[KeyType] | undefined,
|
||||
lastState?: Partial<StateType>,
|
||||
currentState?: Partial<StateType>
|
||||
) => boolean;
|
||||
|
||||
export type ComparatorDefinition<StateType, KeyType extends keyof StateType> = [
|
||||
PublishingSubject<StateType[KeyType]>,
|
||||
(value: StateType[KeyType]) => void,
|
||||
ComparatorFunction<StateType, KeyType>?
|
||||
];
|
||||
|
||||
export type StateComparators<StateType> = {
|
||||
[KeyType in keyof Required<StateType>]: ComparatorDefinition<StateType, KeyType>;
|
||||
};
|
|
@ -10,13 +10,14 @@
|
|||
export { isEmbeddableApiContext, type EmbeddableApiContext } from './embeddable_api_context';
|
||||
|
||||
export {
|
||||
getInitialValuesFromComparators,
|
||||
getUnchangingComparator,
|
||||
runComparators,
|
||||
type ComparatorDefinition,
|
||||
type ComparatorFunction,
|
||||
type StateComparators,
|
||||
} from './comparators';
|
||||
type WithAllKeys,
|
||||
runComparator,
|
||||
areComparatorsEqual,
|
||||
diffComparators,
|
||||
initializeStateManager,
|
||||
} from './state_manager';
|
||||
export {
|
||||
apiCanAccessViewMode,
|
||||
getInheritedViewMode,
|
||||
|
@ -29,9 +30,10 @@ export {
|
|||
} from './interfaces/can_lock_hover_actions';
|
||||
export { fetch$, useFetchContext, type FetchContext } from './interfaces/fetch/fetch';
|
||||
export {
|
||||
initializeTimeRange,
|
||||
initializeTimeRangeManager,
|
||||
timeRangeComparators,
|
||||
type SerializedTimeRange,
|
||||
} from './interfaces/fetch/initialize_time_range';
|
||||
} from './interfaces/fetch/time_range_manager';
|
||||
export { apiPublishesReload, type PublishesReload } from './interfaces/fetch/publishes_reload';
|
||||
export {
|
||||
apiPublishesFilters,
|
||||
|
@ -73,9 +75,7 @@ export {
|
|||
export { apiHasParentApi, type HasParentApi } from './interfaces/has_parent_api';
|
||||
export {
|
||||
apiHasSerializableState,
|
||||
apiHasSnapshottableState,
|
||||
type HasSerializableState,
|
||||
type HasSnapshottableState,
|
||||
type SerializedPanelState,
|
||||
} from './interfaces/has_serializable_state';
|
||||
export {
|
||||
|
@ -146,6 +146,7 @@ export {
|
|||
export {
|
||||
initializeTitleManager,
|
||||
stateHasTitles,
|
||||
titleComparators,
|
||||
type TitlesApi,
|
||||
type SerializedTitles,
|
||||
} from './interfaces/titles/title_manager';
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import fastIsEqual from 'fast-deep-equal';
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import { StateComparators } from '../../comparators';
|
||||
import { PublishesWritableTimeRange } from './publishes_unified_search';
|
||||
|
||||
export interface SerializedTimeRange {
|
||||
timeRange?: TimeRange | undefined;
|
||||
}
|
||||
|
||||
export const initializeTimeRange = (
|
||||
rawState: SerializedTimeRange
|
||||
): {
|
||||
serialize: () => SerializedTimeRange;
|
||||
api: PublishesWritableTimeRange;
|
||||
comparators: StateComparators<SerializedTimeRange>;
|
||||
} => {
|
||||
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(rawState.timeRange);
|
||||
function setTimeRange(nextTimeRange: TimeRange | undefined) {
|
||||
timeRange$.next(nextTimeRange);
|
||||
}
|
||||
|
||||
return {
|
||||
serialize: () => ({
|
||||
timeRange: timeRange$.value,
|
||||
}),
|
||||
comparators: {
|
||||
timeRange: [timeRange$, setTimeRange, fastIsEqual],
|
||||
} as StateComparators<SerializedTimeRange>,
|
||||
api: {
|
||||
timeRange$,
|
||||
setTimeRange,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import { StateManager } from '../../state_manager/types';
|
||||
import { StateComparators, WithAllKeys, initializeStateManager } from '../../state_manager';
|
||||
|
||||
export interface SerializedTimeRange {
|
||||
timeRange?: TimeRange | undefined;
|
||||
}
|
||||
|
||||
const defaultTimeRangeState: WithAllKeys<SerializedTimeRange> = {
|
||||
timeRange: undefined,
|
||||
};
|
||||
|
||||
export const timeRangeComparators: StateComparators<SerializedTimeRange> = {
|
||||
timeRange: 'deepEquality',
|
||||
};
|
||||
|
||||
export const initializeTimeRangeManager = (
|
||||
initialTimeRangeState: SerializedTimeRange
|
||||
): StateManager<SerializedTimeRange> =>
|
||||
initializeStateManager(initialTimeRangeState, defaultTimeRangeState);
|
|
@ -29,21 +29,3 @@ export interface HasSerializableState<State extends object = object> {
|
|||
export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => {
|
||||
return Boolean((api as HasSerializableState)?.serializeState);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use HasSerializableState instead
|
||||
*/
|
||||
export interface HasSnapshottableState<RuntimeState extends object = object> {
|
||||
/**
|
||||
* Serializes all runtime state exactly as it appears. This can be used
|
||||
* to rehydrate a component's state without needing to serialize then deserialize it.
|
||||
*/
|
||||
snapshotRuntimeState: () => RuntimeState;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use apiHasSerializableState instead
|
||||
*/
|
||||
export const apiHasSnapshottableState = (api: unknown | null): api is HasSnapshottableState => {
|
||||
return Boolean((api as HasSnapshottableState)?.snapshotRuntimeState);
|
||||
};
|
||||
|
|
|
@ -7,17 +7,18 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { PublishingSubject } from '../publishing_subject';
|
||||
import { MaybePromise } from '@kbn/utility-types';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface PublishesUnsavedChanges<Runtime extends object = object> {
|
||||
unsavedChanges$: PublishingSubject<Partial<Runtime> | undefined>;
|
||||
resetUnsavedChanges: () => boolean;
|
||||
export interface PublishesUnsavedChanges {
|
||||
hasUnsavedChanges$: Observable<boolean>; // Observable rather than publishingSubject because it should be derived.
|
||||
resetUnsavedChanges: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const apiPublishesUnsavedChanges = (api: unknown): api is PublishesUnsavedChanges => {
|
||||
return Boolean(
|
||||
api &&
|
||||
(api as PublishesUnsavedChanges).unsavedChanges$ &&
|
||||
(api as PublishesUnsavedChanges).hasUnsavedChanges$ &&
|
||||
(api as PublishesUnsavedChanges).resetUnsavedChanges
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { initializeTitleManager, SerializedTitles } from './title_manager';
|
||||
import { ComparatorFunction } from '../../state_manager';
|
||||
import { initializeTitleManager, SerializedTitles, titleComparators } from './title_manager';
|
||||
|
||||
describe('titles api', () => {
|
||||
const rawState: SerializedTitles = {
|
||||
|
@ -20,7 +21,7 @@ describe('titles api', () => {
|
|||
const { api } = initializeTitleManager(rawState);
|
||||
expect(api.title$.value).toBe(rawState.title);
|
||||
expect(api.description$.value).toBe(rawState.description);
|
||||
expect(api.hideTitle$.value).toBe(rawState.hidePanelTitles);
|
||||
expect(api.hidePanelTitles$.value).toBe(rawState.hidePanelTitles);
|
||||
});
|
||||
|
||||
it('should update publishing subject values when set functions are called', () => {
|
||||
|
@ -28,18 +29,18 @@ describe('titles api', () => {
|
|||
|
||||
api.setTitle('even cooler title');
|
||||
api.setDescription('super uncool description');
|
||||
api.setHideTitle(true);
|
||||
api.setHidePanelTitles(true);
|
||||
|
||||
expect(api.title$.value).toEqual('even cooler title');
|
||||
expect(api.description$.value).toEqual('super uncool description');
|
||||
expect(api.hideTitle$.value).toBe(true);
|
||||
expect(api.hidePanelTitles$.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly serialize current state', () => {
|
||||
const titleManager = initializeTitleManager(rawState);
|
||||
titleManager.api.setTitle('UH OH, A TITLE');
|
||||
|
||||
const serializedTitles = titleManager.serialize();
|
||||
const serializedTitles = titleManager.getLatestState();
|
||||
expect(serializedTitles).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"description": "less cool description",
|
||||
|
@ -49,19 +50,13 @@ describe('titles api', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
it('should return the correct set of comparators', () => {
|
||||
const { comparators } = initializeTitleManager(rawState);
|
||||
|
||||
expect(comparators.title).toBeDefined();
|
||||
expect(comparators.description).toBeDefined();
|
||||
expect(comparators.hidePanelTitles).toBeDefined();
|
||||
});
|
||||
|
||||
it('should correctly compare hidePanelTitles with custom comparator', () => {
|
||||
const { comparators } = initializeTitleManager(rawState);
|
||||
|
||||
expect(comparators.hidePanelTitles![2]!(true, false)).toBe(false);
|
||||
expect(comparators.hidePanelTitles![2]!(undefined, false)).toBe(true);
|
||||
expect(comparators.hidePanelTitles![2]!(true, undefined)).toBe(false);
|
||||
const comparator = titleComparators.hidePanelTitles as ComparatorFunction<
|
||||
SerializedTitles,
|
||||
'hidePanelTitles'
|
||||
>;
|
||||
expect(comparator(true, false)).toBe(false);
|
||||
expect(comparator(undefined, false)).toBe(true);
|
||||
expect(comparator(true, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { StateComparators } from '../../comparators';
|
||||
import { WithAllKeys } from '../../state_manager';
|
||||
import { initializeStateManager } from '../../state_manager/state_manager';
|
||||
import { StateComparators, StateManager } from '../../state_manager/types';
|
||||
import { PublishesWritableDescription } from './publishes_description';
|
||||
import { PublishesWritableTitle } from './publishes_title';
|
||||
import { PublishesTitle, PublishesWritableTitle } from './publishes_title';
|
||||
|
||||
export interface SerializedTitles {
|
||||
title?: string;
|
||||
|
@ -18,6 +19,18 @@ export interface SerializedTitles {
|
|||
hidePanelTitles?: boolean;
|
||||
}
|
||||
|
||||
const defaultTitlesState: WithAllKeys<SerializedTitles> = {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
hidePanelTitles: undefined,
|
||||
};
|
||||
|
||||
export const titleComparators: StateComparators<SerializedTitles> = {
|
||||
title: 'referenceEquality',
|
||||
description: 'referenceEquality',
|
||||
hidePanelTitles: (a, b) => Boolean(a) === Boolean(b),
|
||||
};
|
||||
|
||||
export const stateHasTitles = (state: unknown): state is SerializedTitles => {
|
||||
return (
|
||||
(state as SerializedTitles)?.title !== undefined ||
|
||||
|
@ -29,44 +42,23 @@ export const stateHasTitles = (state: unknown): state is SerializedTitles => {
|
|||
export interface TitlesApi extends PublishesWritableTitle, PublishesWritableDescription {}
|
||||
|
||||
export const initializeTitleManager = (
|
||||
rawState: SerializedTitles
|
||||
): {
|
||||
api: TitlesApi;
|
||||
comparators: StateComparators<SerializedTitles>;
|
||||
serialize: () => SerializedTitles;
|
||||
initialTitlesState: SerializedTitles
|
||||
): StateManager<SerializedTitles> & {
|
||||
api: {
|
||||
hideTitle$: PublishesTitle['hideTitle$'];
|
||||
setHideTitle: PublishesWritableTitle['setHideTitle'];
|
||||
};
|
||||
} => {
|
||||
const title$ = new BehaviorSubject<string | undefined>(rawState.title);
|
||||
const description$ = new BehaviorSubject<string | undefined>(rawState.description);
|
||||
const hideTitle$ = new BehaviorSubject<boolean | undefined>(rawState.hidePanelTitles);
|
||||
|
||||
const setTitle = (value: string | undefined) => {
|
||||
if (value !== title$.value) title$.next(value);
|
||||
};
|
||||
const setHideTitle = (value: boolean | undefined) => {
|
||||
if (value !== hideTitle$.value) hideTitle$.next(value);
|
||||
};
|
||||
const setDescription = (value: string | undefined) => {
|
||||
if (value !== description$.value) description$.next(value);
|
||||
};
|
||||
|
||||
const stateManager = initializeStateManager(initialTitlesState, defaultTitlesState);
|
||||
return {
|
||||
...stateManager,
|
||||
api: {
|
||||
title$,
|
||||
hideTitle$,
|
||||
setTitle,
|
||||
setHideTitle,
|
||||
description$,
|
||||
setDescription,
|
||||
...stateManager.api,
|
||||
// SerializedTitles defines hideTitles as hidePanelTitles
|
||||
// This state is persisted and this naming conflict will be resolved TBD
|
||||
// add named APIs that match interface names as a work-around
|
||||
hideTitle$: stateManager.api.hidePanelTitles$,
|
||||
setHideTitle: stateManager.api.setHidePanelTitles,
|
||||
},
|
||||
comparators: {
|
||||
title: [title$, setTitle],
|
||||
description: [description$, setDescription],
|
||||
hidePanelTitles: [hideTitle$, setHideTitle, (a, b) => Boolean(a) === Boolean(b)],
|
||||
} as StateComparators<SerializedTitles>,
|
||||
serialize: () => ({
|
||||
title: title$.value,
|
||||
hidePanelTitles: hideTitle$.value,
|
||||
description: description$.value,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -7,6 +7,6 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type { ComparatorFunction, ComparatorDefinition, StateComparators } from './types';
|
||||
export { getInitialValuesFromComparators, runComparators } from './state_comparators';
|
||||
export { getUnchangingComparator } from './fallback_comparator';
|
||||
export { areComparatorsEqual, diffComparators, runComparator } from './state_comparators';
|
||||
export { initializeStateManager } from './state_manager';
|
||||
export type { ComparatorFunction, StateComparators, WithAllKeys } from './types';
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { StateComparators } from './types';
|
||||
|
||||
const referenceEquality = <T>(a: T, b: T) => a === b;
|
||||
const deepEquality = <T>(a: T, b: T) => deepEqual(a, b);
|
||||
|
||||
export const runComparator = <StateType extends object = object>(
|
||||
comparator: StateComparators<StateType>[keyof StateType],
|
||||
lastSavedState?: StateType,
|
||||
latestState?: StateType,
|
||||
lastSavedValue?: StateType[keyof StateType],
|
||||
latestValue?: StateType[keyof StateType]
|
||||
): boolean => {
|
||||
if (comparator === 'skip') return true;
|
||||
if (comparator === 'deepEquality') return deepEquality(lastSavedValue, latestValue);
|
||||
if (comparator === 'referenceEquality') return referenceEquality(lastSavedValue, latestValue);
|
||||
if (typeof comparator === 'function') {
|
||||
return comparator(lastSavedValue, latestValue, lastSavedState, latestState);
|
||||
}
|
||||
throw new Error(`Comparator ${comparator} is not a valid comparator.`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Run all comparators, and return an object containing only the keys that are not equal, set to the value of the latest state
|
||||
*/
|
||||
export const diffComparators = <StateType extends object = object>(
|
||||
comparators: StateComparators<StateType>,
|
||||
lastSavedState?: StateType,
|
||||
latestState?: StateType
|
||||
): Partial<StateType> => {
|
||||
return Object.keys(comparators).reduce((acc, key) => {
|
||||
const comparator = comparators[key as keyof StateType];
|
||||
const lastSavedValue = lastSavedState?.[key as keyof StateType];
|
||||
const currentValue = latestState?.[key as keyof StateType];
|
||||
|
||||
if (!runComparator(comparator, lastSavedState, latestState, lastSavedValue, currentValue)) {
|
||||
acc[key as keyof StateType] = currentValue;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Partial<StateType>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Run comparators until at least one returns false
|
||||
*/
|
||||
export const areComparatorsEqual = <StateType extends object = object>(
|
||||
comparators: StateComparators<StateType>,
|
||||
lastSavedState?: StateType,
|
||||
currentState?: StateType,
|
||||
defaultState?: Partial<StateType>
|
||||
): boolean => {
|
||||
return Object.keys(comparators).every((key) => {
|
||||
const comparator = comparators[key as keyof StateType];
|
||||
const lastSavedValue =
|
||||
lastSavedState?.[key as keyof StateType] ?? defaultState?.[key as keyof StateType];
|
||||
const currentValue =
|
||||
currentState?.[key as keyof StateType] ?? defaultState?.[key as keyof StateType];
|
||||
|
||||
return runComparator(comparator, lastSavedState, currentState, lastSavedValue, currentValue);
|
||||
});
|
||||
};
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject, map, merge } from 'rxjs';
|
||||
import { StateManager, WithAllKeys } from './types';
|
||||
|
||||
type SubjectOf<StateType extends object> = BehaviorSubject<WithAllKeys<StateType>[keyof StateType]>;
|
||||
|
||||
interface UnstructuredSettersAndSubjects<StateType extends object> {
|
||||
[key: string]: SubjectOf<StateType> | ((value: StateType[keyof StateType]) => void);
|
||||
}
|
||||
|
||||
type KeyToSubjectMap<StateType extends object> = {
|
||||
[Key in keyof StateType]?: SubjectOf<StateType>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a composable state manager instance for a given state type.
|
||||
* @param initialState - The initial state of the state manager.
|
||||
* @param defaultState - The default state of the state manager. Every key in this state must be present, for optional keys specify undefined explicly.
|
||||
* @param customComparators - Custom comparators for each key in the state. If not provided, defaults to reference equality.
|
||||
*/
|
||||
export const initializeStateManager = <StateType extends object>(
|
||||
initialState: StateType,
|
||||
defaultState: WithAllKeys<StateType>
|
||||
): StateManager<StateType> => {
|
||||
const allState = { ...defaultState, ...initialState };
|
||||
const allSubjects: Array<SubjectOf<StateType>> = [];
|
||||
const keyToSubjectMap: KeyToSubjectMap<StateType> = {};
|
||||
|
||||
/**
|
||||
* Build the API for this state type. We loop through default state because it is guaranteed to
|
||||
* have all keys and we use it to build the API with a setter and a subject for each key.
|
||||
*/
|
||||
const api: StateManager<StateType>['api'] = (
|
||||
Object.keys(defaultState) as Array<keyof StateType>
|
||||
).reduce((acc, key) => {
|
||||
const subject = new BehaviorSubject(allState[key]);
|
||||
const setter = (value: StateType[typeof key]) => {
|
||||
subject.next(value);
|
||||
};
|
||||
|
||||
const capitalizedKey = (key as string).charAt(0).toUpperCase() + (key as string).slice(1);
|
||||
acc[`set${capitalizedKey}`] = setter;
|
||||
acc[`${key as string}$`] = subject;
|
||||
|
||||
allSubjects.push(subject);
|
||||
keyToSubjectMap[key] = subject;
|
||||
return acc;
|
||||
}, {} as UnstructuredSettersAndSubjects<StateType>) as StateManager<StateType>['api'];
|
||||
|
||||
/**
|
||||
* Gets the latest state of this state manager.
|
||||
*/
|
||||
const getLatestState: StateManager<StateType>['getLatestState'] = () => {
|
||||
return Object.keys(defaultState).reduce((acc, key) => {
|
||||
acc[key as keyof StateType] = keyToSubjectMap[key as keyof StateType]!.getValue();
|
||||
return acc;
|
||||
}, {} as StateType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reinitializes the state of this state manager. Takes a partial state object that may be undefined.
|
||||
*
|
||||
* This method resets ALL keys in this state, if a key is not present in the new state, it will be set to the default value.
|
||||
*/
|
||||
const reinitializeState = (newState?: Partial<StateType>) => {
|
||||
for (const [key, subject] of Object.entries<SubjectOf<StateType>>(
|
||||
keyToSubjectMap as { [key: string]: SubjectOf<StateType> }
|
||||
)) {
|
||||
subject.next(newState?.[key as keyof StateType] ?? defaultState[key as keyof StateType]);
|
||||
}
|
||||
};
|
||||
|
||||
// SERIALIZED STATE ONLY TODO: Remember that the state manager DOES NOT contain comparators, because it's meant for Runtime state, and comparators should be written against serialized state.
|
||||
|
||||
return {
|
||||
api,
|
||||
getLatestState,
|
||||
reinitializeState,
|
||||
anyStateChange$: merge(...allSubjects).pipe(map(() => undefined)),
|
||||
};
|
||||
};
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { PublishingSubject } from '../publishing_subject';
|
||||
|
||||
export type WithAllKeys<T extends object> = { [Key in keyof Required<T>]: T[Key] };
|
||||
|
||||
export type ComparatorFunction<StateType, KeyType extends keyof StateType> = (
|
||||
last: StateType[KeyType] | undefined,
|
||||
current: StateType[KeyType] | undefined,
|
||||
lastState?: Partial<StateType>,
|
||||
currentState?: Partial<StateType>
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* A type that maps each key in a state type to a definition of how it should be compared. If a custom
|
||||
* comparator is provided, return true if the values are equal, false otherwise.
|
||||
*/
|
||||
export type StateComparators<StateType> = {
|
||||
[KeyType in keyof Required<StateType>]:
|
||||
| 'referenceEquality'
|
||||
| 'deepEquality'
|
||||
| 'skip'
|
||||
| ComparatorFunction<StateType, KeyType>;
|
||||
};
|
||||
|
||||
export type CustomComparators<StateType> = {
|
||||
[KeyType in keyof StateType]?: ComparatorFunction<StateType, KeyType>;
|
||||
};
|
||||
|
||||
type SubjectsOf<T extends object> = {
|
||||
[KeyType in keyof Required<T> as `${string & KeyType}$`]: PublishingSubject<T[KeyType]>;
|
||||
};
|
||||
|
||||
type SettersOf<T extends object> = {
|
||||
[KeyType in keyof Required<T> as `set${Capitalize<string & KeyType>}`]: (
|
||||
value: T[KeyType]
|
||||
) => void;
|
||||
};
|
||||
|
||||
export interface StateManager<StateType extends object> {
|
||||
getLatestState: () => WithAllKeys<StateType>;
|
||||
reinitializeState: (newState?: Partial<StateType>) => void;
|
||||
api: SettersOf<StateType> & SubjectsOf<StateType>;
|
||||
anyStateChange$: Observable<void>;
|
||||
}
|
|
@ -11,6 +11,7 @@
|
|||
"@kbn/data-views-plugin",
|
||||
"@kbn/expressions-plugin",
|
||||
"@kbn/core-execution-context-common",
|
||||
"@kbn/content-management-utils"
|
||||
"@kbn/content-management-utils",
|
||||
"@kbn/utility-types"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ export const registerCreateImageAction = () => {
|
|||
|
||||
canAddNewPanelParent.addNewPanel<ImageEmbeddableSerializedState>({
|
||||
panelType: IMAGE_EMBEDDABLE_TYPE,
|
||||
initialState: { imageConfig },
|
||||
serializedState: { rawState: { imageConfig } },
|
||||
});
|
||||
} catch {
|
||||
// swallow the rejection, since this just means the user closed without saving
|
||||
|
|
|
@ -8,14 +8,13 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import deepEqual from 'react-fast-compare';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BehaviorSubject, map, merge } from 'rxjs';
|
||||
|
||||
import { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { PresentationContainer } from '@kbn/presentation-containers';
|
||||
import { getUnchangingComparator, initializeTitleManager } from '@kbn/presentation-publishing';
|
||||
import { PresentationContainer, initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import { initializeTitleManager, titleComparators } from '@kbn/presentation-publishing';
|
||||
|
||||
import { IMAGE_CLICK_TRIGGER } from '../actions';
|
||||
import { openImageEditor } from '../components/image_editor/open_image_editor';
|
||||
|
@ -30,72 +29,82 @@ export const getImageEmbeddableFactory = ({
|
|||
}: {
|
||||
embeddableEnhanced?: EmbeddableEnhancedPluginStart;
|
||||
}) => {
|
||||
const imageEmbeddableFactory: ReactEmbeddableFactory<
|
||||
ImageEmbeddableSerializedState,
|
||||
const imageEmbeddableFactory: EmbeddableFactory<
|
||||
ImageEmbeddableSerializedState,
|
||||
ImageEmbeddableApi
|
||||
> = {
|
||||
type: IMAGE_EMBEDDABLE_TYPE,
|
||||
deserializeState: (state) => state.rawState,
|
||||
buildEmbeddable: async (initialState, buildApi, uuid) => {
|
||||
const titleManager = initializeTitleManager(initialState);
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => {
|
||||
const titleManager = initializeTitleManager(initialState.rawState);
|
||||
|
||||
const dynamicActionsApi = embeddableEnhanced?.initializeReactEmbeddableDynamicActions(
|
||||
const dynamicActionsManager = embeddableEnhanced?.initializeEmbeddableDynamicActions(
|
||||
uuid,
|
||||
() => titleManager.api.title$.getValue(),
|
||||
initialState
|
||||
initialState.rawState
|
||||
);
|
||||
// if it is provided, start the dynamic actions manager
|
||||
const maybeStopDynamicActions = dynamicActionsApi?.startDynamicActions();
|
||||
const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions();
|
||||
|
||||
const filesClient = filesService.filesClientFactory.asUnscoped<FileImageMetadata>();
|
||||
const imageConfig$ = new BehaviorSubject<ImageConfig>(initialState.imageConfig);
|
||||
const imageConfig$ = new BehaviorSubject<ImageConfig>(initialState.rawState.imageConfig);
|
||||
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
|
||||
|
||||
const embeddable = buildApi(
|
||||
{
|
||||
...titleManager.api,
|
||||
...(dynamicActionsApi?.dynamicActionsApi ?? {}),
|
||||
dataLoading$,
|
||||
supportedTriggers: () => [IMAGE_CLICK_TRIGGER],
|
||||
onEdit: async () => {
|
||||
try {
|
||||
const newImageConfig = await openImageEditor({
|
||||
parentApi: embeddable.parentApi as PresentationContainer,
|
||||
initialImageConfig: imageConfig$.getValue(),
|
||||
});
|
||||
imageConfig$.next(newImageConfig);
|
||||
} catch {
|
||||
// swallow the rejection, since this just means the user closed without saving
|
||||
}
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName.edit', {
|
||||
defaultMessage: 'image',
|
||||
}),
|
||||
serializeState: () => {
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.serialize(),
|
||||
...(dynamicActionsApi?.serializeDynamicActions() ?? {}),
|
||||
imageConfig: imageConfig$.getValue(),
|
||||
},
|
||||
};
|
||||
function serializeState() {
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.getLatestState(),
|
||||
...(dynamicActionsManager?.getLatestState() ?? {}),
|
||||
imageConfig: imageConfig$.getValue(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const unsavedChangesApi = initializeUnsavedChanges<ImageEmbeddableSerializedState>({
|
||||
uuid,
|
||||
parentApi,
|
||||
serializeState,
|
||||
anyStateChange$: merge(
|
||||
titleManager.anyStateChange$,
|
||||
imageConfig$.pipe(map(() => undefined))
|
||||
),
|
||||
getComparators: () => {
|
||||
return {
|
||||
...(dynamicActionsManager?.comparators ?? { enhancements: 'skip' }),
|
||||
...titleComparators,
|
||||
imageConfig: 'deepEquality',
|
||||
};
|
||||
},
|
||||
{
|
||||
...titleManager.comparators,
|
||||
...(dynamicActionsApi?.dynamicActionsComparator ?? {
|
||||
enhancements: getUnchangingComparator(),
|
||||
onReset: (lastSaved) => {
|
||||
titleManager.reinitializeState(lastSaved?.rawState);
|
||||
dynamicActionsManager?.reinitializeState(lastSaved?.rawState ?? {});
|
||||
if (lastSaved) imageConfig$.next(lastSaved.rawState.imageConfig);
|
||||
},
|
||||
});
|
||||
|
||||
const embeddable = finalizeApi({
|
||||
...titleManager.api,
|
||||
...(dynamicActionsManager?.api ?? {}),
|
||||
...unsavedChangesApi,
|
||||
dataLoading$,
|
||||
supportedTriggers: () => [IMAGE_CLICK_TRIGGER],
|
||||
onEdit: async () => {
|
||||
try {
|
||||
const newImageConfig = await openImageEditor({
|
||||
parentApi: embeddable.parentApi as PresentationContainer,
|
||||
initialImageConfig: imageConfig$.getValue(),
|
||||
});
|
||||
imageConfig$.next(newImageConfig);
|
||||
} catch {
|
||||
// swallow the rejection, since this just means the user closed without saving
|
||||
}
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName.edit', {
|
||||
defaultMessage: 'image',
|
||||
}),
|
||||
imageConfig: [
|
||||
imageConfig$,
|
||||
(value) => imageConfig$.next(value),
|
||||
(a, b) => deepEqual(a, b),
|
||||
],
|
||||
}
|
||||
);
|
||||
serializeState,
|
||||
});
|
||||
return {
|
||||
api: embeddable,
|
||||
Component: () => {
|
||||
|
|
|
@ -17,10 +17,11 @@ import {
|
|||
apiPublishesTitle,
|
||||
apiPublishesSavedObjectId,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import type { LinksParentApi } from '../types';
|
||||
import type { LinksParentApi, LinksSerializedState } from '../types';
|
||||
import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common';
|
||||
import { ADD_LINKS_PANEL_ACTION_ID } from './constants';
|
||||
import { openEditorFlyout } from '../editor/open_editor_flyout';
|
||||
import { serializeLinksAttributes } from '../lib/serialize_attributes';
|
||||
|
||||
export const isParentApiCompatible = (parentApi: unknown): parentApi is LinksParentApi =>
|
||||
apiIsPresentationContainer(parentApi) &&
|
||||
|
@ -42,9 +43,29 @@ export const addLinksPanelAction: ActionDefinition<EmbeddableApiContext> = {
|
|||
});
|
||||
if (!runtimeState) return;
|
||||
|
||||
await embeddable.addNewPanel({
|
||||
function serializeState() {
|
||||
if (!runtimeState) return;
|
||||
|
||||
if (runtimeState.savedObjectId !== undefined) {
|
||||
return {
|
||||
rawState: {
|
||||
savedObjectId: runtimeState.savedObjectId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { attributes, references } = serializeLinksAttributes(runtimeState);
|
||||
return {
|
||||
rawState: {
|
||||
attributes,
|
||||
},
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
await embeddable.addNewPanel<LinksSerializedState>({
|
||||
panelType: CONTENT_ID,
|
||||
initialState: runtimeState,
|
||||
serializedState: serializeState(),
|
||||
});
|
||||
},
|
||||
grouping: [ADD_PANEL_ANNOTATION_GROUP],
|
||||
|
|
|
@ -71,8 +71,8 @@ export const runSaveToLibrary = async (
|
|||
});
|
||||
resolve({
|
||||
...newState,
|
||||
defaultPanelTitle: newTitle,
|
||||
defaultPanelDescription: newDescription,
|
||||
defaultTitle: newTitle,
|
||||
defaultDescription: newDescription,
|
||||
savedObjectId: id,
|
||||
});
|
||||
return { id };
|
||||
|
|
|
@ -12,17 +12,11 @@ import { render, screen, waitFor } from '@testing-library/react';
|
|||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
import { setStubKibanaServices } from '@kbn/presentation-panel-plugin/public/mocks';
|
||||
import { EuiThemeProvider } from '@elastic/eui';
|
||||
import { getLinksEmbeddableFactory } from './links_embeddable';
|
||||
import { deserializeState, getLinksEmbeddableFactory } from './links_embeddable';
|
||||
import { Link } from '../../common/content_management';
|
||||
import { CONTENT_ID } from '../../common';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
LinksApi,
|
||||
LinksParentApi,
|
||||
LinksRuntimeState,
|
||||
LinksSerializedState,
|
||||
ResolvedLink,
|
||||
} from '../types';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { LinksApi, LinksParentApi, LinksSerializedState, ResolvedLink } from '../types';
|
||||
import { linksClient } from '../content_management';
|
||||
import { getMockLinksParentApi } from '../mocks';
|
||||
|
||||
|
@ -148,7 +142,7 @@ const renderEmbeddable = (
|
|||
) => {
|
||||
return render(
|
||||
<EuiThemeProvider>
|
||||
<ReactEmbeddableRenderer<LinksSerializedState, LinksRuntimeState, LinksApi>
|
||||
<EmbeddableRenderer<LinksSerializedState, LinksApi>
|
||||
type={CONTENT_ID}
|
||||
onApiAvailable={jest.fn()}
|
||||
getParentApi={jest.fn().mockReturnValue(parent)}
|
||||
|
@ -178,8 +172,8 @@ describe('getLinksEmbeddableFactory', () => {
|
|||
} as LinksSerializedState;
|
||||
|
||||
const expectedRuntimeState = {
|
||||
defaultPanelTitle: 'links 001',
|
||||
defaultPanelDescription: 'some links',
|
||||
defaultTitle: 'links 001',
|
||||
defaultDescription: 'some links',
|
||||
layout: 'vertical',
|
||||
links: getResolvedLinks(),
|
||||
description: 'just a few links',
|
||||
|
@ -195,7 +189,7 @@ describe('getLinksEmbeddableFactory', () => {
|
|||
});
|
||||
|
||||
test('deserializeState', async () => {
|
||||
const deserializedState = await factory.deserializeState({
|
||||
const deserializedState = await deserializeState({
|
||||
rawState,
|
||||
references: [], // no references passed because the panel is by reference
|
||||
});
|
||||
|
@ -266,8 +260,8 @@ describe('getLinksEmbeddableFactory', () => {
|
|||
} as LinksSerializedState;
|
||||
|
||||
const expectedRuntimeState = {
|
||||
defaultPanelTitle: undefined,
|
||||
defaultPanelDescription: undefined,
|
||||
defaultTitle: undefined,
|
||||
defaultDescription: undefined,
|
||||
layout: 'horizontal',
|
||||
links: getResolvedLinks(),
|
||||
description: 'just a few links',
|
||||
|
@ -283,7 +277,7 @@ describe('getLinksEmbeddableFactory', () => {
|
|||
});
|
||||
|
||||
test('deserializeState', async () => {
|
||||
const deserializedState = await factory.deserializeState({
|
||||
const deserializedState = await deserializeState({
|
||||
rawState,
|
||||
references,
|
||||
});
|
||||
|
|
|
@ -8,24 +8,26 @@
|
|||
*/
|
||||
|
||||
import React, { createContext, useMemo } from 'react';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { cloneDeep, isUndefined, omitBy } from 'lodash';
|
||||
import { BehaviorSubject, merge } from 'rxjs';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { EuiListGroup, EuiPanel, UseEuiTheme } from '@elastic/eui';
|
||||
|
||||
import { PanelIncompatibleError, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { PanelIncompatibleError, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
SerializedTitles,
|
||||
initializeTitleManager,
|
||||
SerializedPanelState,
|
||||
useBatchedOptionalPublishingSubjects,
|
||||
initializeStateManager,
|
||||
titleComparators,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
|
||||
import { apiIsPresentationContainer, initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import {
|
||||
CONTENT_ID,
|
||||
DASHBOARD_LINK_TYPE,
|
||||
LinksLayoutType,
|
||||
LINKS_HORIZONTAL_LAYOUT,
|
||||
LINKS_VERTICAL_LAYOUT,
|
||||
} from '../../common/content_management';
|
||||
|
@ -38,7 +40,6 @@ import {
|
|||
LinksParentApi,
|
||||
LinksRuntimeState,
|
||||
LinksSerializedState,
|
||||
ResolvedLink,
|
||||
} from '../types';
|
||||
import { DISPLAY_NAME } from '../../common';
|
||||
import { injectReferences } from '../../common/persistable_state';
|
||||
|
@ -54,172 +55,212 @@ import { isParentApiCompatible } from '../actions/add_links_panel_action';
|
|||
|
||||
export const LinksContext = createContext<LinksApi | null>(null);
|
||||
|
||||
export const getLinksEmbeddableFactory = () => {
|
||||
const linksEmbeddableFactory: ReactEmbeddableFactory<
|
||||
LinksSerializedState,
|
||||
LinksRuntimeState,
|
||||
LinksApi
|
||||
> = {
|
||||
type: CONTENT_ID,
|
||||
deserializeState: async (serializedState) => {
|
||||
// Clone the state to avoid an object not extensible error when injecting references
|
||||
const state = cloneDeep(serializedState.rawState);
|
||||
const { title, description, hidePanelTitles } = serializedState.rawState;
|
||||
export async function deserializeState(
|
||||
serializedState: SerializedPanelState<LinksSerializedState>
|
||||
) {
|
||||
// Clone the state to avoid an object not extensible error when injecting references
|
||||
const state = cloneDeep(serializedState.rawState);
|
||||
const { title, description, hidePanelTitles } = serializedState.rawState;
|
||||
|
||||
if (linksSerializeStateIsByReference(state)) {
|
||||
const linksSavedObject = await linksClient.get(state.savedObjectId);
|
||||
const runtimeState = await deserializeLinksSavedObject(linksSavedObject.item);
|
||||
if (linksSerializeStateIsByReference(state)) {
|
||||
const linksSavedObject = await linksClient.get(state.savedObjectId);
|
||||
const runtimeState = await deserializeLinksSavedObject(linksSavedObject.item);
|
||||
return {
|
||||
...runtimeState,
|
||||
title,
|
||||
description,
|
||||
hidePanelTitles,
|
||||
};
|
||||
}
|
||||
|
||||
const { attributes: attributesWithInjectedIds } = injectReferences({
|
||||
attributes: state.attributes,
|
||||
references: serializedState.references ?? [],
|
||||
});
|
||||
|
||||
const resolvedLinks = await resolveLinks(attributesWithInjectedIds.links ?? []);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
hidePanelTitles,
|
||||
links: resolvedLinks,
|
||||
layout: attributesWithInjectedIds.layout,
|
||||
defaultTitle: attributesWithInjectedIds.title,
|
||||
defaultDescription: attributesWithInjectedIds.description,
|
||||
};
|
||||
}
|
||||
|
||||
export const getLinksEmbeddableFactory = () => {
|
||||
const linksEmbeddableFactory: EmbeddableFactory<LinksSerializedState, LinksApi> = {
|
||||
type: CONTENT_ID,
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => {
|
||||
const titleManager = initializeTitleManager(initialState.rawState);
|
||||
const savedObjectId = linksSerializeStateIsByReference(initialState.rawState)
|
||||
? initialState.rawState.savedObjectId
|
||||
: undefined;
|
||||
const isByReference = savedObjectId !== undefined;
|
||||
|
||||
const initialRuntimeState = await deserializeState(initialState);
|
||||
|
||||
const blockingError$ = new BehaviorSubject<Error | undefined>(undefined);
|
||||
if (!isParentApiCompatible(parentApi)) blockingError$.next(new PanelIncompatibleError());
|
||||
|
||||
const stateManager = initializeStateManager<
|
||||
Pick<LinksRuntimeState, 'defaultDescription' | 'defaultTitle' | 'layout' | 'links'>
|
||||
>(initialRuntimeState, {
|
||||
defaultDescription: undefined,
|
||||
defaultTitle: undefined,
|
||||
layout: undefined,
|
||||
links: undefined,
|
||||
});
|
||||
|
||||
function serializeByReference(id: string) {
|
||||
return {
|
||||
...runtimeState,
|
||||
title,
|
||||
description,
|
||||
hidePanelTitles,
|
||||
rawState: {
|
||||
...titleManager.getLatestState(),
|
||||
savedObjectId: id,
|
||||
} as LinksByReferenceSerializedState,
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { attributes: attributesWithInjectedIds } = injectReferences({
|
||||
attributes: state.attributes,
|
||||
references: serializedState.references ?? [],
|
||||
function serializeByValue() {
|
||||
const { attributes, references } = serializeLinksAttributes(stateManager.getLatestState());
|
||||
return {
|
||||
rawState: {
|
||||
...titleManager.getLatestState(),
|
||||
attributes,
|
||||
} as LinksByValueSerializedState,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
const serializeState = () =>
|
||||
isByReference ? serializeByReference(savedObjectId) : serializeByValue();
|
||||
|
||||
const unsavedChangesApi = initializeUnsavedChanges<LinksSerializedState>({
|
||||
uuid,
|
||||
parentApi,
|
||||
serializeState,
|
||||
anyStateChange$: merge(titleManager.anyStateChange$, stateManager.anyStateChange$),
|
||||
getComparators: () => {
|
||||
return {
|
||||
...titleComparators,
|
||||
attributes: isByReference
|
||||
? 'skip'
|
||||
: (
|
||||
a?: LinksByValueSerializedState['attributes'],
|
||||
b?: LinksByValueSerializedState['attributes']
|
||||
) => {
|
||||
if (
|
||||
a?.title !== b?.title ||
|
||||
a?.description !== b?.description ||
|
||||
a?.layout !== b?.layout ||
|
||||
a?.links?.length !== b?.links?.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasLinkDifference = (a?.links ?? []).some((linkFromA, index) => {
|
||||
const linkFromB = b?.links?.[index];
|
||||
return !deepEqual(
|
||||
omitBy(linkFromA, isUndefined),
|
||||
omitBy(linkFromB, isUndefined)
|
||||
);
|
||||
});
|
||||
return !hasLinkDifference;
|
||||
},
|
||||
savedObjectId: 'skip',
|
||||
};
|
||||
},
|
||||
onReset: async (lastSaved) => {
|
||||
titleManager.reinitializeState(lastSaved?.rawState);
|
||||
if (lastSaved && !isByReference) {
|
||||
const lastSavedRuntimeState = await deserializeState(lastSaved);
|
||||
stateManager.reinitializeState(lastSavedRuntimeState);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const resolvedLinks = await resolveLinks(attributesWithInjectedIds.links ?? []);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
hidePanelTitles,
|
||||
links: resolvedLinks,
|
||||
layout: attributesWithInjectedIds.layout,
|
||||
defaultPanelTitle: attributesWithInjectedIds.title,
|
||||
defaultPanelDescription: attributesWithInjectedIds.description,
|
||||
};
|
||||
},
|
||||
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
|
||||
const blockingError$ = new BehaviorSubject<Error | undefined>(state.error);
|
||||
if (!isParentApiCompatible(parentApi)) blockingError$.next(new PanelIncompatibleError());
|
||||
|
||||
const links$ = new BehaviorSubject<ResolvedLink[] | undefined>(state.links);
|
||||
const layout$ = new BehaviorSubject<LinksLayoutType | undefined>(state.layout);
|
||||
const defaultTitle$ = new BehaviorSubject<string | undefined>(state.defaultPanelTitle);
|
||||
const defaultDescription$ = new BehaviorSubject<string | undefined>(
|
||||
state.defaultPanelDescription
|
||||
);
|
||||
const savedObjectId$ = new BehaviorSubject(state.savedObjectId);
|
||||
const isByReference = Boolean(state.savedObjectId);
|
||||
|
||||
const titleManager = initializeTitleManager(state);
|
||||
|
||||
const serializeLinksState = (byReference: boolean, newId?: string) => {
|
||||
if (byReference) {
|
||||
const linksByReferenceState: LinksByReferenceSerializedState = {
|
||||
savedObjectId: newId ?? state.savedObjectId!,
|
||||
...titleManager.serialize(),
|
||||
};
|
||||
return { rawState: linksByReferenceState, references: [] };
|
||||
}
|
||||
const runtimeState = api.snapshotRuntimeState();
|
||||
const { attributes, references } = serializeLinksAttributes(runtimeState);
|
||||
const linksByValueState: LinksByValueSerializedState = {
|
||||
attributes,
|
||||
...titleManager.serialize(),
|
||||
};
|
||||
return { rawState: linksByValueState, references };
|
||||
};
|
||||
|
||||
const api = buildApi(
|
||||
{
|
||||
...titleManager.api,
|
||||
blockingError$,
|
||||
defaultTitle$,
|
||||
defaultDescription$,
|
||||
isEditingEnabled: () => Boolean(blockingError$.value === undefined),
|
||||
getTypeDisplayName: () => DISPLAY_NAME,
|
||||
serializeState: () => serializeLinksState(isByReference),
|
||||
saveToLibrary: async (newTitle: string) => {
|
||||
defaultTitle$.next(newTitle);
|
||||
const runtimeState = api.snapshotRuntimeState();
|
||||
const { attributes, references } = serializeLinksAttributes(runtimeState);
|
||||
const {
|
||||
item: { id },
|
||||
} = await linksClient.create({
|
||||
data: {
|
||||
...attributes,
|
||||
title: newTitle,
|
||||
},
|
||||
options: { references },
|
||||
});
|
||||
return id;
|
||||
},
|
||||
getSerializedStateByValue: () =>
|
||||
serializeLinksState(false) as SerializedPanelState<LinksByValueSerializedState>,
|
||||
getSerializedStateByReference: (newId: string) =>
|
||||
serializeLinksState(
|
||||
true,
|
||||
newId
|
||||
) as SerializedPanelState<LinksByReferenceSerializedState>,
|
||||
canLinkToLibrary: async () => !isByReference,
|
||||
canUnlinkFromLibrary: async () => isByReference,
|
||||
checkForDuplicateTitle: async (
|
||||
newTitle: string,
|
||||
isTitleDuplicateConfirmed: boolean,
|
||||
onTitleDuplicate: () => void
|
||||
) => {
|
||||
await checkForDuplicateTitle({
|
||||
const api = finalizeApi({
|
||||
...titleManager.api,
|
||||
...unsavedChangesApi,
|
||||
blockingError$,
|
||||
defaultTitle$: stateManager.api.defaultTitle$,
|
||||
defaultDescription$: stateManager.api.defaultDescription$,
|
||||
isEditingEnabled: () => Boolean(blockingError$.value === undefined),
|
||||
getTypeDisplayName: () => DISPLAY_NAME,
|
||||
serializeState,
|
||||
saveToLibrary: async (newTitle: string) => {
|
||||
stateManager.api.setDefaultTitle(newTitle);
|
||||
const { attributes, references } = serializeLinksAttributes(
|
||||
stateManager.getLatestState()
|
||||
);
|
||||
const {
|
||||
item: { id },
|
||||
} = await linksClient.create({
|
||||
data: {
|
||||
...attributes,
|
||||
title: newTitle,
|
||||
copyOnSave: false,
|
||||
lastSavedTitle: '',
|
||||
isTitleDuplicateConfirmed,
|
||||
onTitleDuplicate,
|
||||
});
|
||||
},
|
||||
onEdit: async () => {
|
||||
const { openEditorFlyout } = await import('../editor/open_editor_flyout');
|
||||
const newState = await openEditorFlyout({
|
||||
initialState: api.snapshotRuntimeState(),
|
||||
parentDashboard: parentApi,
|
||||
});
|
||||
if (!newState) return;
|
||||
|
||||
// if the by reference state has changed during this edit, reinitialize the panel.
|
||||
const nextIsByReference = Boolean(newState?.savedObjectId);
|
||||
if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) {
|
||||
const serializedState = serializeLinksState(
|
||||
nextIsByReference,
|
||||
newState?.savedObjectId
|
||||
);
|
||||
(serializedState.rawState as SerializedTitles).title = newState.title;
|
||||
|
||||
api.parentApi.replacePanel<LinksSerializedState>(api.uuid, {
|
||||
serializedState,
|
||||
panelType: api.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
links$.next(newState.links);
|
||||
layout$.next(newState.layout);
|
||||
defaultTitle$.next(newState.defaultPanelTitle);
|
||||
defaultDescription$.next(newState.defaultPanelDescription);
|
||||
},
|
||||
},
|
||||
options: { references },
|
||||
});
|
||||
return id;
|
||||
},
|
||||
{
|
||||
...titleManager.comparators,
|
||||
links: [links$, (nextLinks?: ResolvedLink[]) => links$.next(nextLinks ?? [])],
|
||||
layout: [
|
||||
layout$,
|
||||
(nextLayout?: LinksLayoutType) => layout$.next(nextLayout ?? LINKS_VERTICAL_LAYOUT),
|
||||
],
|
||||
error: [blockingError$, (nextError?: Error) => blockingError$.next(nextError)],
|
||||
defaultPanelDescription: [
|
||||
defaultDescription$,
|
||||
(nextDescription?: string) => defaultDescription$.next(nextDescription),
|
||||
],
|
||||
defaultPanelTitle: [defaultTitle$, (nextTitle?: string) => defaultTitle$.next(nextTitle)],
|
||||
savedObjectId: [savedObjectId$, (val) => savedObjectId$.next(val)],
|
||||
}
|
||||
);
|
||||
getSerializedStateByValue: serializeByValue,
|
||||
getSerializedStateByReference: serializeByReference,
|
||||
canLinkToLibrary: async () => !isByReference,
|
||||
canUnlinkFromLibrary: async () => isByReference,
|
||||
checkForDuplicateTitle: async (
|
||||
newTitle: string,
|
||||
isTitleDuplicateConfirmed: boolean,
|
||||
onTitleDuplicate: () => void
|
||||
) => {
|
||||
await checkForDuplicateTitle({
|
||||
title: newTitle,
|
||||
copyOnSave: false,
|
||||
lastSavedTitle: '',
|
||||
isTitleDuplicateConfirmed,
|
||||
onTitleDuplicate,
|
||||
});
|
||||
},
|
||||
onEdit: async () => {
|
||||
const { openEditorFlyout } = await import('../editor/open_editor_flyout');
|
||||
const newState = await openEditorFlyout({
|
||||
initialState: {
|
||||
...stateManager.getLatestState(),
|
||||
savedObjectId,
|
||||
},
|
||||
parentDashboard: parentApi,
|
||||
});
|
||||
if (!newState) return;
|
||||
|
||||
// if the by reference state has changed during this edit, reinitialize the panel.
|
||||
const nextSavedObjectId = newState?.savedObjectId;
|
||||
const nextIsByReference = nextSavedObjectId !== undefined;
|
||||
if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) {
|
||||
const serializedState = nextIsByReference
|
||||
? serializeByReference(nextSavedObjectId)
|
||||
: serializeByValue();
|
||||
(serializedState.rawState as SerializedTitles).title = newState.title;
|
||||
|
||||
api.parentApi.replacePanel<LinksSerializedState>(api.uuid, {
|
||||
serializedState,
|
||||
panelType: api.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
stateManager.reinitializeState(newState);
|
||||
},
|
||||
});
|
||||
|
||||
const Component = () => {
|
||||
const [links, layout] = useBatchedOptionalPublishingSubjects(links$, layout$);
|
||||
const [links, layout] = useBatchedOptionalPublishingSubjects(
|
||||
stateManager.api.links$,
|
||||
stateManager.api.layout$
|
||||
);
|
||||
|
||||
const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => {
|
||||
if (!links) return {};
|
||||
|
|
|
@ -27,13 +27,13 @@ export const deserializeLinksSavedObject = async (
|
|||
|
||||
const links = await resolveLinks(attributes.links ?? []);
|
||||
|
||||
const { title: defaultPanelTitle, description: defaultPanelDescription, layout } = attributes;
|
||||
const { title: defaultTitle, description: defaultDescription, layout } = attributes;
|
||||
|
||||
return {
|
||||
links,
|
||||
layout,
|
||||
savedObjectId: linksSavedObject.id,
|
||||
defaultPanelTitle,
|
||||
defaultPanelDescription,
|
||||
defaultTitle,
|
||||
defaultDescription,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@ import { extractReferences } from '../../common/persistable_state';
|
|||
import { LinksRuntimeState } from '../types';
|
||||
|
||||
export const serializeLinksAttributes = (
|
||||
state: LinksRuntimeState,
|
||||
state: Pick<LinksRuntimeState, 'defaultDescription' | 'defaultTitle' | 'layout' | 'links'>,
|
||||
shouldExtractReferences: boolean = true
|
||||
) => {
|
||||
const linksToSave: Link[] | undefined = state.links
|
||||
|
@ -25,8 +25,8 @@ export const serializeLinksAttributes = (
|
|||
) as unknown as Link
|
||||
);
|
||||
const attributes = {
|
||||
title: state.defaultPanelTitle,
|
||||
description: state.defaultPanelDescription,
|
||||
title: state.defaultTitle,
|
||||
description: state.defaultDescription,
|
||||
layout: state.layout,
|
||||
links: linksToSave,
|
||||
};
|
||||
|
|
|
@ -25,7 +25,8 @@ import { VisualizationsSetup } from '@kbn/visualizations-plugin/public';
|
|||
|
||||
import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
|
||||
import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
|
||||
import { LinksRuntimeState } from './types';
|
||||
import { SerializedPanelState } from '@kbn/presentation-publishing';
|
||||
import { LinksSerializedState } from './types';
|
||||
import { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from '../common';
|
||||
import { LinksCrudTypes } from '../common/content_management';
|
||||
import { getLinksClient } from './content_management/links_content_management_client';
|
||||
|
@ -64,11 +65,13 @@ export class LinksPlugin
|
|||
|
||||
plugins.embeddable.registerAddFromLibraryType({
|
||||
onAdd: async (container, savedObject) => {
|
||||
const { deserializeLinksSavedObject } = await import('./lib/deserialize_from_library');
|
||||
const initialState = await deserializeLinksSavedObject(savedObject);
|
||||
container.addNewPanel<LinksRuntimeState>({
|
||||
container.addNewPanel<LinksSerializedState>({
|
||||
panelType: CONTENT_ID,
|
||||
initialState,
|
||||
serializedState: {
|
||||
rawState: {
|
||||
savedObjectId: savedObject.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
savedObjectType: CONTENT_ID,
|
||||
|
@ -142,8 +145,10 @@ export class LinksPlugin
|
|||
|
||||
plugins.dashboard.registerDashboardPanelPlacementSetting(
|
||||
CONTENT_ID,
|
||||
async (runtimeState?: LinksRuntimeState) => {
|
||||
if (!runtimeState) return {};
|
||||
async (serializedState?: SerializedPanelState<LinksSerializedState>) => {
|
||||
if (!serializedState) return {};
|
||||
const { deserializeState } = await import('./embeddable/links_embeddable');
|
||||
const runtimeState = await deserializeState(serializedState);
|
||||
const isHorizontal = runtimeState.layout === 'horizontal';
|
||||
const width = isHorizontal ? DASHBOARD_GRID_COLUMN_COUNT : 8;
|
||||
const height = isHorizontal ? 4 : (runtimeState.links?.length ?? 1 * 3) + 4;
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
SerializedTitles,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
|
||||
import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public/plugin';
|
||||
import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers';
|
||||
import { LocatorPublic } from '@kbn/share-plugin/common';
|
||||
import { DASHBOARD_API_TYPE } from '@kbn/dashboard-plugin/public';
|
||||
|
@ -39,7 +38,7 @@ export type LinksParentApi = PresentationContainer &
|
|||
};
|
||||
|
||||
export type LinksApi = HasType<typeof CONTENT_ID> &
|
||||
DefaultEmbeddableApi<LinksSerializedState, LinksRuntimeState> &
|
||||
DefaultEmbeddableApi<LinksSerializedState> &
|
||||
HasEditCapabilities &
|
||||
HasLibraryTransforms<LinksByReferenceSerializedState, LinksByValueSerializedState>;
|
||||
|
||||
|
@ -52,17 +51,15 @@ export interface LinksByValueSerializedState {
|
|||
}
|
||||
|
||||
export type LinksSerializedState = SerializedTitles &
|
||||
Partial<DynamicActionsSerializedState> &
|
||||
(LinksByReferenceSerializedState | LinksByValueSerializedState);
|
||||
|
||||
export interface LinksRuntimeState
|
||||
extends Partial<LinksByReferenceSerializedState>,
|
||||
SerializedTitles {
|
||||
error?: Error;
|
||||
links?: ResolvedLink[];
|
||||
layout?: LinksLayoutType;
|
||||
defaultPanelTitle?: string;
|
||||
defaultPanelDescription?: string;
|
||||
defaultTitle?: string;
|
||||
defaultDescription?: string;
|
||||
}
|
||||
|
||||
export type ResolvedLink = Link & {
|
||||
|
|
|
@ -40,7 +40,6 @@
|
|||
"@kbn/presentation-publishing",
|
||||
"@kbn/react-kibana-context-render",
|
||||
"@kbn/presentation-panel-plugin",
|
||||
"@kbn/embeddable-enhanced-plugin",
|
||||
"@kbn/share-plugin",
|
||||
"@kbn/es-query"
|
||||
],
|
||||
|
|
|
@ -12,7 +12,7 @@ import { BehaviorSubject } from 'rxjs';
|
|||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { getOptionsListControlFactory } from '../controls/data_controls/options_list_control/get_options_list_control_factory';
|
||||
import { OptionsListControlApi } from '../controls/data_controls/options_list_control/types';
|
||||
import { getMockedBuildApi, getMockedControlGroupApi } from '../controls/mocks/control_mocks';
|
||||
import { getMockedControlGroupApi, getMockedFinalizeApi } from '../controls/mocks/control_mocks';
|
||||
import { coreServices } from '../services/kibana_services';
|
||||
import { DeleteControlAction } from './delete_control_action';
|
||||
|
||||
|
@ -31,18 +31,18 @@ beforeAll(async () => {
|
|||
const controlFactory = getOptionsListControlFactory();
|
||||
|
||||
const uuid = 'testControl';
|
||||
const control = await controlFactory.buildControl(
|
||||
{
|
||||
const control = await controlFactory.buildControl({
|
||||
initialState: {
|
||||
dataViewId: 'test-data-view',
|
||||
title: 'test',
|
||||
fieldName: 'test-field',
|
||||
width: 'medium',
|
||||
grow: false,
|
||||
},
|
||||
getMockedBuildApi(uuid, controlFactory, controlGroupApi),
|
||||
finalizeApi: getMockedFinalizeApi(uuid, controlFactory, controlGroupApi),
|
||||
uuid,
|
||||
controlGroupApi
|
||||
);
|
||||
controlGroupApi,
|
||||
});
|
||||
|
||||
controlApi = control.api;
|
||||
});
|
||||
|
|
|
@ -15,7 +15,7 @@ import type { ViewMode } from '@kbn/presentation-publishing';
|
|||
|
||||
import { getOptionsListControlFactory } from '../controls/data_controls/options_list_control/get_options_list_control_factory';
|
||||
import type { OptionsListControlApi } from '../controls/data_controls/options_list_control/types';
|
||||
import { getMockedBuildApi, getMockedControlGroupApi } from '../controls/mocks/control_mocks';
|
||||
import { getMockedControlGroupApi, getMockedFinalizeApi } from '../controls/mocks/control_mocks';
|
||||
import { getTimesliderControlFactory } from '../controls/timeslider_control/get_timeslider_control_factory';
|
||||
import { dataService } from '../services/kibana_services';
|
||||
import { EditControlAction } from './edit_control_action';
|
||||
|
@ -43,18 +43,19 @@ beforeAll(async () => {
|
|||
const controlFactory = getOptionsListControlFactory();
|
||||
|
||||
const optionsListUuid = 'optionsListControl';
|
||||
const optionsListControl = await controlFactory.buildControl(
|
||||
{
|
||||
|
||||
const optionsListControl = await controlFactory.buildControl({
|
||||
initialState: {
|
||||
dataViewId: 'test-data-view',
|
||||
title: 'test',
|
||||
fieldName: 'test-field',
|
||||
width: 'medium',
|
||||
grow: false,
|
||||
},
|
||||
getMockedBuildApi(optionsListUuid, controlFactory, controlGroupApi),
|
||||
optionsListUuid,
|
||||
controlGroupApi
|
||||
);
|
||||
finalizeApi: getMockedFinalizeApi(optionsListUuid, controlFactory, controlGroupApi),
|
||||
uuid: optionsListUuid,
|
||||
controlGroupApi,
|
||||
});
|
||||
|
||||
optionsListApi = optionsListControl.api;
|
||||
});
|
||||
|
@ -63,12 +64,12 @@ describe('Incompatible embeddables', () => {
|
|||
test('Action is incompatible with embeddables that are not editable', async () => {
|
||||
const timeSliderFactory = getTimesliderControlFactory();
|
||||
const timeSliderUuid = 'timeSliderControl';
|
||||
const timeSliderControl = await timeSliderFactory.buildControl(
|
||||
{},
|
||||
getMockedBuildApi(timeSliderUuid, timeSliderFactory, controlGroupApi),
|
||||
timeSliderUuid,
|
||||
controlGroupApi
|
||||
);
|
||||
const timeSliderControl = await timeSliderFactory.buildControl({
|
||||
initialState: {},
|
||||
finalizeApi: getMockedFinalizeApi(timeSliderUuid, timeSliderFactory, controlGroupApi),
|
||||
uuid: timeSliderUuid,
|
||||
controlGroupApi,
|
||||
});
|
||||
const editControlAction = new EditControlAction();
|
||||
expect(
|
||||
await editControlAction.isCompatible({
|
||||
|
|
|
@ -13,14 +13,10 @@ import { BehaviorSubject } from 'rxjs';
|
|||
import { render } from '@testing-library/react';
|
||||
|
||||
import { ControlGroupApi } from '../..';
|
||||
import {
|
||||
ControlGroupChainingSystem,
|
||||
ControlLabelPosition,
|
||||
DEFAULT_CONTROL_LABEL_POSITION,
|
||||
ParentIgnoreSettings,
|
||||
} from '../../../common';
|
||||
import { DefaultControlApi } from '../../controls/types';
|
||||
import { ControlGroupEditor } from './control_group_editor';
|
||||
import { initializeEditorStateManager } from '../initialize_editor_state_manager';
|
||||
import { DEFAULT_CONTROL_LABEL_POSITION } from '../../../common';
|
||||
|
||||
describe('render', () => {
|
||||
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
|
||||
|
@ -31,12 +27,12 @@ describe('render', () => {
|
|||
onCancel: () => {},
|
||||
onSave: () => {},
|
||||
onDeleteAll: () => {},
|
||||
stateManager: {
|
||||
chainingSystem: new BehaviorSubject<ControlGroupChainingSystem>('HIERARCHICAL'),
|
||||
labelPosition: new BehaviorSubject<ControlLabelPosition>(DEFAULT_CONTROL_LABEL_POSITION),
|
||||
autoApplySelections: new BehaviorSubject<boolean>(true),
|
||||
ignoreParentSettings: new BehaviorSubject<ParentIgnoreSettings | undefined>(undefined),
|
||||
},
|
||||
stateManager: initializeEditorStateManager({
|
||||
chainingSystem: 'HIERARCHICAL',
|
||||
autoApplySelections: true,
|
||||
ignoreParentSettings: undefined,
|
||||
labelPosition: DEFAULT_CONTROL_LABEL_POSITION,
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -27,9 +27,9 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
|
||||
import { StateManager } from '@kbn/presentation-publishing/state_manager/types';
|
||||
import type { ControlLabelPosition, ParentIgnoreSettings } from '../../../common';
|
||||
import { CONTROL_LAYOUT_OPTIONS } from '../../controls/data_controls/editor_constants';
|
||||
import type { ControlStateManager } from '../../controls/types';
|
||||
import { ControlGroupStrings } from '../control_group_strings';
|
||||
import type { ControlGroupApi, ControlGroupEditorState } from '../types';
|
||||
import { ControlSettingTooltipLabel } from './control_setting_tooltip_label';
|
||||
|
@ -38,7 +38,7 @@ interface Props {
|
|||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onDeleteAll: () => void;
|
||||
stateManager: ControlStateManager<ControlGroupEditorState>;
|
||||
stateManager: StateManager<ControlGroupEditorState>;
|
||||
api: ControlGroupApi; // controls must always have a parent API
|
||||
}
|
||||
|
||||
|
@ -51,22 +51,22 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
|
|||
selectedIgnoreParentSettings,
|
||||
] = useBatchedPublishingSubjects(
|
||||
api.children$,
|
||||
stateManager.labelPosition,
|
||||
stateManager.chainingSystem,
|
||||
stateManager.autoApplySelections,
|
||||
stateManager.ignoreParentSettings
|
||||
stateManager.api.labelPosition$,
|
||||
stateManager.api.chainingSystem$,
|
||||
stateManager.api.autoApplySelections$,
|
||||
stateManager.api.ignoreParentSettings$
|
||||
);
|
||||
|
||||
const controlCount = useMemo(() => Object.keys(children).length, [children]);
|
||||
|
||||
const updateIgnoreSetting = useCallback(
|
||||
(newSettings: Partial<ParentIgnoreSettings>) => {
|
||||
stateManager.ignoreParentSettings.next({
|
||||
stateManager.api.setIgnoreParentSettings({
|
||||
...(selectedIgnoreParentSettings ?? {}),
|
||||
...newSettings,
|
||||
});
|
||||
},
|
||||
[stateManager.ignoreParentSettings, selectedIgnoreParentSettings]
|
||||
[stateManager.api, selectedIgnoreParentSettings]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -86,7 +86,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
|
|||
idSelected={selectedLabelPosition}
|
||||
legend={ControlGroupStrings.management.labelPosition.getLabelPositionLegend()}
|
||||
onChange={(newPosition: string) => {
|
||||
stateManager.labelPosition.next(newPosition as ControlLabelPosition);
|
||||
stateManager.api.setLabelPosition(newPosition as ControlLabelPosition);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
@ -149,7 +149,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
|
|||
}
|
||||
checked={selectedChainingSystem === 'HIERARCHICAL'}
|
||||
onChange={(e) =>
|
||||
stateManager.chainingSystem.next(e.target.checked ? 'HIERARCHICAL' : 'NONE')
|
||||
stateManager.api.setChainingSystem(e.target.checked ? 'HIERARCHICAL' : 'NONE')
|
||||
}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
|
@ -163,7 +163,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
|
|||
/>
|
||||
}
|
||||
checked={selectedAutoApplySelections}
|
||||
onChange={(e) => stateManager.autoApplySelections.next(e.target.checked)}
|
||||
onChange={(e) => stateManager.api.setAutoApplySelections(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</EuiFormRow>
|
||||
|
|
|
@ -7,12 +7,9 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import React, { useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
|
||||
import { StateComparators } from '@kbn/presentation-publishing';
|
||||
|
||||
import type { DefaultControlState } from '../../../common';
|
||||
import { getControlFactory } from '../../control_factory_registry';
|
||||
import type { ControlApiRegistration, DefaultControlApi } from '../../controls/types';
|
||||
|
@ -38,8 +35,6 @@ export const ControlRenderer = <
|
|||
onApiAvailable?: (api: ApiType) => void;
|
||||
isControlGroupInitialized: boolean;
|
||||
}) => {
|
||||
const cleanupFunction = useRef<(() => void) | null>(null);
|
||||
|
||||
const [component, setComponent] = useState<undefined | React.FC<{ className: string }>>(
|
||||
undefined
|
||||
);
|
||||
|
@ -49,33 +44,26 @@ export const ControlRenderer = <
|
|||
let ignore = false;
|
||||
|
||||
async function buildControl() {
|
||||
const parentApi = getParentApi();
|
||||
const controlGroupApi = getParentApi();
|
||||
const factory = await getControlFactory<StateType, ApiType>(type);
|
||||
const buildApi = (
|
||||
apiRegistration: ControlApiRegistration<ApiType>,
|
||||
comparators: StateComparators<StateType>
|
||||
): ApiType => {
|
||||
const unsavedChanges = initializeUnsavedChanges<StateType>(
|
||||
parentApi.getLastSavedControlState(uuid) as StateType,
|
||||
parentApi,
|
||||
comparators
|
||||
);
|
||||
|
||||
cleanupFunction.current = () => unsavedChanges.cleanup();
|
||||
|
||||
const finalizeApi = (apiRegistration: ControlApiRegistration<ApiType>): ApiType => {
|
||||
return {
|
||||
...apiRegistration,
|
||||
...unsavedChanges.api,
|
||||
uuid,
|
||||
parentApi,
|
||||
parentApi: controlGroupApi,
|
||||
type: factory.type,
|
||||
} as unknown as ApiType;
|
||||
};
|
||||
|
||||
const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? {
|
||||
const { rawState: initialState } = controlGroupApi.getSerializedStateForChild(uuid) ?? {
|
||||
rawState: {},
|
||||
};
|
||||
return await factory.buildControl(initialState as StateType, buildApi, uuid, parentApi);
|
||||
return await factory.buildControl({
|
||||
initialState: initialState as StateType,
|
||||
finalizeApi,
|
||||
uuid,
|
||||
controlGroupApi,
|
||||
});
|
||||
}
|
||||
|
||||
buildControl()
|
||||
|
@ -127,12 +115,6 @@ export const ControlRenderer = <
|
|||
[type]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupFunction.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return component && isControlGroupInitialized ? (
|
||||
// @ts-expect-error
|
||||
<ControlPanel<ApiType> Component={component} uuid={uuid} />
|
||||
|
|
|
@ -66,7 +66,7 @@ describe('control group renderer', () => {
|
|||
expect(buildControlGroupSpy).toBeCalledTimes(1);
|
||||
act(() => api.updateInput({ autoApplySelections: false }));
|
||||
await waitFor(() => {
|
||||
expect(buildControlGroupSpy).toBeCalledTimes(2);
|
||||
expect(buildControlGroupSpy).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -7,30 +7,27 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BehaviorSubject, Subject, map } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import type { Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { useSearchApi, type ViewMode } from '@kbn/presentation-publishing';
|
||||
|
||||
import type { ControlGroupApi } from '../..';
|
||||
import {
|
||||
CONTROL_GROUP_TYPE,
|
||||
DEFAULT_CONTROL_LABEL_POSITION,
|
||||
type ControlGroupRuntimeState,
|
||||
type ControlGroupSerializedState,
|
||||
DEFAULT_CONTROL_CHAINING,
|
||||
DEFAULT_AUTO_APPLY_SELECTIONS,
|
||||
} from '../../../common';
|
||||
import {
|
||||
type ControlGroupStateBuilder,
|
||||
controlGroupStateBuilder,
|
||||
} from '../utils/control_group_state_builder';
|
||||
import { getDefaultControlGroupRuntimeState } from '../utils/initialization_utils';
|
||||
import type { ControlGroupCreationOptions, ControlGroupRendererApi } from './types';
|
||||
import { deserializeControlGroup } from '../utils/serialization_utils';
|
||||
import { defaultRuntimeState, serializeRuntimeState } from '../utils/serialize_runtime_state';
|
||||
|
||||
export interface ControlGroupRendererProps {
|
||||
onApiAvailable: (api: ControlGroupRendererApi) => void;
|
||||
|
@ -56,8 +53,9 @@ export const ControlGroupRenderer = ({
|
|||
dataLoading,
|
||||
compressed,
|
||||
}: ControlGroupRendererProps) => {
|
||||
const lastState$Ref = useRef(new BehaviorSubject(serializeRuntimeState({})));
|
||||
const id = useMemo(() => uuidv4(), []);
|
||||
const [regenerateId, setRegenerateId] = useState(uuidv4());
|
||||
const [isStateLoaded, setIsStateLoaded] = useState(false);
|
||||
const [controlGroup, setControlGroup] = useState<ControlGroupRendererApi | undefined>();
|
||||
|
||||
/**
|
||||
|
@ -91,69 +89,39 @@ export const ControlGroupRenderer = ({
|
|||
|
||||
const reload$ = useMemo(() => new Subject<void>(), []);
|
||||
|
||||
/**
|
||||
* Control group API set up
|
||||
*/
|
||||
const runtimeState$ = useMemo(
|
||||
() => new BehaviorSubject<ControlGroupRuntimeState>(getDefaultControlGroupRuntimeState()),
|
||||
[]
|
||||
);
|
||||
const [serializedState, setSerializedState] = useState<ControlGroupSerializedState | undefined>();
|
||||
|
||||
const updateInput = useCallback(
|
||||
(newState: Partial<ControlGroupRuntimeState>) => {
|
||||
runtimeState$.next({
|
||||
...runtimeState$.getValue(),
|
||||
...newState,
|
||||
});
|
||||
},
|
||||
[runtimeState$]
|
||||
);
|
||||
|
||||
/**
|
||||
* To mimic `input$`, subscribe to unsaved changes and snapshot the runtime state whenever
|
||||
* something change
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!controlGroup) return;
|
||||
const stateChangeSubscription = controlGroup.unsavedChanges$.subscribe((changes) => {
|
||||
runtimeState$.next({ ...runtimeState$.getValue(), ...changes });
|
||||
const subscription = controlGroup.hasUnsavedChanges$.subscribe((hasUnsavedChanges) => {
|
||||
if (hasUnsavedChanges) lastState$Ref.current.next(controlGroup.serializeState());
|
||||
});
|
||||
return () => {
|
||||
stateChangeSubscription.unsubscribe();
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [controlGroup, runtimeState$]);
|
||||
}, [controlGroup]);
|
||||
|
||||
/**
|
||||
* On mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { initialState, editorConfig } =
|
||||
(await getCreationOptions?.(
|
||||
getDefaultControlGroupRuntimeState(),
|
||||
controlGroupStateBuilder
|
||||
)) ?? {};
|
||||
updateInput({
|
||||
...initialState,
|
||||
editorConfig,
|
||||
});
|
||||
const state: ControlGroupSerializedState = {
|
||||
...omit(initialState, ['initialChildControlState']),
|
||||
editorConfig,
|
||||
autoApplySelections: initialState?.autoApplySelections ?? DEFAULT_AUTO_APPLY_SELECTIONS,
|
||||
labelPosition: initialState?.labelPosition ?? DEFAULT_CONTROL_LABEL_POSITION,
|
||||
chainingSystem: initialState?.chainingSystem ?? DEFAULT_CONTROL_CHAINING,
|
||||
controls: Object.entries(initialState?.initialChildControlState ?? {}).map(
|
||||
([controlId, value]) => ({ ...value, id: controlId })
|
||||
),
|
||||
};
|
||||
if (!getCreationOptions) {
|
||||
setIsStateLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
getCreationOptions(defaultRuntimeState, controlGroupStateBuilder)
|
||||
.then(({ initialState, editorConfig }) => {
|
||||
if (cancelled) return;
|
||||
const initialRuntimeState = {
|
||||
...(initialState ?? defaultRuntimeState),
|
||||
editorConfig,
|
||||
} as ControlGroupRuntimeState;
|
||||
lastState$Ref.current.next(serializeRuntimeState(initialRuntimeState));
|
||||
setIsStateLoaded(true);
|
||||
})
|
||||
.catch();
|
||||
|
||||
if (!cancelled) {
|
||||
setSerializedState(state);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
@ -161,9 +129,8 @@ export const ControlGroupRenderer = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return !serializedState ? null : (
|
||||
<ReactEmbeddableRenderer<ControlGroupSerializedState, ControlGroupRuntimeState, ControlGroupApi>
|
||||
key={regenerateId} // this key forces a re-mount when `updateInput` is called
|
||||
return !isStateLoaded ? null : (
|
||||
<EmbeddableRenderer<ControlGroupSerializedState, ControlGroupApi>
|
||||
maybeId={id}
|
||||
type={CONTROL_GROUP_TYPE}
|
||||
getParentApi={() => ({
|
||||
|
@ -173,12 +140,9 @@ export const ControlGroupRenderer = ({
|
|||
query$: searchApi.query$,
|
||||
timeRange$: searchApi.timeRange$,
|
||||
unifiedSearchFilters$: searchApi.filters$,
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: serializedState,
|
||||
}),
|
||||
getRuntimeStateForChild: () => {
|
||||
return runtimeState$.getValue();
|
||||
},
|
||||
getSerializedStateForChild: () => lastState$Ref.current.value,
|
||||
lastSavedStateForChild$: () => lastState$Ref.current,
|
||||
getLastSavedStateForChild: () => lastState$Ref.current.value,
|
||||
compressed: compressed ?? true,
|
||||
})}
|
||||
onApiAvailable={async (controlGroupApi) => {
|
||||
|
@ -186,11 +150,17 @@ export const ControlGroupRenderer = ({
|
|||
const controlGroupRendererApi: ControlGroupRendererApi = {
|
||||
...controlGroupApi,
|
||||
reload: () => reload$.next(),
|
||||
updateInput: (newInput) => {
|
||||
updateInput(newInput);
|
||||
setRegenerateId(uuidv4()); // force remount
|
||||
updateInput: (newInput: Partial<ControlGroupRuntimeState>) => {
|
||||
lastState$Ref.current.next(
|
||||
serializeRuntimeState({
|
||||
...lastState$Ref.current.value,
|
||||
...newInput,
|
||||
})
|
||||
);
|
||||
controlGroupApi.resetUnsavedChanges();
|
||||
},
|
||||
getInput$: () => runtimeState$,
|
||||
getInput$: () => lastState$Ref.current.pipe(map(deserializeControlGroup)),
|
||||
getInput: () => deserializeControlGroup(lastState$Ref.current.value),
|
||||
};
|
||||
setControlGroup(controlGroupRendererApi);
|
||||
onApiAvailable(controlGroupRendererApi);
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { ControlGroupEditorConfig, ControlGroupRuntimeState } from '../../../common';
|
||||
import type { ControlGroupApi } from '../..';
|
||||
|
||||
|
@ -18,7 +18,7 @@ export type ControlGroupRendererApi = ControlGroupApi & {
|
|||
* @deprecated
|
||||
* Calling `updateInput` will cause the entire control group to be re-initialized.
|
||||
*
|
||||
* Therefore, to update the runtime state without `updateInput`, you should add public setters to the
|
||||
* Therefore, to update state without `updateInput`, you should add public setters to the
|
||||
* relavant API (`ControlGroupApi` or the individual control type APIs) for the state you wish to update
|
||||
* and call those setters instead.
|
||||
*/
|
||||
|
@ -29,7 +29,12 @@ export type ControlGroupRendererApi = ControlGroupApi & {
|
|||
* Instead of subscribing to the whole runtime state, it is more efficient to subscribe to the individual
|
||||
* publishing subjects of the control group API.
|
||||
*/
|
||||
getInput$: () => BehaviorSubject<ControlGroupRuntimeState>;
|
||||
getInput$: () => Observable<ControlGroupRuntimeState>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
getInput: () => ControlGroupRuntimeState;
|
||||
};
|
||||
|
||||
export interface ControlGroupCreationOptions {
|
||||
|
|
|
@ -7,71 +7,113 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import { combineLatest, map } from 'rxjs';
|
||||
import fastIsEqual from 'fast-deep-equal';
|
||||
import { combineLatest, combineLatestWith, debounceTime, map, merge, of } from 'rxjs';
|
||||
|
||||
import {
|
||||
apiHasLastSavedChildState,
|
||||
childrenUnsavedChanges$,
|
||||
initializeUnsavedChanges,
|
||||
type PresentationContainer,
|
||||
} from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiPublishesUnsavedChanges,
|
||||
type PublishesUnsavedChanges,
|
||||
type StateComparators,
|
||||
PublishingSubject,
|
||||
SerializedPanelState,
|
||||
} from '@kbn/presentation-publishing';
|
||||
|
||||
import type { ControlGroupRuntimeState, ControlPanelsState } from '../../common';
|
||||
import { StateManager } from '@kbn/presentation-publishing/state_manager/types';
|
||||
import type { ControlGroupSerializedState, ControlPanelsState } from '../../common';
|
||||
import { apiPublishesAsyncFilters } from '../controls/data_controls/publishes_async_filters';
|
||||
import { getControlsInOrder, type ControlsInOrder } from './init_controls_manager';
|
||||
import { deserializeControlGroup } from './utils/serialization_utils';
|
||||
import { ControlGroupEditorState } from './types';
|
||||
import { defaultEditorState, editorStateComparators } from './initialize_editor_state_manager';
|
||||
|
||||
export type ControlGroupComparatorState = Pick<
|
||||
ControlGroupRuntimeState,
|
||||
'autoApplySelections' | 'chainingSystem' | 'ignoreParentSettings' | 'labelPosition'
|
||||
> & {
|
||||
controlsInOrder: ControlsInOrder;
|
||||
};
|
||||
export function initializeControlGroupUnsavedChanges({
|
||||
applySelections,
|
||||
children$,
|
||||
controlGroupId,
|
||||
editorStateManager,
|
||||
layout$,
|
||||
parentApi,
|
||||
resetControlsUnsavedChanges,
|
||||
serializeControlGroupState,
|
||||
}: {
|
||||
applySelections: () => void;
|
||||
children$: PresentationContainer['children$'];
|
||||
controlGroupId: string;
|
||||
editorStateManager: StateManager<ControlGroupEditorState>;
|
||||
layout$: PublishingSubject<ControlsInOrder>;
|
||||
parentApi: unknown;
|
||||
resetControlsUnsavedChanges: (lastSavedControlsState: ControlPanelsState) => void;
|
||||
serializeControlGroupState: () => SerializedPanelState<ControlGroupSerializedState>;
|
||||
}) {
|
||||
function getLastSavedControlsState() {
|
||||
if (!apiHasLastSavedChildState<ControlGroupSerializedState>(parentApi)) {
|
||||
return {};
|
||||
}
|
||||
const lastSavedControlGroupState = parentApi.getLastSavedStateForChild(controlGroupId);
|
||||
return lastSavedControlGroupState
|
||||
? deserializeControlGroup(lastSavedControlGroupState).initialChildControlState
|
||||
: {};
|
||||
}
|
||||
|
||||
export function initializeControlGroupUnsavedChanges(
|
||||
applySelections: () => void,
|
||||
children$: PresentationContainer['children$'],
|
||||
comparators: StateComparators<ControlGroupComparatorState>,
|
||||
snapshotControlsRuntimeState: () => ControlPanelsState,
|
||||
resetControlsUnsavedChanges: () => void,
|
||||
parentApi: unknown,
|
||||
lastSavedRuntimeState: ControlGroupRuntimeState
|
||||
) {
|
||||
const controlGroupUnsavedChanges = initializeUnsavedChanges<ControlGroupComparatorState>(
|
||||
{
|
||||
autoApplySelections: lastSavedRuntimeState.autoApplySelections,
|
||||
chainingSystem: lastSavedRuntimeState.chainingSystem,
|
||||
controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState),
|
||||
ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings,
|
||||
labelPosition: lastSavedRuntimeState.labelPosition,
|
||||
},
|
||||
function getLastSavedStateForControl(controlId: string) {
|
||||
const controlState = getLastSavedControlsState()[controlId];
|
||||
return controlState ? { rawState: controlState } : undefined;
|
||||
}
|
||||
|
||||
const lastSavedControlsState$ = apiHasLastSavedChildState<ControlGroupSerializedState>(parentApi)
|
||||
? parentApi.lastSavedStateForChild$(controlGroupId).pipe(map(() => getLastSavedControlsState()))
|
||||
: of({});
|
||||
|
||||
const controlGroupEditorUnsavedChangesApi = initializeUnsavedChanges<ControlGroupEditorState>({
|
||||
uuid: controlGroupId,
|
||||
parentApi,
|
||||
comparators
|
||||
serializeState: serializeControlGroupState,
|
||||
anyStateChange$: merge(editorStateManager.anyStateChange$),
|
||||
getComparators: () => editorStateComparators,
|
||||
defaultState: defaultEditorState,
|
||||
onReset: (lastSaved) => {
|
||||
editorStateManager.reinitializeState(lastSaved?.rawState);
|
||||
},
|
||||
});
|
||||
|
||||
const hasLayoutChanges$ = layout$.pipe(
|
||||
combineLatestWith(
|
||||
lastSavedControlsState$.pipe(map((controlsState) => getControlsInOrder(controlsState)))
|
||||
),
|
||||
debounceTime(100),
|
||||
map(([, lastSavedLayout]) => {
|
||||
const currentLayout = layout$.value;
|
||||
return !fastIsEqual(currentLayout, lastSavedLayout);
|
||||
})
|
||||
);
|
||||
|
||||
const hasControlChanges$ = childrenUnsavedChanges$(children$).pipe(
|
||||
map((childrenWithChanges) => {
|
||||
return childrenWithChanges.some(({ hasUnsavedChanges }) => hasUnsavedChanges);
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
api: {
|
||||
unsavedChanges$: combineLatest([
|
||||
controlGroupUnsavedChanges.api.unsavedChanges$,
|
||||
childrenUnsavedChanges$(children$),
|
||||
lastSavedStateForChild$: (controlId: string) =>
|
||||
lastSavedControlsState$.pipe(map(() => getLastSavedStateForControl(controlId))),
|
||||
getLastSavedStateForChild: getLastSavedStateForControl,
|
||||
hasUnsavedChanges$: combineLatest([
|
||||
controlGroupEditorUnsavedChangesApi.hasUnsavedChanges$,
|
||||
hasControlChanges$,
|
||||
hasLayoutChanges$,
|
||||
]).pipe(
|
||||
map(([unsavedControlGroupState, unsavedControlsState]) => {
|
||||
const unsavedChanges: Partial<ControlGroupRuntimeState> = unsavedControlGroupState
|
||||
? omit(unsavedControlGroupState, 'controlsInOrder')
|
||||
: {};
|
||||
if (unsavedControlsState || unsavedControlGroupState?.controlsInOrder) {
|
||||
unsavedChanges.initialChildControlState = snapshotControlsRuntimeState();
|
||||
}
|
||||
return Object.keys(unsavedChanges).length ? unsavedChanges : undefined;
|
||||
map(([hasUnsavedControlGroupChanges, hasControlChanges, hasLayoutChanges]) => {
|
||||
return hasUnsavedControlGroupChanges || hasControlChanges || hasLayoutChanges;
|
||||
})
|
||||
),
|
||||
asyncResetUnsavedChanges: async () => {
|
||||
controlGroupUnsavedChanges.api.resetUnsavedChanges();
|
||||
resetControlsUnsavedChanges();
|
||||
resetUnsavedChanges: async () => {
|
||||
controlGroupEditorUnsavedChangesApi.resetUnsavedChanges();
|
||||
resetControlsUnsavedChanges(getLastSavedControlsState());
|
||||
|
||||
const filtersReadyPromises: Array<Promise<void>> = [];
|
||||
Object.values(children$.value).forEach((controlApi) => {
|
||||
|
@ -83,12 +125,10 @@ export function initializeControlGroupUnsavedChanges(
|
|||
|
||||
await Promise.all(filtersReadyPromises);
|
||||
|
||||
if (!comparators.autoApplySelections[0].value) {
|
||||
if (!editorStateManager.api.autoApplySelections$.value) {
|
||||
applySelections();
|
||||
}
|
||||
},
|
||||
} as Pick<PublishesUnsavedChanges, 'unsavedChanges$'> & {
|
||||
asyncResetUnsavedChanges: () => Promise<void>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,36 +8,21 @@
|
|||
*/
|
||||
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import type { ESQLControlVariable } from '@kbn/esql-types';
|
||||
import { PublishesESQLVariable, apiPublishesESQLVariable } from '@kbn/esql-types';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
apiHasSaveNotification,
|
||||
combineCompatibleChildrenApis,
|
||||
} from '@kbn/presentation-containers';
|
||||
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
|
||||
import {
|
||||
PublishesDataViews,
|
||||
apiPublishesDataViews,
|
||||
useBatchedPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
|
||||
import fastIsEqual from 'fast-deep-equal';
|
||||
import React, { useEffect } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type {
|
||||
ControlGroupChainingSystem,
|
||||
ControlGroupRuntimeState,
|
||||
ControlGroupSerializedState,
|
||||
ControlLabelPosition,
|
||||
ControlPanelsState,
|
||||
ParentIgnoreSettings,
|
||||
} from '../../common';
|
||||
import {
|
||||
CONTROL_GROUP_TYPE,
|
||||
DEFAULT_CONTROL_CHAINING,
|
||||
DEFAULT_CONTROL_LABEL_POSITION,
|
||||
} from '../../common';
|
||||
import type { ControlGroupSerializedState } from '../../common';
|
||||
import { CONTROL_GROUP_TYPE } from '../../common';
|
||||
import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor';
|
||||
import { coreServices, dataViewsService } from '../services/kibana_services';
|
||||
import { ControlGroup } from './components/control_group';
|
||||
|
@ -48,88 +33,55 @@ import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
|
|||
import { initSelectionsManager } from './selections_manager';
|
||||
import type { ControlGroupApi } from './types';
|
||||
import { deserializeControlGroup } from './utils/serialization_utils';
|
||||
import { initializeEditorStateManager } from './initialize_editor_state_manager';
|
||||
|
||||
export const getControlGroupEmbeddableFactory = () => {
|
||||
const controlGroupEmbeddableFactory: ReactEmbeddableFactory<
|
||||
const controlGroupEmbeddableFactory: EmbeddableFactory<
|
||||
ControlGroupSerializedState,
|
||||
ControlGroupRuntimeState,
|
||||
ControlGroupApi
|
||||
> = {
|
||||
type: CONTROL_GROUP_TYPE,
|
||||
deserializeState: (state) => deserializeControlGroup(state),
|
||||
buildEmbeddable: async (
|
||||
initialRuntimeState,
|
||||
buildApi,
|
||||
uuid,
|
||||
parentApi,
|
||||
setApi,
|
||||
lastSavedRuntimeState
|
||||
) => {
|
||||
const {
|
||||
labelPosition: initialLabelPosition,
|
||||
chainingSystem,
|
||||
autoApplySelections,
|
||||
ignoreParentSettings,
|
||||
} = initialRuntimeState;
|
||||
buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => {
|
||||
const initialRuntimeState = deserializeControlGroup(initialState);
|
||||
|
||||
const editorStateManager = initializeEditorStateManager(initialState?.rawState);
|
||||
|
||||
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
|
||||
const defaultDataViewId = await dataViewsService.getDefaultId();
|
||||
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(
|
||||
lastSavedRuntimeState.initialChildControlState
|
||||
);
|
||||
const controlsManager = initControlsManager(
|
||||
initialRuntimeState.initialChildControlState,
|
||||
lastSavedControlsState$
|
||||
);
|
||||
|
||||
const controlsManager = initControlsManager(initialRuntimeState.initialChildControlState);
|
||||
const selectionsManager = initSelectionsManager({
|
||||
...controlsManager.api,
|
||||
autoApplySelections$,
|
||||
autoApplySelections$: editorStateManager.api.autoApplySelections$,
|
||||
});
|
||||
const esqlVariables$ = new BehaviorSubject<ESQLControlVariable[]>([]);
|
||||
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(undefined);
|
||||
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(
|
||||
chainingSystem ?? DEFAULT_CONTROL_CHAINING
|
||||
);
|
||||
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
|
||||
ignoreParentSettings
|
||||
);
|
||||
const labelPosition$ = new BehaviorSubject<ControlLabelPosition>(
|
||||
initialLabelPosition ?? DEFAULT_CONTROL_LABEL_POSITION
|
||||
);
|
||||
|
||||
const allowExpensiveQueries$ = new BehaviorSubject<boolean>(true);
|
||||
const disabledActionIds$ = new BehaviorSubject<string[] | undefined>(undefined);
|
||||
|
||||
const unsavedChanges = initializeControlGroupUnsavedChanges(
|
||||
selectionsManager.applySelections,
|
||||
controlsManager.api.children$,
|
||||
{
|
||||
...controlsManager.comparators,
|
||||
autoApplySelections: [
|
||||
autoApplySelections$,
|
||||
(next: boolean) => autoApplySelections$.next(next),
|
||||
],
|
||||
chainingSystem: [
|
||||
chainingSystem$,
|
||||
(next: ControlGroupChainingSystem) => chainingSystem$.next(next),
|
||||
(a, b) => (a ?? DEFAULT_CONTROL_CHAINING) === (b ?? DEFAULT_CONTROL_CHAINING),
|
||||
],
|
||||
ignoreParentSettings: [
|
||||
ignoreParentSettings$,
|
||||
(next: ParentIgnoreSettings | undefined) => ignoreParentSettings$.next(next),
|
||||
fastIsEqual,
|
||||
],
|
||||
labelPosition: [
|
||||
labelPosition$,
|
||||
(next: ControlLabelPosition) => labelPosition$.next(next),
|
||||
],
|
||||
},
|
||||
controlsManager.snapshotControlsRuntimeState,
|
||||
controlsManager.resetControlsUnsavedChanges,
|
||||
parentApi,
|
||||
lastSavedRuntimeState
|
||||
);
|
||||
function serializeState() {
|
||||
const { controls, references } = controlsManager.serializeControls();
|
||||
return {
|
||||
rawState: {
|
||||
...editorStateManager.getLatestState(),
|
||||
controls,
|
||||
},
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
const api = setApi({
|
||||
const unsavedChanges = initializeControlGroupUnsavedChanges({
|
||||
applySelections: selectionsManager.applySelections,
|
||||
children$: controlsManager.api.children$,
|
||||
controlGroupId: uuid,
|
||||
editorStateManager,
|
||||
layout$: controlsManager.controlsInOrder$,
|
||||
parentApi,
|
||||
resetControlsUnsavedChanges: controlsManager.resetControlsUnsavedChanges,
|
||||
serializeControlGroupState: serializeState,
|
||||
});
|
||||
|
||||
const api = finalizeApi({
|
||||
...controlsManager.api,
|
||||
esqlVariables$,
|
||||
disabledActionIds$,
|
||||
|
@ -139,31 +91,21 @@ export const getControlGroupEmbeddableFactory = () => {
|
|||
controlFetch$(
|
||||
chaining$(
|
||||
controlUuid,
|
||||
chainingSystem$,
|
||||
editorStateManager.api.chainingSystem$,
|
||||
controlsManager.controlsInOrder$,
|
||||
controlsManager.api.children$
|
||||
),
|
||||
controlGroupFetch$(ignoreParentSettings$, parentApi ? parentApi : {}, onReload)
|
||||
controlGroupFetch$(
|
||||
editorStateManager.api.ignoreParentSettings$,
|
||||
parentApi ? parentApi : {},
|
||||
onReload
|
||||
)
|
||||
),
|
||||
ignoreParentSettings$,
|
||||
autoApplySelections$,
|
||||
ignoreParentSettings$: editorStateManager.api.ignoreParentSettings$,
|
||||
autoApplySelections$: editorStateManager.api.autoApplySelections$,
|
||||
allowExpensiveQueries$,
|
||||
snapshotRuntimeState: () => {
|
||||
return {
|
||||
chainingSystem: chainingSystem$.getValue(),
|
||||
labelPosition: labelPosition$.getValue(),
|
||||
autoApplySelections: autoApplySelections$.getValue(),
|
||||
ignoreParentSettings: ignoreParentSettings$.getValue(),
|
||||
initialChildControlState: controlsManager.snapshotControlsRuntimeState(),
|
||||
};
|
||||
},
|
||||
onEdit: async () => {
|
||||
openEditControlGroupFlyout(api, {
|
||||
chainingSystem: chainingSystem$,
|
||||
labelPosition: labelPosition$,
|
||||
autoApplySelections: autoApplySelections$,
|
||||
ignoreParentSettings: ignoreParentSettings$,
|
||||
});
|
||||
openEditControlGroupFlyout(api, editorStateManager);
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
openAddDataControlFlyout: (settings) => {
|
||||
|
@ -178,36 +120,23 @@ export const getControlGroupEmbeddableFactory = () => {
|
|||
dataViewId:
|
||||
newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined,
|
||||
},
|
||||
onSave: ({ type: controlType, state: initialState }) => {
|
||||
onSave: ({ type: controlType, state: onSaveState }) => {
|
||||
controlsManager.api.addNewPanel({
|
||||
panelType: controlType,
|
||||
initialState: settings?.controlStateTransform
|
||||
? settings.controlStateTransform(initialState, controlType)
|
||||
: initialState,
|
||||
serializedState: {
|
||||
rawState: settings?.controlStateTransform
|
||||
? settings.controlStateTransform(onSaveState, controlType)
|
||||
: onSaveState,
|
||||
},
|
||||
});
|
||||
settings?.onSave?.();
|
||||
},
|
||||
controlGroupApi: api,
|
||||
});
|
||||
},
|
||||
serializeState: () => {
|
||||
const { controls, references } = controlsManager.serializeControls();
|
||||
return {
|
||||
rawState: {
|
||||
chainingSystem: chainingSystem$.getValue(),
|
||||
labelPosition: labelPosition$.getValue(),
|
||||
autoApplySelections: autoApplySelections$.getValue(),
|
||||
ignoreParentSettings: ignoreParentSettings$.getValue(),
|
||||
controls,
|
||||
},
|
||||
references,
|
||||
};
|
||||
},
|
||||
serializeState,
|
||||
dataViews$,
|
||||
labelPosition: labelPosition$,
|
||||
saveNotification$: apiHasSaveNotification(parentApi)
|
||||
? parentApi.saveNotification$
|
||||
: undefined,
|
||||
labelPosition: editorStateManager.api.labelPosition$,
|
||||
reload$: apiPublishesReload(parentApi) ? parentApi.reload$ : undefined,
|
||||
|
||||
/** Public getters */
|
||||
|
@ -216,13 +145,10 @@ export const getControlGroupEmbeddableFactory = () => {
|
|||
defaultMessage: 'Controls',
|
||||
}),
|
||||
getEditorConfig: () => initialRuntimeState.editorConfig,
|
||||
getLastSavedControlState: (controlUuid: string) => {
|
||||
return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {};
|
||||
},
|
||||
|
||||
/** Public setters */
|
||||
setDisabledActionIds: (ids) => disabledActionIds$.next(ids),
|
||||
setChainingSystem: (newChainingSystem) => chainingSystem$.next(newChainingSystem),
|
||||
setChainingSystem: editorStateManager.api.setChainingSystem,
|
||||
});
|
||||
|
||||
/** Subscribe to all children's output data views, combine them, and output them */
|
||||
|
@ -241,26 +167,12 @@ export const getControlGroupEmbeddableFactory = () => {
|
|||
esqlVariables$.next(newESQLVariables);
|
||||
});
|
||||
|
||||
const saveNotificationSubscription = apiHasSaveNotification(parentApi)
|
||||
? parentApi.saveNotification$.subscribe(() => {
|
||||
lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState());
|
||||
|
||||
if (
|
||||
typeof autoApplySelections$.value === 'boolean' &&
|
||||
!autoApplySelections$.value &&
|
||||
selectionsManager.hasUnappliedSelections$.value
|
||||
) {
|
||||
selectionsManager.applySelections();
|
||||
}
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
const [hasUnappliedSelections, labelPosition] = useBatchedPublishingSubjects(
|
||||
selectionsManager.hasUnappliedSelections$,
|
||||
labelPosition$
|
||||
editorStateManager.api.labelPosition$
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -286,7 +198,6 @@ export const getControlGroupEmbeddableFactory = () => {
|
|||
selectionsManager.cleanup();
|
||||
childrenDataViewsSubscription.unsubscribe();
|
||||
childrenESQLVariablesSubscription.unsubscribe();
|
||||
saveNotificationSubscription?.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -7,8 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type { ControlPanelState, ControlPanelsState, DefaultDataControlState } from '../../common';
|
||||
import type { ControlPanelState, DefaultDataControlState } from '../../common';
|
||||
import type { DefaultControlApi } from '../controls/types';
|
||||
import { getLastUsedDataViewId, initControlsManager } from './init_controls_manager';
|
||||
|
||||
|
@ -22,13 +21,12 @@ describe('PresentationContainer api', () => {
|
|||
bravo: { type: 'testControl', order: 1 },
|
||||
charlie: { type: 'testControl', order: 2 },
|
||||
};
|
||||
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
|
||||
|
||||
test('addNewPanel should add control at end of controls', async () => {
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
const addNewPanelPromise = controlsManager.api.addNewPanel({
|
||||
panelType: 'testControl',
|
||||
initialState: {},
|
||||
serializedState: { rawState: {} },
|
||||
});
|
||||
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
|
||||
await addNewPanelPromise;
|
||||
|
@ -41,7 +39,7 @@ describe('PresentationContainer api', () => {
|
|||
});
|
||||
|
||||
test('removePanel should remove control', () => {
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
controlsManager.api.removePanel('bravo');
|
||||
expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([
|
||||
'alpha',
|
||||
|
@ -50,10 +48,10 @@ describe('PresentationContainer api', () => {
|
|||
});
|
||||
|
||||
test('replacePanel should replace control', async () => {
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
const replacePanelPromise = controlsManager.api.replacePanel('bravo', {
|
||||
panelType: 'testControl',
|
||||
initialState: {},
|
||||
serializedState: { rawState: {} },
|
||||
});
|
||||
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
|
||||
await replacePanelPromise;
|
||||
|
@ -66,7 +64,7 @@ describe('PresentationContainer api', () => {
|
|||
|
||||
describe('untilInitialized', () => {
|
||||
test('should not resolve until all controls are initialized', async () => {
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
let isDone = false;
|
||||
controlsManager.api.untilInitialized().then(() => {
|
||||
isDone = true;
|
||||
|
@ -88,7 +86,7 @@ describe('PresentationContainer api', () => {
|
|||
});
|
||||
|
||||
test('should resolve when all control already initialized ', async () => {
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
|
||||
controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi);
|
||||
controlsManager.setControlApi('charlie', {} as unknown as DefaultControlApi);
|
||||
|
@ -104,40 +102,6 @@ describe('PresentationContainer api', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('snapshotControlsRuntimeState', () => {
|
||||
const intialControlsState = {
|
||||
alpha: { type: 'testControl', order: 1 },
|
||||
bravo: { type: 'testControl', order: 0 },
|
||||
};
|
||||
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
|
||||
|
||||
test('should snapshot runtime state for all controls', async () => {
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
controlsManager.setControlApi('alpha', {
|
||||
snapshotRuntimeState: () => {
|
||||
return { key1: 'alpha value' };
|
||||
},
|
||||
} as unknown as DefaultControlApi);
|
||||
controlsManager.setControlApi('bravo', {
|
||||
snapshotRuntimeState: () => {
|
||||
return { key1: 'bravo value' };
|
||||
},
|
||||
} as unknown as DefaultControlApi);
|
||||
expect(controlsManager.snapshotControlsRuntimeState()).toEqual({
|
||||
alpha: {
|
||||
key1: 'alpha value',
|
||||
order: 1,
|
||||
type: 'testControl',
|
||||
},
|
||||
bravo: {
|
||||
key1: 'bravo value',
|
||||
order: 0,
|
||||
type: 'testControl',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastUsedDataViewId', () => {
|
||||
test('should return last used data view id', () => {
|
||||
const dataViewId = getLastUsedDataViewId(
|
||||
|
@ -175,8 +139,7 @@ describe('resetControlsUnsavedChanges', () => {
|
|||
alpha: { type: 'testControl', order: 0 },
|
||||
};
|
||||
// last saved state is empty control group
|
||||
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>({});
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
|
||||
|
||||
expect(controlsManager.controlsInOrder$.value).toEqual([
|
||||
|
@ -186,7 +149,8 @@ describe('resetControlsUnsavedChanges', () => {
|
|||
},
|
||||
]);
|
||||
|
||||
controlsManager.resetControlsUnsavedChanges();
|
||||
// last saved state is empty control group
|
||||
controlsManager.resetControlsUnsavedChanges({});
|
||||
expect(controlsManager.controlsInOrder$.value).toEqual([]);
|
||||
});
|
||||
|
||||
|
@ -194,15 +158,14 @@ describe('resetControlsUnsavedChanges', () => {
|
|||
const intialControlsState = {
|
||||
alpha: { type: 'testControl', order: 0 },
|
||||
};
|
||||
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
|
||||
|
||||
// delete control
|
||||
controlsManager.api.removePanel('alpha');
|
||||
|
||||
// deleted control should exist on reset
|
||||
controlsManager.resetControlsUnsavedChanges();
|
||||
controlsManager.resetControlsUnsavedChanges(intialControlsState);
|
||||
expect(controlsManager.controlsInOrder$.value).toEqual([
|
||||
{
|
||||
id: 'alpha',
|
||||
|
@ -213,22 +176,14 @@ describe('resetControlsUnsavedChanges', () => {
|
|||
|
||||
test('should restore controls to last saved state', () => {
|
||||
const intialControlsState = {};
|
||||
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
|
||||
// add control
|
||||
controlsManager.api.addNewPanel({ panelType: 'testControl' });
|
||||
controlsManager.setControlApi('delta', {
|
||||
snapshotRuntimeState: () => {
|
||||
return {};
|
||||
},
|
||||
} as unknown as DefaultControlApi);
|
||||
|
||||
// simulate save
|
||||
lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState());
|
||||
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
|
||||
|
||||
// saved control should exist on reset
|
||||
controlsManager.resetControlsUnsavedChanges();
|
||||
controlsManager.resetControlsUnsavedChanges({ delta: { type: 'testControl', order: 0 } });
|
||||
expect(controlsManager.controlsInOrder$.value).toEqual([
|
||||
{
|
||||
id: 'delta',
|
||||
|
@ -243,8 +198,7 @@ describe('resetControlsUnsavedChanges', () => {
|
|||
const intialControlsState = {
|
||||
alpha: { type: 'testControl', order: 0 },
|
||||
};
|
||||
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
|
||||
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
|
||||
|
||||
// add another control
|
||||
|
@ -253,7 +207,7 @@ describe('resetControlsUnsavedChanges', () => {
|
|||
expect(Object.keys(controlsManager.api.children$.value).length).toBe(2);
|
||||
|
||||
// reset to lastSavedControlsState
|
||||
controlsManager.resetControlsUnsavedChanges();
|
||||
controlsManager.resetControlsUnsavedChanges(intialControlsState);
|
||||
// children$ should no longer contain control removed by resetting back to original control baseline
|
||||
expect(Object.keys(controlsManager.api.children$.value).length).toBe(1);
|
||||
});
|
||||
|
@ -261,7 +215,7 @@ describe('resetControlsUnsavedChanges', () => {
|
|||
|
||||
describe('getNewControlState', () => {
|
||||
test('should contain defaults when there are no existing controls', () => {
|
||||
const controlsManager = initControlsManager({}, new BehaviorSubject<ControlPanelsState>({}));
|
||||
const controlsManager = initControlsManager({});
|
||||
expect(controlsManager.getNewControlState()).toEqual({
|
||||
grow: false,
|
||||
width: 'medium',
|
||||
|
@ -279,10 +233,7 @@ describe('getNewControlState', () => {
|
|||
grow: false,
|
||||
} as ControlPanelState & Pick<DefaultDataControlState, 'dataViewId'>,
|
||||
};
|
||||
const controlsManager = initControlsManager(
|
||||
intialControlsState,
|
||||
new BehaviorSubject<ControlPanelsState>(intialControlsState)
|
||||
);
|
||||
const controlsManager = initControlsManager(intialControlsState);
|
||||
expect(controlsManager.getNewControlState()).toEqual({
|
||||
grow: false,
|
||||
width: 'medium',
|
||||
|
@ -291,13 +242,15 @@ describe('getNewControlState', () => {
|
|||
});
|
||||
|
||||
test('should contain values of last added control', () => {
|
||||
const controlsManager = initControlsManager({}, new BehaviorSubject<ControlPanelsState>({}));
|
||||
const controlsManager = initControlsManager({});
|
||||
controlsManager.api.addNewPanel({
|
||||
panelType: 'testControl',
|
||||
initialState: {
|
||||
grow: false,
|
||||
width: 'small',
|
||||
dataViewId: 'myOtherDataViewId',
|
||||
serializedState: {
|
||||
rawState: {
|
||||
grow: false,
|
||||
width: 'small',
|
||||
dataViewId: 'myOtherDataViewId',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(controlsManager.getNewControlState()).toEqual({
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import fastIsEqual from 'fast-deep-equal';
|
||||
import { omit } from 'lodash';
|
||||
import { v4 as generateId } from 'uuid';
|
||||
|
||||
|
@ -17,11 +16,7 @@ import type {
|
|||
PanelPackage,
|
||||
PresentationContainer,
|
||||
} from '@kbn/presentation-containers';
|
||||
import {
|
||||
type PublishingSubject,
|
||||
type StateComparators,
|
||||
apiHasSnapshottableState,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import type { PublishingSubject } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject, first, merge } from 'rxjs';
|
||||
import type {
|
||||
ControlGroupSerializedState,
|
||||
|
@ -33,7 +28,6 @@ import type {
|
|||
} from '../../common';
|
||||
import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../common';
|
||||
import type { DefaultControlApi } from '../controls/types';
|
||||
import type { ControlGroupComparatorState } from './control_group_unsaved_changes_api';
|
||||
import type { ControlGroupApi } from './types';
|
||||
|
||||
export type ControlsInOrder = Array<{ id: string; type: string }>;
|
||||
|
@ -53,11 +47,7 @@ export function initControlsManager(
|
|||
/**
|
||||
* Composed from last saved controls state and previous sessions's unsaved changes to controls state
|
||||
*/
|
||||
initialControlsState: ControlPanelsState,
|
||||
/**
|
||||
* Observable that publishes last saved controls state only
|
||||
*/
|
||||
lastSavedControlsState$: PublishingSubject<ControlPanelsState>
|
||||
initialControlsState: ControlPanelsState
|
||||
) {
|
||||
const initialControlIds = Object.keys(initialControlsState);
|
||||
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
|
||||
|
@ -103,17 +93,17 @@ export function initControlsManager(
|
|||
}
|
||||
|
||||
async function addNewPanel(
|
||||
{ panelType, initialState }: PanelPackage<{}, DefaultControlState>,
|
||||
{ panelType, serializedState }: PanelPackage<DefaultControlState>,
|
||||
index: number
|
||||
) {
|
||||
if ((initialState as DefaultDataControlState)?.dataViewId) {
|
||||
lastUsedDataViewId$.next((initialState as DefaultDataControlState).dataViewId);
|
||||
if ((serializedState?.rawState as DefaultDataControlState)?.dataViewId) {
|
||||
lastUsedDataViewId$.next((serializedState!.rawState as DefaultDataControlState).dataViewId);
|
||||
}
|
||||
if (initialState?.width) {
|
||||
lastUsedWidth$.next(initialState.width);
|
||||
if (serializedState?.rawState?.width) {
|
||||
lastUsedWidth$.next(serializedState.rawState.width);
|
||||
}
|
||||
if (typeof initialState?.grow === 'boolean') {
|
||||
lastUsedGrow$.next(initialState.grow);
|
||||
if (typeof serializedState?.rawState?.grow === 'boolean') {
|
||||
lastUsedGrow$.next(serializedState.rawState.grow);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
|
@ -123,7 +113,7 @@ export function initControlsManager(
|
|||
type: panelType,
|
||||
});
|
||||
controlsInOrder$.next(nextControlsInOrder);
|
||||
currentControlsState[id] = initialState ?? {};
|
||||
currentControlsState[id] = serializedState?.rawState ?? {};
|
||||
return await untilControlLoaded(id);
|
||||
}
|
||||
|
||||
|
@ -185,23 +175,9 @@ export function initControlsManager(
|
|||
references,
|
||||
};
|
||||
},
|
||||
snapshotControlsRuntimeState: () => {
|
||||
const controlsRuntimeState: ControlPanelsState = {};
|
||||
controlsInOrder$.getValue().forEach(({ id, type }, index) => {
|
||||
const controlApi = getControlApi(id);
|
||||
if (controlApi && apiHasSnapshottableState(controlApi)) {
|
||||
controlsRuntimeState[id] = {
|
||||
order: index,
|
||||
type,
|
||||
...controlApi.snapshotRuntimeState(),
|
||||
};
|
||||
}
|
||||
});
|
||||
return controlsRuntimeState;
|
||||
},
|
||||
resetControlsUnsavedChanges: () => {
|
||||
resetControlsUnsavedChanges: (lastSavedControlsState: ControlPanelsState) => {
|
||||
currentControlsState = {
|
||||
...lastSavedControlsState$.value,
|
||||
...lastSavedControlsState,
|
||||
};
|
||||
const nextControlsInOrder = getControlsInOrder(currentControlsState as ControlPanelsState);
|
||||
controlsInOrder$.next(nextControlsInOrder);
|
||||
|
@ -263,13 +239,6 @@ export function initControlsManager(
|
|||
} as PresentationContainer &
|
||||
HasSerializedChildState<ControlPanelState> &
|
||||
Pick<ControlGroupApi, 'untilInitialized'>,
|
||||
comparators: {
|
||||
controlsInOrder: [
|
||||
controlsInOrder$,
|
||||
(next: ControlsInOrder) => {}, // setter does nothing, controlsInOrder$ reset by resetControlsRuntimeState
|
||||
fastIsEqual,
|
||||
],
|
||||
} as StateComparators<Pick<ControlGroupComparatorState, 'controlsInOrder'>>,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue