mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51: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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue