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

View file

@ -90,7 +90,7 @@ pageLoadAssetSize:
lens: 76079
licenseManagement: 41817
licensing: 29004
links: 8200
links: 9000
lists: 22900
logsDataAccess: 16759
logsShared: 281060

View file

@ -41,7 +41,7 @@ const controlGroupMock = getControlGroupMock();
const updateControlGroupInputMock = (newState: ControlGroupRuntimeState) => {
act(() => {
controlGroupMock.snapshotRuntimeState.mockReturnValue(newState);
controlGroupMock.getInput.mockReturnValue(newState);
controlGroupFilterStateMock$.next(newState);
});
};

View file

@ -342,7 +342,7 @@ export const FilterGroup = (props: PropsWithChildren<FilterGroupProps>) => {
const upsertPersistableControls = useCallback(async () => {
if (!controlGroup) return;
const currentPanels = getFilterItemObjListFromControlState(controlGroup.snapshotRuntimeState());
const currentPanels = getFilterItemObjListFromControlState(controlGroup.getInput());
const reorderedControls = reorderControlsWithDefaultControls({
controls: currentPanels,

View file

@ -9,7 +9,7 @@
import type { ControlGroupRuntimeState } from '@kbn/controls-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
interface UseControlGroupSyncToLocalStorageArgs {
@ -41,9 +41,9 @@ export const useControlGroupSyncToLocalStorage: UseControlGroupSyncToLocalStorag
}
}, [shouldSync, controlGroupState, storageKey]);
const getStoredControlGroupState = () => {
const getStoredControlGroupState = useCallback(() => {
return (storage.current.get(storageKey) as ControlGroupRuntimeState) ?? undefined;
};
}, [storageKey]);
return {
controlGroupState,

View file

@ -25,6 +25,6 @@ export const getControlGroupMock = () => {
openAddDataControlFlyout: jest.fn(),
filters$: controlGroupFilterOutputMock$,
setChainingSystem: jest.fn(),
snapshotRuntimeState: jest.fn(),
getInput: jest.fn(),
};
};

View file

@ -8,11 +8,10 @@
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils';
import type {
SearchEmbeddableSerializedState,
SearchEmbeddableRuntimeState,
SearchEmbeddableApi,
} from '@kbn/discover-plugin/public';
import { SerializedPanelState } from '@kbn/presentation-publishing';
@ -200,11 +199,7 @@ const SavedSearchComponentTable: React.FC<
);
return (
<ReactEmbeddableRenderer<
SearchEmbeddableSerializedState,
SearchEmbeddableRuntimeState,
SearchEmbeddableApi
>
<EmbeddableRenderer<SearchEmbeddableSerializedState, SearchEmbeddableApi>
maybeId={undefined}
type={SEARCH_EMBEDDABLE_TYPE}
getParentApi={() => parentApi}

View file

@ -8,18 +8,9 @@
*/
export { apiCanAddNewPanel, type CanAddNewPanel } from './interfaces/can_add_new_panel';
export {
apiHasRuntimeChildState,
apiHasSerializedChildState,
type HasRuntimeChildState,
type HasSerializedChildState,
} from './interfaces/child_state';
export { apiHasSerializedChildState, type HasSerializedChildState } from './interfaces/child_state';
export { childrenUnsavedChanges$ } from './interfaces/unsaved_changes/children_unsaved_changes';
export { initializeUnsavedChanges } from './interfaces/unsaved_changes/initialize_unsaved_changes';
export {
apiHasSaveNotification,
type HasSaveNotification,
} from './interfaces/has_save_notification';
export {
apiCanDuplicatePanels,
apiCanExpandPanels,
@ -30,6 +21,10 @@ export {
canTrackContentfulRender,
type TrackContentfulRender,
} from './interfaces/performance_trackers';
export {
type HasLastSavedChildState,
apiHasLastSavedChildState,
} from './interfaces/last_saved_child_state';
export {
apiIsPresentationContainer,
combineCompatibleChildrenApis,

View file

@ -13,8 +13,8 @@ import { PanelPackage } from './presentation_container';
* This API can add a new panel as a child.
*/
export interface CanAddNewPanel {
addNewPanel: <SerializedState extends object, ApiType extends unknown = unknown>(
panel: PanelPackage<SerializedState>,
addNewPanel: <StateType extends object, ApiType extends unknown = unknown>(
panel: PanelPackage<StateType>,
displaySuccessMessage?: boolean
) => Promise<ApiType | undefined>;
}

View file

@ -15,23 +15,8 @@ export interface HasSerializedChildState<SerializedState extends object = object
) => SerializedPanelState<SerializedState> | undefined;
}
/**
* @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state.
*/
export interface HasRuntimeChildState<RuntimeState extends object = object> {
getRuntimeStateForChild: (childId: string) => Partial<RuntimeState> | undefined;
}
export const apiHasSerializedChildState = <SerializedState extends object = object>(
api: unknown
): api is HasSerializedChildState<SerializedState> => {
return Boolean(api && (api as HasSerializedChildState).getSerializedStateForChild);
};
/**
* @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state.
*/
export const apiHasRuntimeChildState = <RuntimeState extends object = object>(
api: unknown
): api is HasRuntimeChildState<RuntimeState> => {
return Boolean(api && (api as HasRuntimeChildState).getRuntimeStateForChild);
};

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Subject } from 'rxjs';
export interface HasSaveNotification {
saveNotification$: Subject<void>; // a notification that state has been saved
}
export const apiHasSaveNotification = (api: unknown): api is HasSaveNotification => {
return Boolean(api && (api as HasSaveNotification).saveNotification$);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { SerializedPanelState } from '@kbn/presentation-publishing';
import { Observable } from 'rxjs';
export interface HasLastSavedChildState<SerializedState extends object = object> {
lastSavedStateForChild$: (
childId: string
) => Observable<SerializedPanelState<SerializedState> | undefined>;
getLastSavedStateForChild: (childId: string) => SerializedPanelState<SerializedState> | undefined;
}
export const apiHasLastSavedChildState = <SerializedState extends object = object>(
api: unknown
): api is HasLastSavedChildState<SerializedState> => {
return Boolean(
api &&
(api as HasLastSavedChildState).lastSavedStateForChild$ &&
(api as HasLastSavedChildState).getLastSavedStateForChild
);
};

View file

@ -16,24 +16,16 @@ import {
import { BehaviorSubject, combineLatest, isObservable, map, Observable, of, switchMap } from 'rxjs';
import { apiCanAddNewPanel, CanAddNewPanel } from './can_add_new_panel';
export interface PanelPackage<
SerializedStateType extends object = object,
RuntimeStateType extends object = object
> {
export interface PanelPackage<SerializedStateType extends object = object> {
panelType: string;
/**
* The serialized state of this panel.
*/
serializedState?: SerializedPanelState<SerializedStateType>;
/**
* The runtime state of this panel. @deprecated Use `serializedState` instead.
*/
initialState?: RuntimeStateType;
}
export interface PresentationContainer extends CanAddNewPanel {
export interface PresentationContainer<ApiType extends unknown = unknown> extends CanAddNewPanel {
/**
* Removes a panel from the container.
*/
@ -57,12 +49,19 @@ export interface PresentationContainer extends CanAddNewPanel {
*/
getPanelCount: () => number;
/**
* Gets a child API for the given ID. This is asynchronous and should await for the
* child API to be available. It is best practice to retrieve a child API using this method
*/
getChildApi: (uuid: string) => Promise<ApiType | undefined>;
/**
* A publishing subject containing the child APIs of the container. Note that
* children are created asynchronously. This means that the children$ observable might
* contain fewer children than the actual number of panels in the container.
* contain fewer children than the actual number of panels in the container. Use getChildApi
* to retrieve the child API for a specific panel.
*/
children$: PublishingSubject<{ [key: string]: unknown }>;
children$: PublishingSubject<{ [key: string]: ApiType }>;
}
export const apiIsPresentationContainer = (api: unknown | null): api is PresentationContainer => {

View file

@ -13,20 +13,22 @@ import { waitFor } from '@testing-library/react';
describe('childrenUnsavedChanges$', () => {
const child1Api = {
unsavedChanges$: new BehaviorSubject<object | undefined>(undefined),
resetUnsavedChanges: () => true,
uuid: 'child1',
hasUnsavedChanges$: new BehaviorSubject<boolean>(false),
resetUnsavedChanges: () => undefined,
};
const child2Api = {
unsavedChanges$: new BehaviorSubject<object | undefined>(undefined),
resetUnsavedChanges: () => true,
uuid: 'child2',
hasUnsavedChanges$: new BehaviorSubject<boolean>(false),
resetUnsavedChanges: () => undefined,
};
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
const onFireMock = jest.fn();
beforeEach(() => {
onFireMock.mockReset();
child1Api.unsavedChanges$.next(undefined);
child2Api.unsavedChanges$.next(undefined);
child1Api.hasUnsavedChanges$.next(false);
child2Api.hasUnsavedChanges$.next(false);
children$.next({
child1: child1Api,
child2: child2Api,
@ -40,7 +42,18 @@ describe('childrenUnsavedChanges$', () => {
() => {
expect(onFireMock).toHaveBeenCalledTimes(1);
const childUnsavedChanges = onFireMock.mock.calls[0][0];
expect(childUnsavedChanges).toBeUndefined();
expect(childUnsavedChanges).toMatchInlineSnapshot(`
Array [
Object {
"hasUnsavedChanges": false,
"uuid": "child1",
},
Object {
"hasUnsavedChanges": false,
"uuid": "child2",
},
]
`);
},
{
interval: DEBOUNCE_TIME + 1,
@ -61,19 +74,24 @@ describe('childrenUnsavedChanges$', () => {
}
);
child1Api.unsavedChanges$.next({
key1: 'modified value',
});
child1Api.hasUnsavedChanges$.next(true);
await waitFor(
() => {
expect(onFireMock).toHaveBeenCalledTimes(2);
const childUnsavedChanges = onFireMock.mock.calls[1][0];
expect(childUnsavedChanges).toEqual({
child1: {
key1: 'modified value',
},
});
expect(childUnsavedChanges).toMatchInlineSnapshot(`
Array [
Object {
"hasUnsavedChanges": true,
"uuid": "child1",
},
Object {
"hasUnsavedChanges": false,
"uuid": "child2",
},
]
`);
},
{
interval: DEBOUNCE_TIME + 1,
@ -98,8 +116,9 @@ describe('childrenUnsavedChanges$', () => {
children$.next({
...children$.value,
child3: {
unsavedChanges$: new BehaviorSubject<object | undefined>({ key1: 'modified value' }),
resetUnsavedChanges: () => true,
uuid: 'child3',
hasUnsavedChanges$: new BehaviorSubject<boolean>(true),
resetUnsavedChanges: () => undefined,
},
});
@ -107,11 +126,22 @@ describe('childrenUnsavedChanges$', () => {
() => {
expect(onFireMock).toHaveBeenCalledTimes(2);
const childUnsavedChanges = onFireMock.mock.calls[1][0];
expect(childUnsavedChanges).toEqual({
child3: {
key1: 'modified value',
},
});
expect(childUnsavedChanges).toMatchInlineSnapshot(`
Array [
Object {
"hasUnsavedChanges": false,
"uuid": "child1",
},
Object {
"hasUnsavedChanges": false,
"uuid": "child2",
},
Object {
"hasUnsavedChanges": true,
"uuid": "child3",
},
]
`);
},
{
interval: DEBOUNCE_TIME + 1,

View file

@ -9,15 +9,22 @@
import { combineLatest, debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing';
import { PresentationContainer } from '../presentation_container';
import {
apiHasUniqueId,
apiPublishesUnsavedChanges,
HasUniqueId,
PublishesUnsavedChanges,
PublishingSubject,
} from '@kbn/presentation-publishing';
export const DEBOUNCE_TIME = 100;
/**
* Create an observable stream of unsaved changes from all react embeddable children
*/
export function childrenUnsavedChanges$(children$: PresentationContainer['children$']) {
export function childrenUnsavedChanges$<Api extends unknown = unknown>(
children$: PublishingSubject<{ [key: string]: Api }>
) {
return children$.pipe(
map((children) => Object.keys(children)),
distinctUntilChanged(deepEqual),
@ -25,27 +32,20 @@ export function childrenUnsavedChanges$(children$: PresentationContainer['childr
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) => {
if (newChildIds.length === 0) return of([]);
const childrenThatPublishUnsavedChanges = Object.entries(children$.value).filter(
([childId, child]) => apiPublishesUnsavedChanges(child)
) as Array<[string, PublishesUnsavedChanges]>;
const childrenThatPublishUnsavedChanges = Object.values(children$.value).filter(
(child) => apiPublishesUnsavedChanges(child) && apiHasUniqueId(child)
) as Array<PublishesUnsavedChanges & HasUniqueId>;
return childrenThatPublishUnsavedChanges.length === 0
? of([])
: combineLatest(
childrenThatPublishUnsavedChanges.map(([childId, child]) =>
child.unsavedChanges$.pipe(map((unsavedChanges) => ({ childId, unsavedChanges })))
childrenThatPublishUnsavedChanges.map((child) =>
child.hasUnsavedChanges$.pipe(
map((hasUnsavedChanges) => ({ uuid: child.uuid, hasUnsavedChanges }))
)
)
);
}),
debounceTime(DEBOUNCE_TIME),
map((unsavedChildStates) => {
const unsavedChildrenState: { [key: string]: object } = {};
unsavedChildStates.forEach(({ childId, unsavedChanges }) => {
if (unsavedChanges) {
unsavedChildrenState[childId] = unsavedChanges;
}
});
return Object.keys(unsavedChildrenState).length ? unsavedChildrenState : undefined;
})
debounceTime(DEBOUNCE_TIME)
);
}

View file

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

View file

@ -8,111 +8,56 @@
*/
import {
BehaviorSubject,
combineLatest,
combineLatestWith,
debounceTime,
map,
Subscription,
} from 'rxjs';
import {
getInitialValuesFromComparators,
PublishesUnsavedChanges,
PublishingSubject,
runComparators,
SerializedPanelState,
StateComparators,
HasSnapshottableState,
areComparatorsEqual,
} from '@kbn/presentation-publishing';
import { apiHasSaveNotification } from '../has_save_notification';
import { MaybePromise } from '@kbn/utility-types';
import { Observable, combineLatestWith, debounceTime, map, of } from 'rxjs';
import { apiHasLastSavedChildState } from '../last_saved_child_state';
export const COMPARATOR_SUBJECTS_DEBOUNCE = 100;
const UNSAVED_CHANGES_DEBOUNCE = 100;
export const initializeUnsavedChanges = <RuntimeState extends {} = {}>(
initialLastSavedState: RuntimeState,
parentApi: unknown,
comparators: StateComparators<RuntimeState>
) => {
const subscriptions: Subscription[] = [];
const lastSavedState$ = new BehaviorSubject<RuntimeState | undefined>(initialLastSavedState);
const snapshotRuntimeState = () => {
const comparatorKeys = Object.keys(comparators) as Array<keyof RuntimeState>;
const snapshot = {} as RuntimeState;
comparatorKeys.forEach((key) => {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
snapshot[key] = comparatorSubject.value as RuntimeState[typeof key];
});
return snapshot;
};
if (apiHasSaveNotification(parentApi)) {
subscriptions.push(
// any time the parent saves, the current state becomes the last saved state...
parentApi.saveNotification$.subscribe(() => {
lastSavedState$.next(snapshotRuntimeState());
})
);
export const initializeUnsavedChanges = <StateType extends object = object>({
uuid,
onReset,
parentApi,
getComparators,
defaultState,
serializeState,
anyStateChange$,
}: {
uuid: string;
parentApi: unknown;
anyStateChange$: Observable<void>;
serializeState: () => SerializedPanelState<StateType>;
getComparators: () => StateComparators<StateType>;
defaultState?: Partial<StateType>;
onReset: (lastSavedPanelState?: SerializedPanelState<StateType>) => MaybePromise<void>;
}): PublishesUnsavedChanges => {
if (!apiHasLastSavedChildState<StateType>(parentApi)) {
return {
hasUnsavedChanges$: of(false),
resetUnsavedChanges: () => Promise.resolve(),
};
}
const comparatorSubjects: Array<PublishingSubject<unknown>> = [];
const comparatorKeys: Array<keyof RuntimeState> = []; // index maps comparator subject to comparator key
for (const key of Object.keys(comparators) as Array<keyof RuntimeState>) {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
comparatorSubjects.push(comparatorSubject as PublishingSubject<unknown>);
comparatorKeys.push(key);
}
const unsavedChanges$ = new BehaviorSubject<Partial<RuntimeState> | undefined>(
runComparators(
comparators,
comparatorKeys,
lastSavedState$.getValue() as RuntimeState,
getInitialValuesFromComparators(comparators, comparatorKeys)
)
const hasUnsavedChanges$ = anyStateChange$.pipe(
combineLatestWith(
parentApi.lastSavedStateForChild$(uuid).pipe(map((panelState) => panelState?.rawState))
),
debounceTime(UNSAVED_CHANGES_DEBOUNCE),
map(([, lastSavedState]) => {
const currentState = serializeState().rawState;
return !areComparatorsEqual(getComparators(), lastSavedState, currentState, defaultState);
})
);
subscriptions.push(
combineLatest(comparatorSubjects)
.pipe(
debounceTime(COMPARATOR_SUBJECTS_DEBOUNCE),
map((latestStates) =>
comparatorKeys.reduce((acc, key, index) => {
acc[key] = latestStates[index] as RuntimeState[typeof key];
return acc;
}, {} as Partial<RuntimeState>)
),
combineLatestWith(lastSavedState$)
)
.subscribe(([latestState, lastSavedState]) => {
unsavedChanges$.next(
runComparators(comparators, comparatorKeys, lastSavedState, latestState)
);
})
);
return {
api: {
unsavedChanges$,
resetUnsavedChanges: () => {
const lastSaved = lastSavedState$.getValue();
// Do not reset to undefined or empty last saved state
// Temporary fix for https://github.com/elastic/kibana/issues/201627
// TODO remove when architecture fix resolves issue.
if (comparatorKeys.length && (!lastSaved || Object.keys(lastSaved).length === 0)) {
return false;
}
for (const key of comparatorKeys) {
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
setter(lastSaved?.[key] as RuntimeState[typeof key]);
}
return true;
},
snapshotRuntimeState,
} as PublishesUnsavedChanges<RuntimeState> & HasSnapshottableState<RuntimeState>,
cleanup: () => {
subscriptions.forEach((subscription) => subscription.unsubscribe());
},
const resetUnsavedChanges = async () => {
const lastSavedState = parentApi.getLastSavedStateForChild(uuid);
await onReset(lastSavedState);
};
return { hasUnsavedChanges$, resetUnsavedChanges };
};

View file

@ -15,6 +15,7 @@ export const getMockPresentationContainer = (): PresentationContainer => {
removePanel: jest.fn(),
addNewPanel: jest.fn(),
replacePanel: jest.fn(),
getChildApi: jest.fn(),
getPanelCount: jest.fn(),
children$: new BehaviorSubject<{ [key: string]: unknown }>({}),
};

View file

@ -9,5 +9,6 @@
"kbn_references": [
"@kbn/presentation-publishing",
"@kbn/core-mount-utils-browser",
"@kbn/utility-types",
]
}

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { PublishingSubject } from '../publishing_subject';
import { ComparatorDefinition } from './types';
/**
* Comparators are required for every runtime state key. Occasionally, a comparator may
* actually be optional. In those cases, implementors can fall back to this blank definition
* which will always return 'true'.
*/
export const getUnchangingComparator = <
State extends object,
Key extends keyof State
>(): ComparatorDefinition<State, Key> => {
const subj = new BehaviorSubject<never>(null as never);
return [subj as unknown as PublishingSubject<State[Key]>, () => {}, () => true];
};

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { StateComparators } from './types';
const defaultComparator = <T>(a: T, b: T) => a === b;
export const getInitialValuesFromComparators = <StateType extends object = object>(
comparators: StateComparators<StateType>,
comparatorKeys: Array<keyof StateType>
) => {
const initialValues: Partial<StateType> = {};
for (const key of comparatorKeys) {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
initialValues[key] = comparatorSubject?.value;
}
return initialValues;
};
export const runComparators = <StateType extends object = object>(
comparators: StateComparators<StateType>,
comparatorKeys: Array<keyof StateType>,
lastSavedState: StateType | undefined,
latestState: Partial<StateType>
) => {
if (!lastSavedState || Object.keys(latestState).length === 0) {
// if we have no last saved state, everything is considered a change
return latestState;
}
const latestChanges: Partial<StateType> = {};
for (const key of comparatorKeys) {
const customComparator = comparators[key]?.[2]; // 2nd element of the tuple is the custom comparator
const comparator = customComparator ?? defaultComparator;
if (!comparator(lastSavedState?.[key], latestState[key], lastSavedState, latestState)) {
latestChanges[key] = latestState[key];
}
}
return Object.keys(latestChanges).length > 0 ? latestChanges : undefined;
};

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 { PublishingSubject } from '../publishing_subject';
export type ComparatorFunction<StateType, KeyType extends keyof StateType> = (
last: StateType[KeyType] | undefined,
current: StateType[KeyType] | undefined,
lastState?: Partial<StateType>,
currentState?: Partial<StateType>
) => boolean;
export type ComparatorDefinition<StateType, KeyType extends keyof StateType> = [
PublishingSubject<StateType[KeyType]>,
(value: StateType[KeyType]) => void,
ComparatorFunction<StateType, KeyType>?
];
export type StateComparators<StateType> = {
[KeyType in keyof Required<StateType>]: ComparatorDefinition<StateType, KeyType>;
};

View file

@ -10,13 +10,14 @@
export { isEmbeddableApiContext, type EmbeddableApiContext } from './embeddable_api_context';
export {
getInitialValuesFromComparators,
getUnchangingComparator,
runComparators,
type ComparatorDefinition,
type ComparatorFunction,
type StateComparators,
} from './comparators';
type WithAllKeys,
runComparator,
areComparatorsEqual,
diffComparators,
initializeStateManager,
} from './state_manager';
export {
apiCanAccessViewMode,
getInheritedViewMode,
@ -29,9 +30,10 @@ export {
} from './interfaces/can_lock_hover_actions';
export { fetch$, useFetchContext, type FetchContext } from './interfaces/fetch/fetch';
export {
initializeTimeRange,
initializeTimeRangeManager,
timeRangeComparators,
type SerializedTimeRange,
} from './interfaces/fetch/initialize_time_range';
} from './interfaces/fetch/time_range_manager';
export { apiPublishesReload, type PublishesReload } from './interfaces/fetch/publishes_reload';
export {
apiPublishesFilters,
@ -73,9 +75,7 @@ export {
export { apiHasParentApi, type HasParentApi } from './interfaces/has_parent_api';
export {
apiHasSerializableState,
apiHasSnapshottableState,
type HasSerializableState,
type HasSnapshottableState,
type SerializedPanelState,
} from './interfaces/has_serializable_state';
export {
@ -146,6 +146,7 @@ export {
export {
initializeTitleManager,
stateHasTitles,
titleComparators,
type TitlesApi,
type SerializedTitles,
} from './interfaces/titles/title_manager';

View file

@ -1,44 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import { TimeRange } from '@kbn/es-query';
import { StateComparators } from '../../comparators';
import { PublishesWritableTimeRange } from './publishes_unified_search';
export interface SerializedTimeRange {
timeRange?: TimeRange | undefined;
}
export const initializeTimeRange = (
rawState: SerializedTimeRange
): {
serialize: () => SerializedTimeRange;
api: PublishesWritableTimeRange;
comparators: StateComparators<SerializedTimeRange>;
} => {
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(rawState.timeRange);
function setTimeRange(nextTimeRange: TimeRange | undefined) {
timeRange$.next(nextTimeRange);
}
return {
serialize: () => ({
timeRange: timeRange$.value,
}),
comparators: {
timeRange: [timeRange$, setTimeRange, fastIsEqual],
} as StateComparators<SerializedTimeRange>,
api: {
timeRange$,
setTimeRange,
},
};
};

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { TimeRange } from '@kbn/es-query';
import { StateManager } from '../../state_manager/types';
import { StateComparators, WithAllKeys, initializeStateManager } from '../../state_manager';
export interface SerializedTimeRange {
timeRange?: TimeRange | undefined;
}
const defaultTimeRangeState: WithAllKeys<SerializedTimeRange> = {
timeRange: undefined,
};
export const timeRangeComparators: StateComparators<SerializedTimeRange> = {
timeRange: 'deepEquality',
};
export const initializeTimeRangeManager = (
initialTimeRangeState: SerializedTimeRange
): StateManager<SerializedTimeRange> =>
initializeStateManager(initialTimeRangeState, defaultTimeRangeState);

View file

@ -29,21 +29,3 @@ export interface HasSerializableState<State extends object = object> {
export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => {
return Boolean((api as HasSerializableState)?.serializeState);
};
/**
* @deprecated use HasSerializableState instead
*/
export interface HasSnapshottableState<RuntimeState extends object = object> {
/**
* Serializes all runtime state exactly as it appears. This can be used
* to rehydrate a component's state without needing to serialize then deserialize it.
*/
snapshotRuntimeState: () => RuntimeState;
}
/**
* @deprecated use apiHasSerializableState instead
*/
export const apiHasSnapshottableState = (api: unknown | null): api is HasSnapshottableState => {
return Boolean((api as HasSnapshottableState)?.snapshotRuntimeState);
};

View file

@ -7,17 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PublishingSubject } from '../publishing_subject';
import { MaybePromise } from '@kbn/utility-types';
import { Observable } from 'rxjs';
export interface PublishesUnsavedChanges<Runtime extends object = object> {
unsavedChanges$: PublishingSubject<Partial<Runtime> | undefined>;
resetUnsavedChanges: () => boolean;
export interface PublishesUnsavedChanges {
hasUnsavedChanges$: Observable<boolean>; // Observable rather than publishingSubject because it should be derived.
resetUnsavedChanges: () => MaybePromise<void>;
}
export const apiPublishesUnsavedChanges = (api: unknown): api is PublishesUnsavedChanges => {
return Boolean(
api &&
(api as PublishesUnsavedChanges).unsavedChanges$ &&
(api as PublishesUnsavedChanges).hasUnsavedChanges$ &&
(api as PublishesUnsavedChanges).resetUnsavedChanges
);
};

View file

@ -7,7 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { initializeTitleManager, SerializedTitles } from './title_manager';
import { ComparatorFunction } from '../../state_manager';
import { initializeTitleManager, SerializedTitles, titleComparators } from './title_manager';
describe('titles api', () => {
const rawState: SerializedTitles = {
@ -20,7 +21,7 @@ describe('titles api', () => {
const { api } = initializeTitleManager(rawState);
expect(api.title$.value).toBe(rawState.title);
expect(api.description$.value).toBe(rawState.description);
expect(api.hideTitle$.value).toBe(rawState.hidePanelTitles);
expect(api.hidePanelTitles$.value).toBe(rawState.hidePanelTitles);
});
it('should update publishing subject values when set functions are called', () => {
@ -28,18 +29,18 @@ describe('titles api', () => {
api.setTitle('even cooler title');
api.setDescription('super uncool description');
api.setHideTitle(true);
api.setHidePanelTitles(true);
expect(api.title$.value).toEqual('even cooler title');
expect(api.description$.value).toEqual('super uncool description');
expect(api.hideTitle$.value).toBe(true);
expect(api.hidePanelTitles$.value).toBe(true);
});
it('should correctly serialize current state', () => {
const titleManager = initializeTitleManager(rawState);
titleManager.api.setTitle('UH OH, A TITLE');
const serializedTitles = titleManager.serialize();
const serializedTitles = titleManager.getLatestState();
expect(serializedTitles).toMatchInlineSnapshot(`
Object {
"description": "less cool description",
@ -49,19 +50,13 @@ describe('titles api', () => {
`);
});
it('should return the correct set of comparators', () => {
const { comparators } = initializeTitleManager(rawState);
expect(comparators.title).toBeDefined();
expect(comparators.description).toBeDefined();
expect(comparators.hidePanelTitles).toBeDefined();
});
it('should correctly compare hidePanelTitles with custom comparator', () => {
const { comparators } = initializeTitleManager(rawState);
expect(comparators.hidePanelTitles![2]!(true, false)).toBe(false);
expect(comparators.hidePanelTitles![2]!(undefined, false)).toBe(true);
expect(comparators.hidePanelTitles![2]!(true, undefined)).toBe(false);
const comparator = titleComparators.hidePanelTitles as ComparatorFunction<
SerializedTitles,
'hidePanelTitles'
>;
expect(comparator(true, false)).toBe(false);
expect(comparator(undefined, false)).toBe(true);
expect(comparator(true, undefined)).toBe(false);
});
});

View file

@ -7,10 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { StateComparators } from '../../comparators';
import { WithAllKeys } from '../../state_manager';
import { initializeStateManager } from '../../state_manager/state_manager';
import { StateComparators, StateManager } from '../../state_manager/types';
import { PublishesWritableDescription } from './publishes_description';
import { PublishesWritableTitle } from './publishes_title';
import { PublishesTitle, PublishesWritableTitle } from './publishes_title';
export interface SerializedTitles {
title?: string;
@ -18,6 +19,18 @@ export interface SerializedTitles {
hidePanelTitles?: boolean;
}
const defaultTitlesState: WithAllKeys<SerializedTitles> = {
title: undefined,
description: undefined,
hidePanelTitles: undefined,
};
export const titleComparators: StateComparators<SerializedTitles> = {
title: 'referenceEquality',
description: 'referenceEquality',
hidePanelTitles: (a, b) => Boolean(a) === Boolean(b),
};
export const stateHasTitles = (state: unknown): state is SerializedTitles => {
return (
(state as SerializedTitles)?.title !== undefined ||
@ -29,44 +42,23 @@ export const stateHasTitles = (state: unknown): state is SerializedTitles => {
export interface TitlesApi extends PublishesWritableTitle, PublishesWritableDescription {}
export const initializeTitleManager = (
rawState: SerializedTitles
): {
api: TitlesApi;
comparators: StateComparators<SerializedTitles>;
serialize: () => SerializedTitles;
initialTitlesState: SerializedTitles
): StateManager<SerializedTitles> & {
api: {
hideTitle$: PublishesTitle['hideTitle$'];
setHideTitle: PublishesWritableTitle['setHideTitle'];
};
} => {
const title$ = new BehaviorSubject<string | undefined>(rawState.title);
const description$ = new BehaviorSubject<string | undefined>(rawState.description);
const hideTitle$ = new BehaviorSubject<boolean | undefined>(rawState.hidePanelTitles);
const setTitle = (value: string | undefined) => {
if (value !== title$.value) title$.next(value);
};
const setHideTitle = (value: boolean | undefined) => {
if (value !== hideTitle$.value) hideTitle$.next(value);
};
const setDescription = (value: string | undefined) => {
if (value !== description$.value) description$.next(value);
};
const stateManager = initializeStateManager(initialTitlesState, defaultTitlesState);
return {
...stateManager,
api: {
title$,
hideTitle$,
setTitle,
setHideTitle,
description$,
setDescription,
...stateManager.api,
// SerializedTitles defines hideTitles as hidePanelTitles
// This state is persisted and this naming conflict will be resolved TBD
// add named APIs that match interface names as a work-around
hideTitle$: stateManager.api.hidePanelTitles$,
setHideTitle: stateManager.api.setHidePanelTitles,
},
comparators: {
title: [title$, setTitle],
description: [description$, setDescription],
hidePanelTitles: [hideTitle$, setHideTitle, (a, b) => Boolean(a) === Boolean(b)],
} as StateComparators<SerializedTitles>,
serialize: () => ({
title: title$.value,
hidePanelTitles: hideTitle$.value,
description: description$.value,
}),
};
};

View file

@ -7,6 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type { ComparatorFunction, ComparatorDefinition, StateComparators } from './types';
export { getInitialValuesFromComparators, runComparators } from './state_comparators';
export { getUnchangingComparator } from './fallback_comparator';
export { areComparatorsEqual, diffComparators, runComparator } from './state_comparators';
export { initializeStateManager } from './state_manager';
export type { ComparatorFunction, StateComparators, WithAllKeys } from './types';

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { StateComparators } from './types';
const referenceEquality = <T>(a: T, b: T) => a === b;
const deepEquality = <T>(a: T, b: T) => deepEqual(a, b);
export const runComparator = <StateType extends object = object>(
comparator: StateComparators<StateType>[keyof StateType],
lastSavedState?: StateType,
latestState?: StateType,
lastSavedValue?: StateType[keyof StateType],
latestValue?: StateType[keyof StateType]
): boolean => {
if (comparator === 'skip') return true;
if (comparator === 'deepEquality') return deepEquality(lastSavedValue, latestValue);
if (comparator === 'referenceEquality') return referenceEquality(lastSavedValue, latestValue);
if (typeof comparator === 'function') {
return comparator(lastSavedValue, latestValue, lastSavedState, latestState);
}
throw new Error(`Comparator ${comparator} is not a valid comparator.`);
};
/**
* Run all comparators, and return an object containing only the keys that are not equal, set to the value of the latest state
*/
export const diffComparators = <StateType extends object = object>(
comparators: StateComparators<StateType>,
lastSavedState?: StateType,
latestState?: StateType
): Partial<StateType> => {
return Object.keys(comparators).reduce((acc, key) => {
const comparator = comparators[key as keyof StateType];
const lastSavedValue = lastSavedState?.[key as keyof StateType];
const currentValue = latestState?.[key as keyof StateType];
if (!runComparator(comparator, lastSavedState, latestState, lastSavedValue, currentValue)) {
acc[key as keyof StateType] = currentValue;
}
return acc;
}, {} as Partial<StateType>);
};
/**
* Run comparators until at least one returns false
*/
export const areComparatorsEqual = <StateType extends object = object>(
comparators: StateComparators<StateType>,
lastSavedState?: StateType,
currentState?: StateType,
defaultState?: Partial<StateType>
): boolean => {
return Object.keys(comparators).every((key) => {
const comparator = comparators[key as keyof StateType];
const lastSavedValue =
lastSavedState?.[key as keyof StateType] ?? defaultState?.[key as keyof StateType];
const currentValue =
currentState?.[key as keyof StateType] ?? defaultState?.[key as keyof StateType];
return runComparator(comparator, lastSavedState, currentState, lastSavedValue, currentValue);
});
};

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject, map, merge } from 'rxjs';
import { StateManager, WithAllKeys } from './types';
type SubjectOf<StateType extends object> = BehaviorSubject<WithAllKeys<StateType>[keyof StateType]>;
interface UnstructuredSettersAndSubjects<StateType extends object> {
[key: string]: SubjectOf<StateType> | ((value: StateType[keyof StateType]) => void);
}
type KeyToSubjectMap<StateType extends object> = {
[Key in keyof StateType]?: SubjectOf<StateType>;
};
/**
* Initializes a composable state manager instance for a given state type.
* @param initialState - The initial state of the state manager.
* @param defaultState - The default state of the state manager. Every key in this state must be present, for optional keys specify undefined explicly.
* @param customComparators - Custom comparators for each key in the state. If not provided, defaults to reference equality.
*/
export const initializeStateManager = <StateType extends object>(
initialState: StateType,
defaultState: WithAllKeys<StateType>
): StateManager<StateType> => {
const allState = { ...defaultState, ...initialState };
const allSubjects: Array<SubjectOf<StateType>> = [];
const keyToSubjectMap: KeyToSubjectMap<StateType> = {};
/**
* Build the API for this state type. We loop through default state because it is guaranteed to
* have all keys and we use it to build the API with a setter and a subject for each key.
*/
const api: StateManager<StateType>['api'] = (
Object.keys(defaultState) as Array<keyof StateType>
).reduce((acc, key) => {
const subject = new BehaviorSubject(allState[key]);
const setter = (value: StateType[typeof key]) => {
subject.next(value);
};
const capitalizedKey = (key as string).charAt(0).toUpperCase() + (key as string).slice(1);
acc[`set${capitalizedKey}`] = setter;
acc[`${key as string}$`] = subject;
allSubjects.push(subject);
keyToSubjectMap[key] = subject;
return acc;
}, {} as UnstructuredSettersAndSubjects<StateType>) as StateManager<StateType>['api'];
/**
* Gets the latest state of this state manager.
*/
const getLatestState: StateManager<StateType>['getLatestState'] = () => {
return Object.keys(defaultState).reduce((acc, key) => {
acc[key as keyof StateType] = keyToSubjectMap[key as keyof StateType]!.getValue();
return acc;
}, {} as StateType);
};
/**
* Reinitializes the state of this state manager. Takes a partial state object that may be undefined.
*
* This method resets ALL keys in this state, if a key is not present in the new state, it will be set to the default value.
*/
const reinitializeState = (newState?: Partial<StateType>) => {
for (const [key, subject] of Object.entries<SubjectOf<StateType>>(
keyToSubjectMap as { [key: string]: SubjectOf<StateType> }
)) {
subject.next(newState?.[key as keyof StateType] ?? defaultState[key as keyof StateType]);
}
};
// SERIALIZED STATE ONLY TODO: Remember that the state manager DOES NOT contain comparators, because it's meant for Runtime state, and comparators should be written against serialized state.
return {
api,
getLatestState,
reinitializeState,
anyStateChange$: merge(...allSubjects).pipe(map(() => undefined)),
};
};

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Observable } from 'rxjs';
import { PublishingSubject } from '../publishing_subject';
export type WithAllKeys<T extends object> = { [Key in keyof Required<T>]: T[Key] };
export type ComparatorFunction<StateType, KeyType extends keyof StateType> = (
last: StateType[KeyType] | undefined,
current: StateType[KeyType] | undefined,
lastState?: Partial<StateType>,
currentState?: Partial<StateType>
) => boolean;
/**
* A type that maps each key in a state type to a definition of how it should be compared. If a custom
* comparator is provided, return true if the values are equal, false otherwise.
*/
export type StateComparators<StateType> = {
[KeyType in keyof Required<StateType>]:
| 'referenceEquality'
| 'deepEquality'
| 'skip'
| ComparatorFunction<StateType, KeyType>;
};
export type CustomComparators<StateType> = {
[KeyType in keyof StateType]?: ComparatorFunction<StateType, KeyType>;
};
type SubjectsOf<T extends object> = {
[KeyType in keyof Required<T> as `${string & KeyType}$`]: PublishingSubject<T[KeyType]>;
};
type SettersOf<T extends object> = {
[KeyType in keyof Required<T> as `set${Capitalize<string & KeyType>}`]: (
value: T[KeyType]
) => void;
};
export interface StateManager<StateType extends object> {
getLatestState: () => WithAllKeys<StateType>;
reinitializeState: (newState?: Partial<StateType>) => void;
api: SettersOf<StateType> & SubjectsOf<StateType>;
anyStateChange$: Observable<void>;
}

View file

@ -11,6 +11,7 @@
"@kbn/data-views-plugin",
"@kbn/expressions-plugin",
"@kbn/core-execution-context-common",
"@kbn/content-management-utils"
"@kbn/content-management-utils",
"@kbn/utility-types"
]
}

View file

@ -42,7 +42,7 @@ export const registerCreateImageAction = () => {
canAddNewPanelParent.addNewPanel<ImageEmbeddableSerializedState>({
panelType: IMAGE_EMBEDDABLE_TYPE,
initialState: { imageConfig },
serializedState: { rawState: { imageConfig } },
});
} catch {
// swallow the rejection, since this just means the user closed without saving

View file

@ -8,14 +8,13 @@
*/
import React, { useEffect, useMemo } from 'react';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, map, merge } from 'rxjs';
import { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { PresentationContainer } from '@kbn/presentation-containers';
import { getUnchangingComparator, initializeTitleManager } from '@kbn/presentation-publishing';
import { PresentationContainer, initializeUnsavedChanges } from '@kbn/presentation-containers';
import { initializeTitleManager, titleComparators } from '@kbn/presentation-publishing';
import { IMAGE_CLICK_TRIGGER } from '../actions';
import { openImageEditor } from '../components/image_editor/open_image_editor';
@ -30,72 +29,82 @@ export const getImageEmbeddableFactory = ({
}: {
embeddableEnhanced?: EmbeddableEnhancedPluginStart;
}) => {
const imageEmbeddableFactory: ReactEmbeddableFactory<
ImageEmbeddableSerializedState,
const imageEmbeddableFactory: EmbeddableFactory<
ImageEmbeddableSerializedState,
ImageEmbeddableApi
> = {
type: IMAGE_EMBEDDABLE_TYPE,
deserializeState: (state) => state.rawState,
buildEmbeddable: async (initialState, buildApi, uuid) => {
const titleManager = initializeTitleManager(initialState);
buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => {
const titleManager = initializeTitleManager(initialState.rawState);
const dynamicActionsApi = embeddableEnhanced?.initializeReactEmbeddableDynamicActions(
const dynamicActionsManager = embeddableEnhanced?.initializeEmbeddableDynamicActions(
uuid,
() => titleManager.api.title$.getValue(),
initialState
initialState.rawState
);
// if it is provided, start the dynamic actions manager
const maybeStopDynamicActions = dynamicActionsApi?.startDynamicActions();
const maybeStopDynamicActions = dynamicActionsManager?.startDynamicActions();
const filesClient = filesService.filesClientFactory.asUnscoped<FileImageMetadata>();
const imageConfig$ = new BehaviorSubject<ImageConfig>(initialState.imageConfig);
const imageConfig$ = new BehaviorSubject<ImageConfig>(initialState.rawState.imageConfig);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
const embeddable = buildApi(
{
...titleManager.api,
...(dynamicActionsApi?.dynamicActionsApi ?? {}),
dataLoading$,
supportedTriggers: () => [IMAGE_CLICK_TRIGGER],
onEdit: async () => {
try {
const newImageConfig = await openImageEditor({
parentApi: embeddable.parentApi as PresentationContainer,
initialImageConfig: imageConfig$.getValue(),
});
imageConfig$.next(newImageConfig);
} catch {
// swallow the rejection, since this just means the user closed without saving
}
},
isEditingEnabled: () => true,
getTypeDisplayName: () =>
i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName.edit', {
defaultMessage: 'image',
}),
serializeState: () => {
return {
rawState: {
...titleManager.serialize(),
...(dynamicActionsApi?.serializeDynamicActions() ?? {}),
imageConfig: imageConfig$.getValue(),
},
};
function serializeState() {
return {
rawState: {
...titleManager.getLatestState(),
...(dynamicActionsManager?.getLatestState() ?? {}),
imageConfig: imageConfig$.getValue(),
},
};
}
const unsavedChangesApi = initializeUnsavedChanges<ImageEmbeddableSerializedState>({
uuid,
parentApi,
serializeState,
anyStateChange$: merge(
titleManager.anyStateChange$,
imageConfig$.pipe(map(() => undefined))
),
getComparators: () => {
return {
...(dynamicActionsManager?.comparators ?? { enhancements: 'skip' }),
...titleComparators,
imageConfig: 'deepEquality',
};
},
{
...titleManager.comparators,
...(dynamicActionsApi?.dynamicActionsComparator ?? {
enhancements: getUnchangingComparator(),
onReset: (lastSaved) => {
titleManager.reinitializeState(lastSaved?.rawState);
dynamicActionsManager?.reinitializeState(lastSaved?.rawState ?? {});
if (lastSaved) imageConfig$.next(lastSaved.rawState.imageConfig);
},
});
const embeddable = finalizeApi({
...titleManager.api,
...(dynamicActionsManager?.api ?? {}),
...unsavedChangesApi,
dataLoading$,
supportedTriggers: () => [IMAGE_CLICK_TRIGGER],
onEdit: async () => {
try {
const newImageConfig = await openImageEditor({
parentApi: embeddable.parentApi as PresentationContainer,
initialImageConfig: imageConfig$.getValue(),
});
imageConfig$.next(newImageConfig);
} catch {
// swallow the rejection, since this just means the user closed without saving
}
},
isEditingEnabled: () => true,
getTypeDisplayName: () =>
i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName.edit', {
defaultMessage: 'image',
}),
imageConfig: [
imageConfig$,
(value) => imageConfig$.next(value),
(a, b) => deepEqual(a, b),
],
}
);
serializeState,
});
return {
api: embeddable,
Component: () => {

View file

@ -17,10 +17,11 @@ import {
apiPublishesTitle,
apiPublishesSavedObjectId,
} from '@kbn/presentation-publishing';
import type { LinksParentApi } from '../types';
import type { LinksParentApi, LinksSerializedState } from '../types';
import { APP_ICON, APP_NAME, CONTENT_ID } from '../../common';
import { ADD_LINKS_PANEL_ACTION_ID } from './constants';
import { openEditorFlyout } from '../editor/open_editor_flyout';
import { serializeLinksAttributes } from '../lib/serialize_attributes';
export const isParentApiCompatible = (parentApi: unknown): parentApi is LinksParentApi =>
apiIsPresentationContainer(parentApi) &&
@ -42,9 +43,29 @@ export const addLinksPanelAction: ActionDefinition<EmbeddableApiContext> = {
});
if (!runtimeState) return;
await embeddable.addNewPanel({
function serializeState() {
if (!runtimeState) return;
if (runtimeState.savedObjectId !== undefined) {
return {
rawState: {
savedObjectId: runtimeState.savedObjectId,
},
};
}
const { attributes, references } = serializeLinksAttributes(runtimeState);
return {
rawState: {
attributes,
},
references,
};
}
await embeddable.addNewPanel<LinksSerializedState>({
panelType: CONTENT_ID,
initialState: runtimeState,
serializedState: serializeState(),
});
},
grouping: [ADD_PANEL_ANNOTATION_GROUP],

View file

@ -71,8 +71,8 @@ export const runSaveToLibrary = async (
});
resolve({
...newState,
defaultPanelTitle: newTitle,
defaultPanelDescription: newDescription,
defaultTitle: newTitle,
defaultDescription: newDescription,
savedObjectId: id,
});
return { id };

View file

@ -12,17 +12,11 @@ import { render, screen, waitFor } from '@testing-library/react';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { setStubKibanaServices } from '@kbn/presentation-panel-plugin/public/mocks';
import { EuiThemeProvider } from '@elastic/eui';
import { getLinksEmbeddableFactory } from './links_embeddable';
import { deserializeState, getLinksEmbeddableFactory } from './links_embeddable';
import { Link } from '../../common/content_management';
import { CONTENT_ID } from '../../common';
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import {
LinksApi,
LinksParentApi,
LinksRuntimeState,
LinksSerializedState,
ResolvedLink,
} from '../types';
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { LinksApi, LinksParentApi, LinksSerializedState, ResolvedLink } from '../types';
import { linksClient } from '../content_management';
import { getMockLinksParentApi } from '../mocks';
@ -148,7 +142,7 @@ const renderEmbeddable = (
) => {
return render(
<EuiThemeProvider>
<ReactEmbeddableRenderer<LinksSerializedState, LinksRuntimeState, LinksApi>
<EmbeddableRenderer<LinksSerializedState, LinksApi>
type={CONTENT_ID}
onApiAvailable={jest.fn()}
getParentApi={jest.fn().mockReturnValue(parent)}
@ -178,8 +172,8 @@ describe('getLinksEmbeddableFactory', () => {
} as LinksSerializedState;
const expectedRuntimeState = {
defaultPanelTitle: 'links 001',
defaultPanelDescription: 'some links',
defaultTitle: 'links 001',
defaultDescription: 'some links',
layout: 'vertical',
links: getResolvedLinks(),
description: 'just a few links',
@ -195,7 +189,7 @@ describe('getLinksEmbeddableFactory', () => {
});
test('deserializeState', async () => {
const deserializedState = await factory.deserializeState({
const deserializedState = await deserializeState({
rawState,
references: [], // no references passed because the panel is by reference
});
@ -266,8 +260,8 @@ describe('getLinksEmbeddableFactory', () => {
} as LinksSerializedState;
const expectedRuntimeState = {
defaultPanelTitle: undefined,
defaultPanelDescription: undefined,
defaultTitle: undefined,
defaultDescription: undefined,
layout: 'horizontal',
links: getResolvedLinks(),
description: 'just a few links',
@ -283,7 +277,7 @@ describe('getLinksEmbeddableFactory', () => {
});
test('deserializeState', async () => {
const deserializedState = await factory.deserializeState({
const deserializedState = await deserializeState({
rawState,
references,
});

View file

@ -8,24 +8,26 @@
*/
import React, { createContext, useMemo } from 'react';
import { cloneDeep } from 'lodash';
import { BehaviorSubject } from 'rxjs';
import { cloneDeep, isUndefined, omitBy } from 'lodash';
import { BehaviorSubject, merge } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import { EuiListGroup, EuiPanel, UseEuiTheme } from '@elastic/eui';
import { PanelIncompatibleError, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { PanelIncompatibleError, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import {
SerializedTitles,
initializeTitleManager,
SerializedPanelState,
useBatchedOptionalPublishingSubjects,
initializeStateManager,
titleComparators,
} from '@kbn/presentation-publishing';
import { css } from '@emotion/react';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import { apiIsPresentationContainer, initializeUnsavedChanges } from '@kbn/presentation-containers';
import {
CONTENT_ID,
DASHBOARD_LINK_TYPE,
LinksLayoutType,
LINKS_HORIZONTAL_LAYOUT,
LINKS_VERTICAL_LAYOUT,
} from '../../common/content_management';
@ -38,7 +40,6 @@ import {
LinksParentApi,
LinksRuntimeState,
LinksSerializedState,
ResolvedLink,
} from '../types';
import { DISPLAY_NAME } from '../../common';
import { injectReferences } from '../../common/persistable_state';
@ -54,172 +55,212 @@ import { isParentApiCompatible } from '../actions/add_links_panel_action';
export const LinksContext = createContext<LinksApi | null>(null);
export const getLinksEmbeddableFactory = () => {
const linksEmbeddableFactory: ReactEmbeddableFactory<
LinksSerializedState,
LinksRuntimeState,
LinksApi
> = {
type: CONTENT_ID,
deserializeState: async (serializedState) => {
// Clone the state to avoid an object not extensible error when injecting references
const state = cloneDeep(serializedState.rawState);
const { title, description, hidePanelTitles } = serializedState.rawState;
export async function deserializeState(
serializedState: SerializedPanelState<LinksSerializedState>
) {
// Clone the state to avoid an object not extensible error when injecting references
const state = cloneDeep(serializedState.rawState);
const { title, description, hidePanelTitles } = serializedState.rawState;
if (linksSerializeStateIsByReference(state)) {
const linksSavedObject = await linksClient.get(state.savedObjectId);
const runtimeState = await deserializeLinksSavedObject(linksSavedObject.item);
if (linksSerializeStateIsByReference(state)) {
const linksSavedObject = await linksClient.get(state.savedObjectId);
const runtimeState = await deserializeLinksSavedObject(linksSavedObject.item);
return {
...runtimeState,
title,
description,
hidePanelTitles,
};
}
const { attributes: attributesWithInjectedIds } = injectReferences({
attributes: state.attributes,
references: serializedState.references ?? [],
});
const resolvedLinks = await resolveLinks(attributesWithInjectedIds.links ?? []);
return {
title,
description,
hidePanelTitles,
links: resolvedLinks,
layout: attributesWithInjectedIds.layout,
defaultTitle: attributesWithInjectedIds.title,
defaultDescription: attributesWithInjectedIds.description,
};
}
export const getLinksEmbeddableFactory = () => {
const linksEmbeddableFactory: EmbeddableFactory<LinksSerializedState, LinksApi> = {
type: CONTENT_ID,
buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => {
const titleManager = initializeTitleManager(initialState.rawState);
const savedObjectId = linksSerializeStateIsByReference(initialState.rawState)
? initialState.rawState.savedObjectId
: undefined;
const isByReference = savedObjectId !== undefined;
const initialRuntimeState = await deserializeState(initialState);
const blockingError$ = new BehaviorSubject<Error | undefined>(undefined);
if (!isParentApiCompatible(parentApi)) blockingError$.next(new PanelIncompatibleError());
const stateManager = initializeStateManager<
Pick<LinksRuntimeState, 'defaultDescription' | 'defaultTitle' | 'layout' | 'links'>
>(initialRuntimeState, {
defaultDescription: undefined,
defaultTitle: undefined,
layout: undefined,
links: undefined,
});
function serializeByReference(id: string) {
return {
...runtimeState,
title,
description,
hidePanelTitles,
rawState: {
...titleManager.getLatestState(),
savedObjectId: id,
} as LinksByReferenceSerializedState,
references: [],
};
}
const { attributes: attributesWithInjectedIds } = injectReferences({
attributes: state.attributes,
references: serializedState.references ?? [],
function serializeByValue() {
const { attributes, references } = serializeLinksAttributes(stateManager.getLatestState());
return {
rawState: {
...titleManager.getLatestState(),
attributes,
} as LinksByValueSerializedState,
references,
};
}
const serializeState = () =>
isByReference ? serializeByReference(savedObjectId) : serializeByValue();
const unsavedChangesApi = initializeUnsavedChanges<LinksSerializedState>({
uuid,
parentApi,
serializeState,
anyStateChange$: merge(titleManager.anyStateChange$, stateManager.anyStateChange$),
getComparators: () => {
return {
...titleComparators,
attributes: isByReference
? 'skip'
: (
a?: LinksByValueSerializedState['attributes'],
b?: LinksByValueSerializedState['attributes']
) => {
if (
a?.title !== b?.title ||
a?.description !== b?.description ||
a?.layout !== b?.layout ||
a?.links?.length !== b?.links?.length
) {
return false;
}
const hasLinkDifference = (a?.links ?? []).some((linkFromA, index) => {
const linkFromB = b?.links?.[index];
return !deepEqual(
omitBy(linkFromA, isUndefined),
omitBy(linkFromB, isUndefined)
);
});
return !hasLinkDifference;
},
savedObjectId: 'skip',
};
},
onReset: async (lastSaved) => {
titleManager.reinitializeState(lastSaved?.rawState);
if (lastSaved && !isByReference) {
const lastSavedRuntimeState = await deserializeState(lastSaved);
stateManager.reinitializeState(lastSavedRuntimeState);
}
},
});
const resolvedLinks = await resolveLinks(attributesWithInjectedIds.links ?? []);
return {
title,
description,
hidePanelTitles,
links: resolvedLinks,
layout: attributesWithInjectedIds.layout,
defaultPanelTitle: attributesWithInjectedIds.title,
defaultPanelDescription: attributesWithInjectedIds.description,
};
},
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
const blockingError$ = new BehaviorSubject<Error | undefined>(state.error);
if (!isParentApiCompatible(parentApi)) blockingError$.next(new PanelIncompatibleError());
const links$ = new BehaviorSubject<ResolvedLink[] | undefined>(state.links);
const layout$ = new BehaviorSubject<LinksLayoutType | undefined>(state.layout);
const defaultTitle$ = new BehaviorSubject<string | undefined>(state.defaultPanelTitle);
const defaultDescription$ = new BehaviorSubject<string | undefined>(
state.defaultPanelDescription
);
const savedObjectId$ = new BehaviorSubject(state.savedObjectId);
const isByReference = Boolean(state.savedObjectId);
const titleManager = initializeTitleManager(state);
const serializeLinksState = (byReference: boolean, newId?: string) => {
if (byReference) {
const linksByReferenceState: LinksByReferenceSerializedState = {
savedObjectId: newId ?? state.savedObjectId!,
...titleManager.serialize(),
};
return { rawState: linksByReferenceState, references: [] };
}
const runtimeState = api.snapshotRuntimeState();
const { attributes, references } = serializeLinksAttributes(runtimeState);
const linksByValueState: LinksByValueSerializedState = {
attributes,
...titleManager.serialize(),
};
return { rawState: linksByValueState, references };
};
const api = buildApi(
{
...titleManager.api,
blockingError$,
defaultTitle$,
defaultDescription$,
isEditingEnabled: () => Boolean(blockingError$.value === undefined),
getTypeDisplayName: () => DISPLAY_NAME,
serializeState: () => serializeLinksState(isByReference),
saveToLibrary: async (newTitle: string) => {
defaultTitle$.next(newTitle);
const runtimeState = api.snapshotRuntimeState();
const { attributes, references } = serializeLinksAttributes(runtimeState);
const {
item: { id },
} = await linksClient.create({
data: {
...attributes,
title: newTitle,
},
options: { references },
});
return id;
},
getSerializedStateByValue: () =>
serializeLinksState(false) as SerializedPanelState<LinksByValueSerializedState>,
getSerializedStateByReference: (newId: string) =>
serializeLinksState(
true,
newId
) as SerializedPanelState<LinksByReferenceSerializedState>,
canLinkToLibrary: async () => !isByReference,
canUnlinkFromLibrary: async () => isByReference,
checkForDuplicateTitle: async (
newTitle: string,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: () => void
) => {
await checkForDuplicateTitle({
const api = finalizeApi({
...titleManager.api,
...unsavedChangesApi,
blockingError$,
defaultTitle$: stateManager.api.defaultTitle$,
defaultDescription$: stateManager.api.defaultDescription$,
isEditingEnabled: () => Boolean(blockingError$.value === undefined),
getTypeDisplayName: () => DISPLAY_NAME,
serializeState,
saveToLibrary: async (newTitle: string) => {
stateManager.api.setDefaultTitle(newTitle);
const { attributes, references } = serializeLinksAttributes(
stateManager.getLatestState()
);
const {
item: { id },
} = await linksClient.create({
data: {
...attributes,
title: newTitle,
copyOnSave: false,
lastSavedTitle: '',
isTitleDuplicateConfirmed,
onTitleDuplicate,
});
},
onEdit: async () => {
const { openEditorFlyout } = await import('../editor/open_editor_flyout');
const newState = await openEditorFlyout({
initialState: api.snapshotRuntimeState(),
parentDashboard: parentApi,
});
if (!newState) return;
// if the by reference state has changed during this edit, reinitialize the panel.
const nextIsByReference = Boolean(newState?.savedObjectId);
if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) {
const serializedState = serializeLinksState(
nextIsByReference,
newState?.savedObjectId
);
(serializedState.rawState as SerializedTitles).title = newState.title;
api.parentApi.replacePanel<LinksSerializedState>(api.uuid, {
serializedState,
panelType: api.type,
});
return;
}
links$.next(newState.links);
layout$.next(newState.layout);
defaultTitle$.next(newState.defaultPanelTitle);
defaultDescription$.next(newState.defaultPanelDescription);
},
},
options: { references },
});
return id;
},
{
...titleManager.comparators,
links: [links$, (nextLinks?: ResolvedLink[]) => links$.next(nextLinks ?? [])],
layout: [
layout$,
(nextLayout?: LinksLayoutType) => layout$.next(nextLayout ?? LINKS_VERTICAL_LAYOUT),
],
error: [blockingError$, (nextError?: Error) => blockingError$.next(nextError)],
defaultPanelDescription: [
defaultDescription$,
(nextDescription?: string) => defaultDescription$.next(nextDescription),
],
defaultPanelTitle: [defaultTitle$, (nextTitle?: string) => defaultTitle$.next(nextTitle)],
savedObjectId: [savedObjectId$, (val) => savedObjectId$.next(val)],
}
);
getSerializedStateByValue: serializeByValue,
getSerializedStateByReference: serializeByReference,
canLinkToLibrary: async () => !isByReference,
canUnlinkFromLibrary: async () => isByReference,
checkForDuplicateTitle: async (
newTitle: string,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: () => void
) => {
await checkForDuplicateTitle({
title: newTitle,
copyOnSave: false,
lastSavedTitle: '',
isTitleDuplicateConfirmed,
onTitleDuplicate,
});
},
onEdit: async () => {
const { openEditorFlyout } = await import('../editor/open_editor_flyout');
const newState = await openEditorFlyout({
initialState: {
...stateManager.getLatestState(),
savedObjectId,
},
parentDashboard: parentApi,
});
if (!newState) return;
// if the by reference state has changed during this edit, reinitialize the panel.
const nextSavedObjectId = newState?.savedObjectId;
const nextIsByReference = nextSavedObjectId !== undefined;
if (nextIsByReference !== isByReference && apiIsPresentationContainer(api.parentApi)) {
const serializedState = nextIsByReference
? serializeByReference(nextSavedObjectId)
: serializeByValue();
(serializedState.rawState as SerializedTitles).title = newState.title;
api.parentApi.replacePanel<LinksSerializedState>(api.uuid, {
serializedState,
panelType: api.type,
});
return;
}
stateManager.reinitializeState(newState);
},
});
const Component = () => {
const [links, layout] = useBatchedOptionalPublishingSubjects(links$, layout$);
const [links, layout] = useBatchedOptionalPublishingSubjects(
stateManager.api.links$,
stateManager.api.layout$
);
const linkItems: { [id: string]: { id: string; content: JSX.Element } } = useMemo(() => {
if (!links) return {};

View file

@ -27,13 +27,13 @@ export const deserializeLinksSavedObject = async (
const links = await resolveLinks(attributes.links ?? []);
const { title: defaultPanelTitle, description: defaultPanelDescription, layout } = attributes;
const { title: defaultTitle, description: defaultDescription, layout } = attributes;
return {
links,
layout,
savedObjectId: linksSavedObject.id,
defaultPanelTitle,
defaultPanelDescription,
defaultTitle,
defaultDescription,
};
};

View file

@ -12,7 +12,7 @@ import { extractReferences } from '../../common/persistable_state';
import { LinksRuntimeState } from '../types';
export const serializeLinksAttributes = (
state: LinksRuntimeState,
state: Pick<LinksRuntimeState, 'defaultDescription' | 'defaultTitle' | 'layout' | 'links'>,
shouldExtractReferences: boolean = true
) => {
const linksToSave: Link[] | undefined = state.links
@ -25,8 +25,8 @@ export const serializeLinksAttributes = (
) as unknown as Link
);
const attributes = {
title: state.defaultPanelTitle,
description: state.defaultPanelDescription,
title: state.defaultTitle,
description: state.defaultDescription,
layout: state.layout,
links: linksToSave,
};

View file

@ -25,7 +25,8 @@ import { VisualizationsSetup } from '@kbn/visualizations-plugin/public';
import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { LinksRuntimeState } from './types';
import { SerializedPanelState } from '@kbn/presentation-publishing';
import { LinksSerializedState } from './types';
import { APP_ICON, APP_NAME, CONTENT_ID, LATEST_VERSION } from '../common';
import { LinksCrudTypes } from '../common/content_management';
import { getLinksClient } from './content_management/links_content_management_client';
@ -64,11 +65,13 @@ export class LinksPlugin
plugins.embeddable.registerAddFromLibraryType({
onAdd: async (container, savedObject) => {
const { deserializeLinksSavedObject } = await import('./lib/deserialize_from_library');
const initialState = await deserializeLinksSavedObject(savedObject);
container.addNewPanel<LinksRuntimeState>({
container.addNewPanel<LinksSerializedState>({
panelType: CONTENT_ID,
initialState,
serializedState: {
rawState: {
savedObjectId: savedObject.id,
},
},
});
},
savedObjectType: CONTENT_ID,
@ -142,8 +145,10 @@ export class LinksPlugin
plugins.dashboard.registerDashboardPanelPlacementSetting(
CONTENT_ID,
async (runtimeState?: LinksRuntimeState) => {
if (!runtimeState) return {};
async (serializedState?: SerializedPanelState<LinksSerializedState>) => {
if (!serializedState) return {};
const { deserializeState } = await import('./embeddable/links_embeddable');
const runtimeState = await deserializeState(serializedState);
const isHorizontal = runtimeState.layout === 'horizontal';
const width = isHorizontal ? DASHBOARD_GRID_COLUMN_COUNT : 8;
const height = isHorizontal ? 4 : (runtimeState.links?.length ?? 1 * 3) + 4;

View file

@ -18,7 +18,6 @@ import {
SerializedTitles,
} from '@kbn/presentation-publishing';
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public/plugin';
import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers';
import { LocatorPublic } from '@kbn/share-plugin/common';
import { DASHBOARD_API_TYPE } from '@kbn/dashboard-plugin/public';
@ -39,7 +38,7 @@ export type LinksParentApi = PresentationContainer &
};
export type LinksApi = HasType<typeof CONTENT_ID> &
DefaultEmbeddableApi<LinksSerializedState, LinksRuntimeState> &
DefaultEmbeddableApi<LinksSerializedState> &
HasEditCapabilities &
HasLibraryTransforms<LinksByReferenceSerializedState, LinksByValueSerializedState>;
@ -52,17 +51,15 @@ export interface LinksByValueSerializedState {
}
export type LinksSerializedState = SerializedTitles &
Partial<DynamicActionsSerializedState> &
(LinksByReferenceSerializedState | LinksByValueSerializedState);
export interface LinksRuntimeState
extends Partial<LinksByReferenceSerializedState>,
SerializedTitles {
error?: Error;
links?: ResolvedLink[];
layout?: LinksLayoutType;
defaultPanelTitle?: string;
defaultPanelDescription?: string;
defaultTitle?: string;
defaultDescription?: string;
}
export type ResolvedLink = Link & {

View file

@ -40,7 +40,6 @@
"@kbn/presentation-publishing",
"@kbn/react-kibana-context-render",
"@kbn/presentation-panel-plugin",
"@kbn/embeddable-enhanced-plugin",
"@kbn/share-plugin",
"@kbn/es-query"
],

View file

@ -12,7 +12,7 @@ import { BehaviorSubject } from 'rxjs';
import { ViewMode } from '@kbn/presentation-publishing';
import { getOptionsListControlFactory } from '../controls/data_controls/options_list_control/get_options_list_control_factory';
import { OptionsListControlApi } from '../controls/data_controls/options_list_control/types';
import { getMockedBuildApi, getMockedControlGroupApi } from '../controls/mocks/control_mocks';
import { getMockedControlGroupApi, getMockedFinalizeApi } from '../controls/mocks/control_mocks';
import { coreServices } from '../services/kibana_services';
import { DeleteControlAction } from './delete_control_action';
@ -31,18 +31,18 @@ beforeAll(async () => {
const controlFactory = getOptionsListControlFactory();
const uuid = 'testControl';
const control = await controlFactory.buildControl(
{
const control = await controlFactory.buildControl({
initialState: {
dataViewId: 'test-data-view',
title: 'test',
fieldName: 'test-field',
width: 'medium',
grow: false,
},
getMockedBuildApi(uuid, controlFactory, controlGroupApi),
finalizeApi: getMockedFinalizeApi(uuid, controlFactory, controlGroupApi),
uuid,
controlGroupApi
);
controlGroupApi,
});
controlApi = control.api;
});

View file

@ -15,7 +15,7 @@ import type { ViewMode } from '@kbn/presentation-publishing';
import { getOptionsListControlFactory } from '../controls/data_controls/options_list_control/get_options_list_control_factory';
import type { OptionsListControlApi } from '../controls/data_controls/options_list_control/types';
import { getMockedBuildApi, getMockedControlGroupApi } from '../controls/mocks/control_mocks';
import { getMockedControlGroupApi, getMockedFinalizeApi } from '../controls/mocks/control_mocks';
import { getTimesliderControlFactory } from '../controls/timeslider_control/get_timeslider_control_factory';
import { dataService } from '../services/kibana_services';
import { EditControlAction } from './edit_control_action';
@ -43,18 +43,19 @@ beforeAll(async () => {
const controlFactory = getOptionsListControlFactory();
const optionsListUuid = 'optionsListControl';
const optionsListControl = await controlFactory.buildControl(
{
const optionsListControl = await controlFactory.buildControl({
initialState: {
dataViewId: 'test-data-view',
title: 'test',
fieldName: 'test-field',
width: 'medium',
grow: false,
},
getMockedBuildApi(optionsListUuid, controlFactory, controlGroupApi),
optionsListUuid,
controlGroupApi
);
finalizeApi: getMockedFinalizeApi(optionsListUuid, controlFactory, controlGroupApi),
uuid: optionsListUuid,
controlGroupApi,
});
optionsListApi = optionsListControl.api;
});
@ -63,12 +64,12 @@ describe('Incompatible embeddables', () => {
test('Action is incompatible with embeddables that are not editable', async () => {
const timeSliderFactory = getTimesliderControlFactory();
const timeSliderUuid = 'timeSliderControl';
const timeSliderControl = await timeSliderFactory.buildControl(
{},
getMockedBuildApi(timeSliderUuid, timeSliderFactory, controlGroupApi),
timeSliderUuid,
controlGroupApi
);
const timeSliderControl = await timeSliderFactory.buildControl({
initialState: {},
finalizeApi: getMockedFinalizeApi(timeSliderUuid, timeSliderFactory, controlGroupApi),
uuid: timeSliderUuid,
controlGroupApi,
});
const editControlAction = new EditControlAction();
expect(
await editControlAction.isCompatible({

View file

@ -13,14 +13,10 @@ import { BehaviorSubject } from 'rxjs';
import { render } from '@testing-library/react';
import { ControlGroupApi } from '../..';
import {
ControlGroupChainingSystem,
ControlLabelPosition,
DEFAULT_CONTROL_LABEL_POSITION,
ParentIgnoreSettings,
} from '../../../common';
import { DefaultControlApi } from '../../controls/types';
import { ControlGroupEditor } from './control_group_editor';
import { initializeEditorStateManager } from '../initialize_editor_state_manager';
import { DEFAULT_CONTROL_LABEL_POSITION } from '../../../common';
describe('render', () => {
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
@ -31,12 +27,12 @@ describe('render', () => {
onCancel: () => {},
onSave: () => {},
onDeleteAll: () => {},
stateManager: {
chainingSystem: new BehaviorSubject<ControlGroupChainingSystem>('HIERARCHICAL'),
labelPosition: new BehaviorSubject<ControlLabelPosition>(DEFAULT_CONTROL_LABEL_POSITION),
autoApplySelections: new BehaviorSubject<boolean>(true),
ignoreParentSettings: new BehaviorSubject<ParentIgnoreSettings | undefined>(undefined),
},
stateManager: initializeEditorStateManager({
chainingSystem: 'HIERARCHICAL',
autoApplySelections: true,
ignoreParentSettings: undefined,
labelPosition: DEFAULT_CONTROL_LABEL_POSITION,
}),
};
beforeEach(() => {

View file

@ -27,9 +27,9 @@ import {
} from '@elastic/eui';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { StateManager } from '@kbn/presentation-publishing/state_manager/types';
import type { ControlLabelPosition, ParentIgnoreSettings } from '../../../common';
import { CONTROL_LAYOUT_OPTIONS } from '../../controls/data_controls/editor_constants';
import type { ControlStateManager } from '../../controls/types';
import { ControlGroupStrings } from '../control_group_strings';
import type { ControlGroupApi, ControlGroupEditorState } from '../types';
import { ControlSettingTooltipLabel } from './control_setting_tooltip_label';
@ -38,7 +38,7 @@ interface Props {
onCancel: () => void;
onSave: () => void;
onDeleteAll: () => void;
stateManager: ControlStateManager<ControlGroupEditorState>;
stateManager: StateManager<ControlGroupEditorState>;
api: ControlGroupApi; // controls must always have a parent API
}
@ -51,22 +51,22 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
selectedIgnoreParentSettings,
] = useBatchedPublishingSubjects(
api.children$,
stateManager.labelPosition,
stateManager.chainingSystem,
stateManager.autoApplySelections,
stateManager.ignoreParentSettings
stateManager.api.labelPosition$,
stateManager.api.chainingSystem$,
stateManager.api.autoApplySelections$,
stateManager.api.ignoreParentSettings$
);
const controlCount = useMemo(() => Object.keys(children).length, [children]);
const updateIgnoreSetting = useCallback(
(newSettings: Partial<ParentIgnoreSettings>) => {
stateManager.ignoreParentSettings.next({
stateManager.api.setIgnoreParentSettings({
...(selectedIgnoreParentSettings ?? {}),
...newSettings,
});
},
[stateManager.ignoreParentSettings, selectedIgnoreParentSettings]
[stateManager.api, selectedIgnoreParentSettings]
);
return (
@ -86,7 +86,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
idSelected={selectedLabelPosition}
legend={ControlGroupStrings.management.labelPosition.getLabelPositionLegend()}
onChange={(newPosition: string) => {
stateManager.labelPosition.next(newPosition as ControlLabelPosition);
stateManager.api.setLabelPosition(newPosition as ControlLabelPosition);
}}
/>
</EuiFormRow>
@ -149,7 +149,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
}
checked={selectedChainingSystem === 'HIERARCHICAL'}
onChange={(e) =>
stateManager.chainingSystem.next(e.target.checked ? 'HIERARCHICAL' : 'NONE')
stateManager.api.setChainingSystem(e.target.checked ? 'HIERARCHICAL' : 'NONE')
}
/>
<EuiSpacer size="s" />
@ -163,7 +163,7 @@ export const ControlGroupEditor = ({ onCancel, onSave, onDeleteAll, stateManager
/>
}
checked={selectedAutoApplySelections}
onChange={(e) => stateManager.autoApplySelections.next(e.target.checked)}
onChange={(e) => stateManager.api.setAutoApplySelections(e.target.checked)}
/>
</div>
</EuiFormRow>

View file

@ -7,12 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import React, { useEffect, useImperativeHandle, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import { initializeUnsavedChanges } from '@kbn/presentation-containers';
import { StateComparators } from '@kbn/presentation-publishing';
import type { DefaultControlState } from '../../../common';
import { getControlFactory } from '../../control_factory_registry';
import type { ControlApiRegistration, DefaultControlApi } from '../../controls/types';
@ -38,8 +35,6 @@ export const ControlRenderer = <
onApiAvailable?: (api: ApiType) => void;
isControlGroupInitialized: boolean;
}) => {
const cleanupFunction = useRef<(() => void) | null>(null);
const [component, setComponent] = useState<undefined | React.FC<{ className: string }>>(
undefined
);
@ -49,33 +44,26 @@ export const ControlRenderer = <
let ignore = false;
async function buildControl() {
const parentApi = getParentApi();
const controlGroupApi = getParentApi();
const factory = await getControlFactory<StateType, ApiType>(type);
const buildApi = (
apiRegistration: ControlApiRegistration<ApiType>,
comparators: StateComparators<StateType>
): ApiType => {
const unsavedChanges = initializeUnsavedChanges<StateType>(
parentApi.getLastSavedControlState(uuid) as StateType,
parentApi,
comparators
);
cleanupFunction.current = () => unsavedChanges.cleanup();
const finalizeApi = (apiRegistration: ControlApiRegistration<ApiType>): ApiType => {
return {
...apiRegistration,
...unsavedChanges.api,
uuid,
parentApi,
parentApi: controlGroupApi,
type: factory.type,
} as unknown as ApiType;
};
const { rawState: initialState } = parentApi.getSerializedStateForChild(uuid) ?? {
const { rawState: initialState } = controlGroupApi.getSerializedStateForChild(uuid) ?? {
rawState: {},
};
return await factory.buildControl(initialState as StateType, buildApi, uuid, parentApi);
return await factory.buildControl({
initialState: initialState as StateType,
finalizeApi,
uuid,
controlGroupApi,
});
}
buildControl()
@ -127,12 +115,6 @@ export const ControlRenderer = <
[type]
);
useEffect(() => {
return () => {
cleanupFunction.current?.();
};
}, []);
return component && isControlGroupInitialized ? (
// @ts-expect-error
<ControlPanel<ApiType> Component={component} uuid={uuid} />

View file

@ -66,7 +66,7 @@ describe('control group renderer', () => {
expect(buildControlGroupSpy).toBeCalledTimes(1);
act(() => api.updateInput({ autoApplySelections: false }));
await waitFor(() => {
expect(buildControlGroupSpy).toBeCalledTimes(2);
expect(buildControlGroupSpy).toBeCalledTimes(1);
});
});

View file

@ -7,30 +7,27 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { omit } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { BehaviorSubject, Subject } from 'rxjs';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { BehaviorSubject, Subject, map } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { useSearchApi, type ViewMode } from '@kbn/presentation-publishing';
import type { ControlGroupApi } from '../..';
import {
CONTROL_GROUP_TYPE,
DEFAULT_CONTROL_LABEL_POSITION,
type ControlGroupRuntimeState,
type ControlGroupSerializedState,
DEFAULT_CONTROL_CHAINING,
DEFAULT_AUTO_APPLY_SELECTIONS,
} from '../../../common';
import {
type ControlGroupStateBuilder,
controlGroupStateBuilder,
} from '../utils/control_group_state_builder';
import { getDefaultControlGroupRuntimeState } from '../utils/initialization_utils';
import type { ControlGroupCreationOptions, ControlGroupRendererApi } from './types';
import { deserializeControlGroup } from '../utils/serialization_utils';
import { defaultRuntimeState, serializeRuntimeState } from '../utils/serialize_runtime_state';
export interface ControlGroupRendererProps {
onApiAvailable: (api: ControlGroupRendererApi) => void;
@ -56,8 +53,9 @@ export const ControlGroupRenderer = ({
dataLoading,
compressed,
}: ControlGroupRendererProps) => {
const lastState$Ref = useRef(new BehaviorSubject(serializeRuntimeState({})));
const id = useMemo(() => uuidv4(), []);
const [regenerateId, setRegenerateId] = useState(uuidv4());
const [isStateLoaded, setIsStateLoaded] = useState(false);
const [controlGroup, setControlGroup] = useState<ControlGroupRendererApi | undefined>();
/**
@ -91,69 +89,39 @@ export const ControlGroupRenderer = ({
const reload$ = useMemo(() => new Subject<void>(), []);
/**
* Control group API set up
*/
const runtimeState$ = useMemo(
() => new BehaviorSubject<ControlGroupRuntimeState>(getDefaultControlGroupRuntimeState()),
[]
);
const [serializedState, setSerializedState] = useState<ControlGroupSerializedState | undefined>();
const updateInput = useCallback(
(newState: Partial<ControlGroupRuntimeState>) => {
runtimeState$.next({
...runtimeState$.getValue(),
...newState,
});
},
[runtimeState$]
);
/**
* To mimic `input$`, subscribe to unsaved changes and snapshot the runtime state whenever
* something change
*/
useEffect(() => {
if (!controlGroup) return;
const stateChangeSubscription = controlGroup.unsavedChanges$.subscribe((changes) => {
runtimeState$.next({ ...runtimeState$.getValue(), ...changes });
const subscription = controlGroup.hasUnsavedChanges$.subscribe((hasUnsavedChanges) => {
if (hasUnsavedChanges) lastState$Ref.current.next(controlGroup.serializeState());
});
return () => {
stateChangeSubscription.unsubscribe();
subscription.unsubscribe();
};
}, [controlGroup, runtimeState$]);
}, [controlGroup]);
/**
* On mount
*/
useEffect(() => {
let cancelled = false;
(async () => {
const { initialState, editorConfig } =
(await getCreationOptions?.(
getDefaultControlGroupRuntimeState(),
controlGroupStateBuilder
)) ?? {};
updateInput({
...initialState,
editorConfig,
});
const state: ControlGroupSerializedState = {
...omit(initialState, ['initialChildControlState']),
editorConfig,
autoApplySelections: initialState?.autoApplySelections ?? DEFAULT_AUTO_APPLY_SELECTIONS,
labelPosition: initialState?.labelPosition ?? DEFAULT_CONTROL_LABEL_POSITION,
chainingSystem: initialState?.chainingSystem ?? DEFAULT_CONTROL_CHAINING,
controls: Object.entries(initialState?.initialChildControlState ?? {}).map(
([controlId, value]) => ({ ...value, id: controlId })
),
};
if (!getCreationOptions) {
setIsStateLoaded(true);
return;
}
let cancelled = false;
getCreationOptions(defaultRuntimeState, controlGroupStateBuilder)
.then(({ initialState, editorConfig }) => {
if (cancelled) return;
const initialRuntimeState = {
...(initialState ?? defaultRuntimeState),
editorConfig,
} as ControlGroupRuntimeState;
lastState$Ref.current.next(serializeRuntimeState(initialRuntimeState));
setIsStateLoaded(true);
})
.catch();
if (!cancelled) {
setSerializedState(state);
}
})();
return () => {
cancelled = true;
};
@ -161,9 +129,8 @@ export const ControlGroupRenderer = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return !serializedState ? null : (
<ReactEmbeddableRenderer<ControlGroupSerializedState, ControlGroupRuntimeState, ControlGroupApi>
key={regenerateId} // this key forces a re-mount when `updateInput` is called
return !isStateLoaded ? null : (
<EmbeddableRenderer<ControlGroupSerializedState, ControlGroupApi>
maybeId={id}
type={CONTROL_GROUP_TYPE}
getParentApi={() => ({
@ -173,12 +140,9 @@ export const ControlGroupRenderer = ({
query$: searchApi.query$,
timeRange$: searchApi.timeRange$,
unifiedSearchFilters$: searchApi.filters$,
getSerializedStateForChild: () => ({
rawState: serializedState,
}),
getRuntimeStateForChild: () => {
return runtimeState$.getValue();
},
getSerializedStateForChild: () => lastState$Ref.current.value,
lastSavedStateForChild$: () => lastState$Ref.current,
getLastSavedStateForChild: () => lastState$Ref.current.value,
compressed: compressed ?? true,
})}
onApiAvailable={async (controlGroupApi) => {
@ -186,11 +150,17 @@ export const ControlGroupRenderer = ({
const controlGroupRendererApi: ControlGroupRendererApi = {
...controlGroupApi,
reload: () => reload$.next(),
updateInput: (newInput) => {
updateInput(newInput);
setRegenerateId(uuidv4()); // force remount
updateInput: (newInput: Partial<ControlGroupRuntimeState>) => {
lastState$Ref.current.next(
serializeRuntimeState({
...lastState$Ref.current.value,
...newInput,
})
);
controlGroupApi.resetUnsavedChanges();
},
getInput$: () => runtimeState$,
getInput$: () => lastState$Ref.current.pipe(map(deserializeControlGroup)),
getInput: () => deserializeControlGroup(lastState$Ref.current.value),
};
setControlGroup(controlGroupRendererApi);
onApiAvailable(controlGroupRendererApi);

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { Observable } from 'rxjs';
import type { ControlGroupEditorConfig, ControlGroupRuntimeState } from '../../../common';
import type { ControlGroupApi } from '../..';
@ -18,7 +18,7 @@ export type ControlGroupRendererApi = ControlGroupApi & {
* @deprecated
* Calling `updateInput` will cause the entire control group to be re-initialized.
*
* Therefore, to update the runtime state without `updateInput`, you should add public setters to the
* Therefore, to update state without `updateInput`, you should add public setters to the
* relavant API (`ControlGroupApi` or the individual control type APIs) for the state you wish to update
* and call those setters instead.
*/
@ -29,7 +29,12 @@ export type ControlGroupRendererApi = ControlGroupApi & {
* Instead of subscribing to the whole runtime state, it is more efficient to subscribe to the individual
* publishing subjects of the control group API.
*/
getInput$: () => BehaviorSubject<ControlGroupRuntimeState>;
getInput$: () => Observable<ControlGroupRuntimeState>;
/**
* @deprecated
*/
getInput: () => ControlGroupRuntimeState;
};
export interface ControlGroupCreationOptions {

View file

@ -7,71 +7,113 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { omit } from 'lodash';
import { combineLatest, map } from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import { combineLatest, combineLatestWith, debounceTime, map, merge, of } from 'rxjs';
import {
apiHasLastSavedChildState,
childrenUnsavedChanges$,
initializeUnsavedChanges,
type PresentationContainer,
} from '@kbn/presentation-containers';
import {
apiPublishesUnsavedChanges,
type PublishesUnsavedChanges,
type StateComparators,
PublishingSubject,
SerializedPanelState,
} from '@kbn/presentation-publishing';
import type { ControlGroupRuntimeState, ControlPanelsState } from '../../common';
import { StateManager } from '@kbn/presentation-publishing/state_manager/types';
import type { ControlGroupSerializedState, ControlPanelsState } from '../../common';
import { apiPublishesAsyncFilters } from '../controls/data_controls/publishes_async_filters';
import { getControlsInOrder, type ControlsInOrder } from './init_controls_manager';
import { deserializeControlGroup } from './utils/serialization_utils';
import { ControlGroupEditorState } from './types';
import { defaultEditorState, editorStateComparators } from './initialize_editor_state_manager';
export type ControlGroupComparatorState = Pick<
ControlGroupRuntimeState,
'autoApplySelections' | 'chainingSystem' | 'ignoreParentSettings' | 'labelPosition'
> & {
controlsInOrder: ControlsInOrder;
};
export function initializeControlGroupUnsavedChanges({
applySelections,
children$,
controlGroupId,
editorStateManager,
layout$,
parentApi,
resetControlsUnsavedChanges,
serializeControlGroupState,
}: {
applySelections: () => void;
children$: PresentationContainer['children$'];
controlGroupId: string;
editorStateManager: StateManager<ControlGroupEditorState>;
layout$: PublishingSubject<ControlsInOrder>;
parentApi: unknown;
resetControlsUnsavedChanges: (lastSavedControlsState: ControlPanelsState) => void;
serializeControlGroupState: () => SerializedPanelState<ControlGroupSerializedState>;
}) {
function getLastSavedControlsState() {
if (!apiHasLastSavedChildState<ControlGroupSerializedState>(parentApi)) {
return {};
}
const lastSavedControlGroupState = parentApi.getLastSavedStateForChild(controlGroupId);
return lastSavedControlGroupState
? deserializeControlGroup(lastSavedControlGroupState).initialChildControlState
: {};
}
export function initializeControlGroupUnsavedChanges(
applySelections: () => void,
children$: PresentationContainer['children$'],
comparators: StateComparators<ControlGroupComparatorState>,
snapshotControlsRuntimeState: () => ControlPanelsState,
resetControlsUnsavedChanges: () => void,
parentApi: unknown,
lastSavedRuntimeState: ControlGroupRuntimeState
) {
const controlGroupUnsavedChanges = initializeUnsavedChanges<ControlGroupComparatorState>(
{
autoApplySelections: lastSavedRuntimeState.autoApplySelections,
chainingSystem: lastSavedRuntimeState.chainingSystem,
controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState),
ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings,
labelPosition: lastSavedRuntimeState.labelPosition,
},
function getLastSavedStateForControl(controlId: string) {
const controlState = getLastSavedControlsState()[controlId];
return controlState ? { rawState: controlState } : undefined;
}
const lastSavedControlsState$ = apiHasLastSavedChildState<ControlGroupSerializedState>(parentApi)
? parentApi.lastSavedStateForChild$(controlGroupId).pipe(map(() => getLastSavedControlsState()))
: of({});
const controlGroupEditorUnsavedChangesApi = initializeUnsavedChanges<ControlGroupEditorState>({
uuid: controlGroupId,
parentApi,
comparators
serializeState: serializeControlGroupState,
anyStateChange$: merge(editorStateManager.anyStateChange$),
getComparators: () => editorStateComparators,
defaultState: defaultEditorState,
onReset: (lastSaved) => {
editorStateManager.reinitializeState(lastSaved?.rawState);
},
});
const hasLayoutChanges$ = layout$.pipe(
combineLatestWith(
lastSavedControlsState$.pipe(map((controlsState) => getControlsInOrder(controlsState)))
),
debounceTime(100),
map(([, lastSavedLayout]) => {
const currentLayout = layout$.value;
return !fastIsEqual(currentLayout, lastSavedLayout);
})
);
const hasControlChanges$ = childrenUnsavedChanges$(children$).pipe(
map((childrenWithChanges) => {
return childrenWithChanges.some(({ hasUnsavedChanges }) => hasUnsavedChanges);
})
);
return {
api: {
unsavedChanges$: combineLatest([
controlGroupUnsavedChanges.api.unsavedChanges$,
childrenUnsavedChanges$(children$),
lastSavedStateForChild$: (controlId: string) =>
lastSavedControlsState$.pipe(map(() => getLastSavedStateForControl(controlId))),
getLastSavedStateForChild: getLastSavedStateForControl,
hasUnsavedChanges$: combineLatest([
controlGroupEditorUnsavedChangesApi.hasUnsavedChanges$,
hasControlChanges$,
hasLayoutChanges$,
]).pipe(
map(([unsavedControlGroupState, unsavedControlsState]) => {
const unsavedChanges: Partial<ControlGroupRuntimeState> = unsavedControlGroupState
? omit(unsavedControlGroupState, 'controlsInOrder')
: {};
if (unsavedControlsState || unsavedControlGroupState?.controlsInOrder) {
unsavedChanges.initialChildControlState = snapshotControlsRuntimeState();
}
return Object.keys(unsavedChanges).length ? unsavedChanges : undefined;
map(([hasUnsavedControlGroupChanges, hasControlChanges, hasLayoutChanges]) => {
return hasUnsavedControlGroupChanges || hasControlChanges || hasLayoutChanges;
})
),
asyncResetUnsavedChanges: async () => {
controlGroupUnsavedChanges.api.resetUnsavedChanges();
resetControlsUnsavedChanges();
resetUnsavedChanges: async () => {
controlGroupEditorUnsavedChangesApi.resetUnsavedChanges();
resetControlsUnsavedChanges(getLastSavedControlsState());
const filtersReadyPromises: Array<Promise<void>> = [];
Object.values(children$.value).forEach((controlApi) => {
@ -83,12 +125,10 @@ export function initializeControlGroupUnsavedChanges(
await Promise.all(filtersReadyPromises);
if (!comparators.autoApplySelections[0].value) {
if (!editorStateManager.api.autoApplySelections$.value) {
applySelections();
}
},
} as Pick<PublishesUnsavedChanges, 'unsavedChanges$'> & {
asyncResetUnsavedChanges: () => Promise<void>;
},
};
}

View file

@ -8,36 +8,21 @@
*/
import { DataView } from '@kbn/data-views-plugin/common';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import type { ESQLControlVariable } from '@kbn/esql-types';
import { PublishesESQLVariable, apiPublishesESQLVariable } from '@kbn/esql-types';
import { i18n } from '@kbn/i18n';
import {
apiHasSaveNotification,
combineCompatibleChildrenApis,
} from '@kbn/presentation-containers';
import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
import {
PublishesDataViews,
apiPublishesDataViews,
useBatchedPublishingSubjects,
} from '@kbn/presentation-publishing';
import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
import fastIsEqual from 'fast-deep-equal';
import React, { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import type {
ControlGroupChainingSystem,
ControlGroupRuntimeState,
ControlGroupSerializedState,
ControlLabelPosition,
ControlPanelsState,
ParentIgnoreSettings,
} from '../../common';
import {
CONTROL_GROUP_TYPE,
DEFAULT_CONTROL_CHAINING,
DEFAULT_CONTROL_LABEL_POSITION,
} from '../../common';
import type { ControlGroupSerializedState } from '../../common';
import { CONTROL_GROUP_TYPE } from '../../common';
import { openDataControlEditor } from '../controls/data_controls/open_data_control_editor';
import { coreServices, dataViewsService } from '../services/kibana_services';
import { ControlGroup } from './components/control_group';
@ -48,88 +33,55 @@ import { openEditControlGroupFlyout } from './open_edit_control_group_flyout';
import { initSelectionsManager } from './selections_manager';
import type { ControlGroupApi } from './types';
import { deserializeControlGroup } from './utils/serialization_utils';
import { initializeEditorStateManager } from './initialize_editor_state_manager';
export const getControlGroupEmbeddableFactory = () => {
const controlGroupEmbeddableFactory: ReactEmbeddableFactory<
const controlGroupEmbeddableFactory: EmbeddableFactory<
ControlGroupSerializedState,
ControlGroupRuntimeState,
ControlGroupApi
> = {
type: CONTROL_GROUP_TYPE,
deserializeState: (state) => deserializeControlGroup(state),
buildEmbeddable: async (
initialRuntimeState,
buildApi,
uuid,
parentApi,
setApi,
lastSavedRuntimeState
) => {
const {
labelPosition: initialLabelPosition,
chainingSystem,
autoApplySelections,
ignoreParentSettings,
} = initialRuntimeState;
buildEmbeddable: async ({ initialState, finalizeApi, uuid, parentApi }) => {
const initialRuntimeState = deserializeControlGroup(initialState);
const editorStateManager = initializeEditorStateManager(initialState?.rawState);
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
const defaultDataViewId = await dataViewsService.getDefaultId();
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(
lastSavedRuntimeState.initialChildControlState
);
const controlsManager = initControlsManager(
initialRuntimeState.initialChildControlState,
lastSavedControlsState$
);
const controlsManager = initControlsManager(initialRuntimeState.initialChildControlState);
const selectionsManager = initSelectionsManager({
...controlsManager.api,
autoApplySelections$,
autoApplySelections$: editorStateManager.api.autoApplySelections$,
});
const esqlVariables$ = new BehaviorSubject<ESQLControlVariable[]>([]);
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(
chainingSystem ?? DEFAULT_CONTROL_CHAINING
);
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
ignoreParentSettings
);
const labelPosition$ = new BehaviorSubject<ControlLabelPosition>(
initialLabelPosition ?? DEFAULT_CONTROL_LABEL_POSITION
);
const allowExpensiveQueries$ = new BehaviorSubject<boolean>(true);
const disabledActionIds$ = new BehaviorSubject<string[] | undefined>(undefined);
const unsavedChanges = initializeControlGroupUnsavedChanges(
selectionsManager.applySelections,
controlsManager.api.children$,
{
...controlsManager.comparators,
autoApplySelections: [
autoApplySelections$,
(next: boolean) => autoApplySelections$.next(next),
],
chainingSystem: [
chainingSystem$,
(next: ControlGroupChainingSystem) => chainingSystem$.next(next),
(a, b) => (a ?? DEFAULT_CONTROL_CHAINING) === (b ?? DEFAULT_CONTROL_CHAINING),
],
ignoreParentSettings: [
ignoreParentSettings$,
(next: ParentIgnoreSettings | undefined) => ignoreParentSettings$.next(next),
fastIsEqual,
],
labelPosition: [
labelPosition$,
(next: ControlLabelPosition) => labelPosition$.next(next),
],
},
controlsManager.snapshotControlsRuntimeState,
controlsManager.resetControlsUnsavedChanges,
parentApi,
lastSavedRuntimeState
);
function serializeState() {
const { controls, references } = controlsManager.serializeControls();
return {
rawState: {
...editorStateManager.getLatestState(),
controls,
},
references,
};
}
const api = setApi({
const unsavedChanges = initializeControlGroupUnsavedChanges({
applySelections: selectionsManager.applySelections,
children$: controlsManager.api.children$,
controlGroupId: uuid,
editorStateManager,
layout$: controlsManager.controlsInOrder$,
parentApi,
resetControlsUnsavedChanges: controlsManager.resetControlsUnsavedChanges,
serializeControlGroupState: serializeState,
});
const api = finalizeApi({
...controlsManager.api,
esqlVariables$,
disabledActionIds$,
@ -139,31 +91,21 @@ export const getControlGroupEmbeddableFactory = () => {
controlFetch$(
chaining$(
controlUuid,
chainingSystem$,
editorStateManager.api.chainingSystem$,
controlsManager.controlsInOrder$,
controlsManager.api.children$
),
controlGroupFetch$(ignoreParentSettings$, parentApi ? parentApi : {}, onReload)
controlGroupFetch$(
editorStateManager.api.ignoreParentSettings$,
parentApi ? parentApi : {},
onReload
)
),
ignoreParentSettings$,
autoApplySelections$,
ignoreParentSettings$: editorStateManager.api.ignoreParentSettings$,
autoApplySelections$: editorStateManager.api.autoApplySelections$,
allowExpensiveQueries$,
snapshotRuntimeState: () => {
return {
chainingSystem: chainingSystem$.getValue(),
labelPosition: labelPosition$.getValue(),
autoApplySelections: autoApplySelections$.getValue(),
ignoreParentSettings: ignoreParentSettings$.getValue(),
initialChildControlState: controlsManager.snapshotControlsRuntimeState(),
};
},
onEdit: async () => {
openEditControlGroupFlyout(api, {
chainingSystem: chainingSystem$,
labelPosition: labelPosition$,
autoApplySelections: autoApplySelections$,
ignoreParentSettings: ignoreParentSettings$,
});
openEditControlGroupFlyout(api, editorStateManager);
},
isEditingEnabled: () => true,
openAddDataControlFlyout: (settings) => {
@ -178,36 +120,23 @@ export const getControlGroupEmbeddableFactory = () => {
dataViewId:
newControlState.dataViewId ?? parentDataViewId ?? defaultDataViewId ?? undefined,
},
onSave: ({ type: controlType, state: initialState }) => {
onSave: ({ type: controlType, state: onSaveState }) => {
controlsManager.api.addNewPanel({
panelType: controlType,
initialState: settings?.controlStateTransform
? settings.controlStateTransform(initialState, controlType)
: initialState,
serializedState: {
rawState: settings?.controlStateTransform
? settings.controlStateTransform(onSaveState, controlType)
: onSaveState,
},
});
settings?.onSave?.();
},
controlGroupApi: api,
});
},
serializeState: () => {
const { controls, references } = controlsManager.serializeControls();
return {
rawState: {
chainingSystem: chainingSystem$.getValue(),
labelPosition: labelPosition$.getValue(),
autoApplySelections: autoApplySelections$.getValue(),
ignoreParentSettings: ignoreParentSettings$.getValue(),
controls,
},
references,
};
},
serializeState,
dataViews$,
labelPosition: labelPosition$,
saveNotification$: apiHasSaveNotification(parentApi)
? parentApi.saveNotification$
: undefined,
labelPosition: editorStateManager.api.labelPosition$,
reload$: apiPublishesReload(parentApi) ? parentApi.reload$ : undefined,
/** Public getters */
@ -216,13 +145,10 @@ export const getControlGroupEmbeddableFactory = () => {
defaultMessage: 'Controls',
}),
getEditorConfig: () => initialRuntimeState.editorConfig,
getLastSavedControlState: (controlUuid: string) => {
return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {};
},
/** Public setters */
setDisabledActionIds: (ids) => disabledActionIds$.next(ids),
setChainingSystem: (newChainingSystem) => chainingSystem$.next(newChainingSystem),
setChainingSystem: editorStateManager.api.setChainingSystem,
});
/** Subscribe to all children's output data views, combine them, and output them */
@ -241,26 +167,12 @@ export const getControlGroupEmbeddableFactory = () => {
esqlVariables$.next(newESQLVariables);
});
const saveNotificationSubscription = apiHasSaveNotification(parentApi)
? parentApi.saveNotification$.subscribe(() => {
lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState());
if (
typeof autoApplySelections$.value === 'boolean' &&
!autoApplySelections$.value &&
selectionsManager.hasUnappliedSelections$.value
) {
selectionsManager.applySelections();
}
})
: undefined;
return {
api,
Component: () => {
const [hasUnappliedSelections, labelPosition] = useBatchedPublishingSubjects(
selectionsManager.hasUnappliedSelections$,
labelPosition$
editorStateManager.api.labelPosition$
);
useEffect(() => {
@ -286,7 +198,6 @@ export const getControlGroupEmbeddableFactory = () => {
selectionsManager.cleanup();
childrenDataViewsSubscription.unsubscribe();
childrenESQLVariablesSubscription.unsubscribe();
saveNotificationSubscription?.unsubscribe();
};
}, []);

View file

@ -7,8 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import type { ControlPanelState, ControlPanelsState, DefaultDataControlState } from '../../common';
import type { ControlPanelState, DefaultDataControlState } from '../../common';
import type { DefaultControlApi } from '../controls/types';
import { getLastUsedDataViewId, initControlsManager } from './init_controls_manager';
@ -22,13 +21,12 @@ describe('PresentationContainer api', () => {
bravo: { type: 'testControl', order: 1 },
charlie: { type: 'testControl', order: 2 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
test('addNewPanel should add control at end of controls', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
const addNewPanelPromise = controlsManager.api.addNewPanel({
panelType: 'testControl',
initialState: {},
serializedState: { rawState: {} },
});
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
await addNewPanelPromise;
@ -41,7 +39,7 @@ describe('PresentationContainer api', () => {
});
test('removePanel should remove control', () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
controlsManager.api.removePanel('bravo');
expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([
'alpha',
@ -50,10 +48,10 @@ describe('PresentationContainer api', () => {
});
test('replacePanel should replace control', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
const replacePanelPromise = controlsManager.api.replacePanel('bravo', {
panelType: 'testControl',
initialState: {},
serializedState: { rawState: {} },
});
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
await replacePanelPromise;
@ -66,7 +64,7 @@ describe('PresentationContainer api', () => {
describe('untilInitialized', () => {
test('should not resolve until all controls are initialized', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
let isDone = false;
controlsManager.api.untilInitialized().then(() => {
isDone = true;
@ -88,7 +86,7 @@ describe('PresentationContainer api', () => {
});
test('should resolve when all control already initialized ', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
controlsManager.setControlApi('bravo', {} as unknown as DefaultControlApi);
controlsManager.setControlApi('charlie', {} as unknown as DefaultControlApi);
@ -104,40 +102,6 @@ describe('PresentationContainer api', () => {
});
});
describe('snapshotControlsRuntimeState', () => {
const intialControlsState = {
alpha: { type: 'testControl', order: 1 },
bravo: { type: 'testControl', order: 0 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
test('should snapshot runtime state for all controls', async () => {
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
controlsManager.setControlApi('alpha', {
snapshotRuntimeState: () => {
return { key1: 'alpha value' };
},
} as unknown as DefaultControlApi);
controlsManager.setControlApi('bravo', {
snapshotRuntimeState: () => {
return { key1: 'bravo value' };
},
} as unknown as DefaultControlApi);
expect(controlsManager.snapshotControlsRuntimeState()).toEqual({
alpha: {
key1: 'alpha value',
order: 1,
type: 'testControl',
},
bravo: {
key1: 'bravo value',
order: 0,
type: 'testControl',
},
});
});
});
describe('getLastUsedDataViewId', () => {
test('should return last used data view id', () => {
const dataViewId = getLastUsedDataViewId(
@ -175,8 +139,7 @@ describe('resetControlsUnsavedChanges', () => {
alpha: { type: 'testControl', order: 0 },
};
// last saved state is empty control group
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>({});
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
expect(controlsManager.controlsInOrder$.value).toEqual([
@ -186,7 +149,8 @@ describe('resetControlsUnsavedChanges', () => {
},
]);
controlsManager.resetControlsUnsavedChanges();
// last saved state is empty control group
controlsManager.resetControlsUnsavedChanges({});
expect(controlsManager.controlsInOrder$.value).toEqual([]);
});
@ -194,15 +158,14 @@ describe('resetControlsUnsavedChanges', () => {
const intialControlsState = {
alpha: { type: 'testControl', order: 0 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
// delete control
controlsManager.api.removePanel('alpha');
// deleted control should exist on reset
controlsManager.resetControlsUnsavedChanges();
controlsManager.resetControlsUnsavedChanges(intialControlsState);
expect(controlsManager.controlsInOrder$.value).toEqual([
{
id: 'alpha',
@ -213,22 +176,14 @@ describe('resetControlsUnsavedChanges', () => {
test('should restore controls to last saved state', () => {
const intialControlsState = {};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
// add control
controlsManager.api.addNewPanel({ panelType: 'testControl' });
controlsManager.setControlApi('delta', {
snapshotRuntimeState: () => {
return {};
},
} as unknown as DefaultControlApi);
// simulate save
lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState());
controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi);
// saved control should exist on reset
controlsManager.resetControlsUnsavedChanges();
controlsManager.resetControlsUnsavedChanges({ delta: { type: 'testControl', order: 0 } });
expect(controlsManager.controlsInOrder$.value).toEqual([
{
id: 'delta',
@ -243,8 +198,7 @@ describe('resetControlsUnsavedChanges', () => {
const intialControlsState = {
alpha: { type: 'testControl', order: 0 },
};
const lastSavedControlsState$ = new BehaviorSubject<ControlPanelsState>(intialControlsState);
const controlsManager = initControlsManager(intialControlsState, lastSavedControlsState$);
const controlsManager = initControlsManager(intialControlsState);
controlsManager.setControlApi('alpha', {} as unknown as DefaultControlApi);
// add another control
@ -253,7 +207,7 @@ describe('resetControlsUnsavedChanges', () => {
expect(Object.keys(controlsManager.api.children$.value).length).toBe(2);
// reset to lastSavedControlsState
controlsManager.resetControlsUnsavedChanges();
controlsManager.resetControlsUnsavedChanges(intialControlsState);
// children$ should no longer contain control removed by resetting back to original control baseline
expect(Object.keys(controlsManager.api.children$.value).length).toBe(1);
});
@ -261,7 +215,7 @@ describe('resetControlsUnsavedChanges', () => {
describe('getNewControlState', () => {
test('should contain defaults when there are no existing controls', () => {
const controlsManager = initControlsManager({}, new BehaviorSubject<ControlPanelsState>({}));
const controlsManager = initControlsManager({});
expect(controlsManager.getNewControlState()).toEqual({
grow: false,
width: 'medium',
@ -279,10 +233,7 @@ describe('getNewControlState', () => {
grow: false,
} as ControlPanelState & Pick<DefaultDataControlState, 'dataViewId'>,
};
const controlsManager = initControlsManager(
intialControlsState,
new BehaviorSubject<ControlPanelsState>(intialControlsState)
);
const controlsManager = initControlsManager(intialControlsState);
expect(controlsManager.getNewControlState()).toEqual({
grow: false,
width: 'medium',
@ -291,13 +242,15 @@ describe('getNewControlState', () => {
});
test('should contain values of last added control', () => {
const controlsManager = initControlsManager({}, new BehaviorSubject<ControlPanelsState>({}));
const controlsManager = initControlsManager({});
controlsManager.api.addNewPanel({
panelType: 'testControl',
initialState: {
grow: false,
width: 'small',
dataViewId: 'myOtherDataViewId',
serializedState: {
rawState: {
grow: false,
width: 'small',
dataViewId: 'myOtherDataViewId',
},
},
});
expect(controlsManager.getNewControlState()).toEqual({

View file

@ -7,7 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import fastIsEqual from 'fast-deep-equal';
import { omit } from 'lodash';
import { v4 as generateId } from 'uuid';
@ -17,11 +16,7 @@ import type {
PanelPackage,
PresentationContainer,
} from '@kbn/presentation-containers';
import {
type PublishingSubject,
type StateComparators,
apiHasSnapshottableState,
} from '@kbn/presentation-publishing';
import type { PublishingSubject } from '@kbn/presentation-publishing';
import { BehaviorSubject, first, merge } from 'rxjs';
import type {
ControlGroupSerializedState,
@ -33,7 +28,6 @@ import type {
} from '../../common';
import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '../../common';
import type { DefaultControlApi } from '../controls/types';
import type { ControlGroupComparatorState } from './control_group_unsaved_changes_api';
import type { ControlGroupApi } from './types';
export type ControlsInOrder = Array<{ id: string; type: string }>;
@ -53,11 +47,7 @@ export function initControlsManager(
/**
* Composed from last saved controls state and previous sessions's unsaved changes to controls state
*/
initialControlsState: ControlPanelsState,
/**
* Observable that publishes last saved controls state only
*/
lastSavedControlsState$: PublishingSubject<ControlPanelsState>
initialControlsState: ControlPanelsState
) {
const initialControlIds = Object.keys(initialControlsState);
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
@ -103,17 +93,17 @@ export function initControlsManager(
}
async function addNewPanel(
{ panelType, initialState }: PanelPackage<{}, DefaultControlState>,
{ panelType, serializedState }: PanelPackage<DefaultControlState>,
index: number
) {
if ((initialState as DefaultDataControlState)?.dataViewId) {
lastUsedDataViewId$.next((initialState as DefaultDataControlState).dataViewId);
if ((serializedState?.rawState as DefaultDataControlState)?.dataViewId) {
lastUsedDataViewId$.next((serializedState!.rawState as DefaultDataControlState).dataViewId);
}
if (initialState?.width) {
lastUsedWidth$.next(initialState.width);
if (serializedState?.rawState?.width) {
lastUsedWidth$.next(serializedState.rawState.width);
}
if (typeof initialState?.grow === 'boolean') {
lastUsedGrow$.next(initialState.grow);
if (typeof serializedState?.rawState?.grow === 'boolean') {
lastUsedGrow$.next(serializedState.rawState.grow);
}
const id = generateId();
@ -123,7 +113,7 @@ export function initControlsManager(
type: panelType,
});
controlsInOrder$.next(nextControlsInOrder);
currentControlsState[id] = initialState ?? {};
currentControlsState[id] = serializedState?.rawState ?? {};
return await untilControlLoaded(id);
}
@ -185,23 +175,9 @@ export function initControlsManager(
references,
};
},
snapshotControlsRuntimeState: () => {
const controlsRuntimeState: ControlPanelsState = {};
controlsInOrder$.getValue().forEach(({ id, type }, index) => {
const controlApi = getControlApi(id);
if (controlApi && apiHasSnapshottableState(controlApi)) {
controlsRuntimeState[id] = {
order: index,
type,
...controlApi.snapshotRuntimeState(),
};
}
});
return controlsRuntimeState;
},
resetControlsUnsavedChanges: () => {
resetControlsUnsavedChanges: (lastSavedControlsState: ControlPanelsState) => {
currentControlsState = {
...lastSavedControlsState$.value,
...lastSavedControlsState,
};
const nextControlsInOrder = getControlsInOrder(currentControlsState as ControlPanelsState);
controlsInOrder$.next(nextControlsInOrder);
@ -263,13 +239,6 @@ export function initControlsManager(
} as PresentationContainer &
HasSerializedChildState<ControlPanelState> &
Pick<ControlGroupApi, 'untilInitialized'>,
comparators: {
controlsInOrder: [
controlsInOrder$,
(next: ControlsInOrder) => {}, // setter does nothing, controlsInOrder$ reset by resetControlsRuntimeState
fastIsEqual,
],
} as StateComparators<Pick<ControlGroupComparatorState, 'controlsInOrder'>>,
};
}

Some files were not shown because too many files have changed in this diff Show more