[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:
Devon Thomson 2025-05-06 15:08:34 -06:00 committed by GitHub
parent 2fd65fba64
commit 3e882d8cd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
295 changed files with 6901 additions and 6833 deletions

View file

@ -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
})
);

View file

@ -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>
)}

View file

@ -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));
}

View file

@ -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`,

View file

@ -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}

View file

@ -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>

View file

@ -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,
};
}

View file

@ -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));
},
};

View file

@ -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));
},
};

View file

@ -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 }>;
}

View file

@ -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">

View file

@ -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));
},
};

View file

@ -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;
},
};

View file

@ -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&apos;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);
}}

View file

@ -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));
},
};

View file

@ -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);

View file

@ -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 (

View file

@ -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$);

View file

@ -16,6 +16,4 @@ import {
export type DataTableSerializedState = SerializedTitles & SerializedTimeRange;
export type DataTableRuntimeState = DataTableSerializedState;
export type DataTableApi = DefaultEmbeddableApi<DataTableSerializedState> & PublishesDataLoading;

View file

@ -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
);

View file

@ -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',
})}

View file

@ -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;

View file

@ -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,
});
},

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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,
});

View file

@ -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: () =>

View file

@ -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>

View file

@ -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();

View file

@ -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;

View file

@ -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';

View file

@ -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
);

View file

@ -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);
});

View file

@ -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,

View file

@ -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;

View file

@ -21,8 +21,6 @@ import {
export type SearchSerializedState = SerializedTimeRange;
export type SearchRuntimeState = SearchSerializedState;
export type SearchApi = DefaultEmbeddableApi<SearchSerializedState> &
PublishesDataViews &
PublishesDataLoading &

View file

@ -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}

View file

@ -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

View file

@ -29,7 +29,6 @@ export const DashboardWithControlsExample = ({ dataView }: { dataView: DataView
.addNewPanel(
{
panelType: FILTER_DEBUGGER_EMBEDDABLE_ID,
initialState: {},
},
true
)

View file

@ -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,