mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Embeddables rebuild] Support for by reference embeddables (#182523)
Adds first-class by reference support to the new Embeddable framework and adds an example of how a new-styled by reference embeddable could work.
This commit is contained in:
parent
95ad2f7fde
commit
53435eace3
53 changed files with 1451 additions and 508 deletions
|
@ -27,19 +27,14 @@ import { SEARCH_EMBEDDABLE_ID } from '../react_embeddables/search/constants';
|
|||
import type { SearchApi, SearchSerializedState } from '../react_embeddables/search/types';
|
||||
|
||||
export const RenderExamples = () => {
|
||||
const initialState = useMemo(() => {
|
||||
return {
|
||||
rawState: {
|
||||
timeRange: undefined,
|
||||
},
|
||||
references: [],
|
||||
};
|
||||
// only run onMount
|
||||
}, []);
|
||||
|
||||
const parentApi = useMemo(() => {
|
||||
return {
|
||||
reload$: new Subject<void>(),
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {
|
||||
timeRange: undefined,
|
||||
},
|
||||
}),
|
||||
timeRange$: new BehaviorSubject<TimeRange>({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
|
@ -85,8 +80,7 @@ export const RenderExamples = () => {
|
|||
<EuiCodeBlock language="jsx" fontSize="m" paddingSize="m">
|
||||
{`<ReactEmbeddableRenderer<State, Api>
|
||||
type={SEARCH_EMBEDDABLE_ID}
|
||||
state={initialState}
|
||||
parentApi={parentApi}
|
||||
getParentApi={() => parentApi}
|
||||
onApiAvailable={(newApi) => {
|
||||
setApi(newApi);
|
||||
}}
|
||||
|
@ -107,8 +101,7 @@ export const RenderExamples = () => {
|
|||
<ReactEmbeddableRenderer<SearchSerializedState, SearchApi>
|
||||
key={hidePanelChrome ? 'hideChrome' : 'showChrome'}
|
||||
type={SEARCH_EMBEDDABLE_ID}
|
||||
state={initialState}
|
||||
parentApi={parentApi}
|
||||
getParentApi={() => parentApi}
|
||||
onApiAvailable={(newApi) => {
|
||||
setApi(newApi);
|
||||
}}
|
||||
|
|
|
@ -21,9 +21,11 @@ import { DATA_TABLE_ID } from './react_embeddables/data_table/constants';
|
|||
import { registerCreateDataTableAction } from './react_embeddables/data_table/create_data_table_action';
|
||||
import { EUI_MARKDOWN_ID } from './react_embeddables/eui_markdown/constants';
|
||||
import { registerCreateEuiMarkdownAction } from './react_embeddables/eui_markdown/create_eui_markdown_action';
|
||||
import { registerCreateFieldListAction } from './react_embeddables/field_list/create_field_list_action';
|
||||
import { FIELD_LIST_ID } from './react_embeddables/field_list/constants';
|
||||
import { registerCreateFieldListAction } from './react_embeddables/field_list/create_field_list_action';
|
||||
import { registerFieldListPanelPlacementSetting } from './react_embeddables/field_list/register_field_list_embeddable';
|
||||
import { SAVED_BOOK_ID } from './react_embeddables/saved_book/constants';
|
||||
import { registerCreateSavedBookAction } from './react_embeddables/saved_book/create_saved_book_action';
|
||||
import { registerAddSearchPanelAction } from './react_embeddables/search/register_add_search_panel_action';
|
||||
import { registerSearchEmbeddable } from './react_embeddables/search/register_search_embeddable';
|
||||
|
||||
|
@ -73,6 +75,14 @@ export class EmbeddableExamplesPlugin implements Plugin<void, void, SetupDeps, S
|
|||
return getDataTableFactory(coreStart, deps);
|
||||
});
|
||||
|
||||
embeddable.registerReactEmbeddableFactory(SAVED_BOOK_ID, async () => {
|
||||
const { getSavedBookEmbeddableFactory } = await import(
|
||||
'./react_embeddables/saved_book/saved_book_react_embeddable'
|
||||
);
|
||||
const [coreStart] = await startServicesPromise;
|
||||
return getSavedBookEmbeddableFactory(coreStart);
|
||||
});
|
||||
|
||||
registerSearchEmbeddable(
|
||||
embeddable,
|
||||
new Promise((resolve) => startServicesPromise.then(([_, startDeps]) => resolve(startDeps)))
|
||||
|
@ -88,6 +98,8 @@ export class EmbeddableExamplesPlugin implements Plugin<void, void, SetupDeps, S
|
|||
registerAddSearchPanelAction(deps.uiActions);
|
||||
|
||||
registerCreateDataTableAction(deps.uiActions);
|
||||
|
||||
registerCreateSavedBookAction(deps.uiActions, core);
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BookAttributes, BookAttributesManager } from './types';
|
||||
|
||||
export const defaultBookAttributes: 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,
|
||||
});
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export const SAVED_BOOK_ID = 'book';
|
||||
export const ADD_SAVED_BOOK_ACTION_ID = 'create_saved_book';
|
|
@ -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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
|
||||
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
|
||||
import { 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 { ADD_SAVED_BOOK_ACTION_ID, SAVED_BOOK_ID } from './constants';
|
||||
import { openSavedBookEditor } from './saved_book_editor';
|
||||
import { saveBookAttributes } from './saved_book_library';
|
||||
import {
|
||||
BookByReferenceSerializedState,
|
||||
BookByValueSerializedState,
|
||||
BookSerializedState,
|
||||
} from './types';
|
||||
|
||||
export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, core: CoreStart) => {
|
||||
uiActions.registerAction<EmbeddableApiContext>({
|
||||
id: ADD_SAVED_BOOK_ACTION_ID,
|
||||
getIconType: () => 'folderClosed',
|
||||
grouping: [embeddableExamplesGrouping],
|
||||
isCompatible: async ({ embeddable }) => {
|
||||
return apiIsPresentationContainer(embeddable);
|
||||
},
|
||||
execute: async ({ embeddable }) => {
|
||||
if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
|
||||
const newPanelStateManager = stateManagerFromAttributes(defaultBookAttributes);
|
||||
|
||||
const { addToLibrary } = await openSavedBookEditor(newPanelStateManager, true, core, {
|
||||
parentApi: embeddable,
|
||||
});
|
||||
|
||||
const initialState: BookSerializedState = await (async () => {
|
||||
// if we're adding this to the library, we only need to return the by reference state.
|
||||
if (addToLibrary) {
|
||||
const savedBookId = await saveBookAttributes(
|
||||
undefined,
|
||||
serializeBookAttributes(newPanelStateManager)
|
||||
);
|
||||
return { savedBookId } as BookByReferenceSerializedState;
|
||||
}
|
||||
return {
|
||||
attributes: serializeBookAttributes(newPanelStateManager),
|
||||
} as BookByValueSerializedState;
|
||||
})();
|
||||
|
||||
embeddable.addNewPanel<BookSerializedState>({
|
||||
panelType: SAVED_BOOK_ID,
|
||||
initialState,
|
||||
});
|
||||
},
|
||||
getDisplayName: () =>
|
||||
i18n.translate('embeddableExamples.savedbook.addBookAction.displayName', {
|
||||
defaultMessage: 'Book',
|
||||
}),
|
||||
});
|
||||
uiActions.attachAction('ADD_PANEL_TRIGGER', ADD_SAVED_BOOK_ACTION_ID);
|
||||
};
|
|
@ -0,0 +1,212 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFieldNumber,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiFormControlLayout,
|
||||
EuiFormRow,
|
||||
EuiSwitch,
|
||||
EuiTextArea,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
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 {
|
||||
apiHasParentApi,
|
||||
apiHasUniqueId,
|
||||
useBatchedOptionalPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import React from 'react';
|
||||
import { serializeBookAttributes } from './book_state';
|
||||
import { BookAttributesManager } from './types';
|
||||
|
||||
export const openSavedBookEditor = (
|
||||
attributesManager: BookAttributesManager,
|
||||
isCreate: boolean,
|
||||
core: CoreStart,
|
||||
api: unknown
|
||||
): Promise<{ addToLibrary: boolean }> => {
|
||||
return new Promise((resolve) => {
|
||||
const closeOverlay = (overlayRef: OverlayRef) => {
|
||||
if (apiHasParentApi(api) && tracksOverlays(api.parentApi)) {
|
||||
api.parentApi.clearOverlays();
|
||||
}
|
||||
overlayRef.close();
|
||||
};
|
||||
|
||||
const initialState = serializeBookAttributes(attributesManager);
|
||||
const overlay = core.overlays.openFlyout(
|
||||
toMountPoint(
|
||||
<SavedBookEditor
|
||||
attributesManager={attributesManager}
|
||||
isCreate={isCreate}
|
||||
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);
|
||||
closeOverlay(overlay);
|
||||
}}
|
||||
onSubmit={(addToLibrary: boolean) => {
|
||||
closeOverlay(overlay);
|
||||
resolve({ addToLibrary });
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
theme: core.theme,
|
||||
i18n: core.i18n,
|
||||
}
|
||||
),
|
||||
{
|
||||
type: isCreate ? 'overlay' : 'push',
|
||||
size: isCreate ? 'm' : 's',
|
||||
onClose: () => closeOverlay(overlay),
|
||||
}
|
||||
);
|
||||
|
||||
const overlayOptions = !isCreate && apiHasUniqueId(api) ? { focusedPanelId: api.uuid } : {};
|
||||
/**
|
||||
* if our parent needs to know about the overlay, notify it. This allows the parent to close the overlay
|
||||
* when navigating away, or change certain behaviors based on the overlay being open.
|
||||
*/
|
||||
if (apiHasParentApi(api) && tracksOverlays(api.parentApi)) {
|
||||
api.parentApi.openOverlay(overlay, overlayOptions);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const SavedBookEditor = ({
|
||||
attributesManager,
|
||||
isCreate,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
attributesManager: BookAttributesManager;
|
||||
isCreate: boolean;
|
||||
onSubmit: (addToLibrary: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
const [addToLibrary, setAddToLibrary] = React.useState(false);
|
||||
const [authorName, synopsis, bookTitle, numberOfPages] = useBatchedOptionalPublishingSubjects(
|
||||
attributesManager.authorName,
|
||||
attributesManager.bookSynopsis,
|
||||
attributesManager.bookTitle,
|
||||
attributesManager.numberOfPages
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>
|
||||
{isCreate
|
||||
? i18n.translate('embeddableExamples.savedBook.editor.newTitle', {
|
||||
defaultMessage: 'Create new book',
|
||||
})
|
||||
: i18n.translate('embeddableExamples.savedBook.editor.editTitle', {
|
||||
defaultMessage: 'Edit book',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<EuiFormControlLayout>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('embeddableExamples.savedBook.editor.authorLabel', {
|
||||
defaultMessage: 'Author',
|
||||
})}
|
||||
>
|
||||
<EuiFieldText
|
||||
value={authorName}
|
||||
onChange={(e) => attributesManager.authorName.next(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('embeddableExamples.savedBook.editor.titleLabel', {
|
||||
defaultMessage: 'Title',
|
||||
})}
|
||||
>
|
||||
<EuiFieldText
|
||||
value={bookTitle}
|
||||
onChange={(e) => attributesManager.bookTitle.next(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('embeddableExamples.savedBook.editor.pagesLabel', {
|
||||
defaultMessage: 'Number of pages',
|
||||
})}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
value={numberOfPages}
|
||||
onChange={(e) => attributesManager.numberOfPages.next(+e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('embeddableExamples.savedBook.editor.synopsisLabel', {
|
||||
defaultMessage: 'Synopsis',
|
||||
})}
|
||||
>
|
||||
<EuiTextArea
|
||||
value={synopsis}
|
||||
onChange={(e) => attributesManager.bookSynopsis.next(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFormControlLayout>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty iconType="cross" onClick={onCancel} flush="left">
|
||||
{i18n.translate('embeddableExamples.savedBook.editor.cancel', {
|
||||
defaultMessage: 'Discard changes',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false}>
|
||||
{isCreate && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSwitch
|
||||
label={i18n.translate('embeddableExamples.savedBook.editor.addToLibrary', {
|
||||
defaultMessage: 'Save to library',
|
||||
})}
|
||||
checked={addToLibrary}
|
||||
onChange={() => setAddToLibrary(!addToLibrary)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton onClick={() => onSubmit(addToLibrary)} fill>
|
||||
{isCreate
|
||||
? i18n.translate('embeddableExamples.savedBook.editor.create', {
|
||||
defaultMessage: 'Create book',
|
||||
})
|
||||
: i18n.translate('embeddableExamples.savedBook.editor.save', {
|
||||
defaultMessage: 'Keep changes',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { v4 } from 'uuid';
|
||||
import { BookAttributes } from './types';
|
||||
|
||||
const storage = new Storage(localStorage);
|
||||
|
||||
export const loadBookAttributes = async (id: string): Promise<BookAttributes> => {
|
||||
await new Promise((r) => setTimeout(r, 500)); // simulate load from network.
|
||||
const attributes = storage.get(id) as BookAttributes;
|
||||
return attributes;
|
||||
};
|
||||
|
||||
export const saveBookAttributes = async (
|
||||
maybeId?: string,
|
||||
attributes?: BookAttributes
|
||||
): Promise<string> => {
|
||||
await new Promise((r) => setTimeout(r, 100)); // simulate save to network.
|
||||
const id = maybeId ?? v4();
|
||||
storage.set(id, attributes);
|
||||
return id;
|
||||
};
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiBadge, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
initializeTitles,
|
||||
SerializedTitles,
|
||||
useBatchedPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { serializeBookAttributes, stateManagerFromAttributes } from './book_state';
|
||||
import { SAVED_BOOK_ID } from './constants';
|
||||
import { openSavedBookEditor } from './saved_book_editor';
|
||||
import { loadBookAttributes, saveBookAttributes } from './saved_book_library';
|
||||
import {
|
||||
BookApi,
|
||||
BookAttributes,
|
||||
BookByReferenceSerializedState,
|
||||
BookByValueSerializedState,
|
||||
BookRuntimeState,
|
||||
BookSerializedState,
|
||||
} from './types';
|
||||
|
||||
const bookSerializedStateIsByReference = (
|
||||
state?: BookSerializedState
|
||||
): state is BookByReferenceSerializedState => {
|
||||
return Boolean(state && (state as BookByReferenceSerializedState).savedBookId !== undefined);
|
||||
};
|
||||
|
||||
export const getSavedBookEmbeddableFactory = (core: CoreStart) => {
|
||||
const savedBookEmbeddableFactory: ReactEmbeddableFactory<
|
||||
BookSerializedState,
|
||||
BookApi,
|
||||
BookRuntimeState
|
||||
> = {
|
||||
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 { titlesApi, titleComparators, serializeTitles } = initializeTitles(state);
|
||||
const bookAttributesManager = stateManagerFromAttributes(state);
|
||||
const savedBookId$ = new BehaviorSubject(state.savedBookId);
|
||||
|
||||
const api = buildApi(
|
||||
{
|
||||
...titlesApi,
|
||||
onEdit: async () => {
|
||||
openSavedBookEditor(bookAttributesManager, false, core, api);
|
||||
},
|
||||
isEditingEnabled: () => true,
|
||||
getTypeDisplayName: () =>
|
||||
i18n.translate('embeddableExamples.savedbook.editBook.displayName', {
|
||||
defaultMessage: 'book',
|
||||
}),
|
||||
serializeState: async () => {
|
||||
if (savedBookId$.value === undefined) {
|
||||
// if this book is currently by value, we serialize the entire state.
|
||||
const bookByValueState: BookByValueSerializedState = {
|
||||
attributes: serializeBookAttributes(bookAttributesManager),
|
||||
...serializeTitles(),
|
||||
};
|
||||
return { rawState: bookByValueState };
|
||||
}
|
||||
|
||||
// if this book is currently by reference, we serialize the reference and write to the external store.
|
||||
const bookByReferenceState: BookByReferenceSerializedState = {
|
||||
savedBookId: savedBookId$.value,
|
||||
...serializeTitles(),
|
||||
};
|
||||
|
||||
await saveBookAttributes(
|
||||
savedBookId$.value,
|
||||
serializeBookAttributes(bookAttributesManager)
|
||||
);
|
||||
return { rawState: bookByReferenceState };
|
||||
},
|
||||
|
||||
// in place library transforms
|
||||
libraryId$: savedBookId$,
|
||||
saveToLibrary: async (newTitle: string) => {
|
||||
bookAttributesManager.bookTitle.next(newTitle);
|
||||
const newId = await saveBookAttributes(
|
||||
undefined,
|
||||
serializeBookAttributes(bookAttributesManager)
|
||||
);
|
||||
savedBookId$.next(newId);
|
||||
return newId;
|
||||
},
|
||||
checkForDuplicateTitle: async (title) => {},
|
||||
unlinkFromLibrary: () => {
|
||||
savedBookId$.next(undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
savedBookId: [savedBookId$, (val) => savedBookId$.next(val)],
|
||||
...bookAttributesManager.comparators,
|
||||
...titleComparators,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
const [authorName, numberOfPages, savedBookId, bookTitle, synopsis] =
|
||||
useBatchedPublishingSubjects(
|
||||
bookAttributesManager.authorName,
|
||||
bookAttributesManager.numberOfPages,
|
||||
savedBookId$,
|
||||
bookAttributesManager.bookTitle,
|
||||
bookAttributesManager.bookSynopsis
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
width: 100%;
|
||||
`}
|
||||
>
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
color={'warning'}
|
||||
title={
|
||||
savedBookId
|
||||
? i18n.translate('embeddableExamples.savedBook.libraryCallout', {
|
||||
defaultMessage: 'Saved in library',
|
||||
})
|
||||
: i18n.translate('embeddableExamples.savedBook.noLibraryCallout', {
|
||||
defaultMessage: 'Not saved in library',
|
||||
})
|
||||
}
|
||||
iconType={savedBookId ? 'folderCheck' : 'folderClosed'}
|
||||
/>
|
||||
<div
|
||||
css={css`
|
||||
padding: ${euiThemeVars.euiSizeM};
|
||||
`}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
direction="column"
|
||||
justifyContent="flexStart"
|
||||
alignItems="stretch"
|
||||
gutterSize="xs"
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="m">
|
||||
<EuiText>{bookTitle}</EuiText>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup wrap responsive={false} gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge iconType="userAvatar" color="hollow">
|
||||
{authorName}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge iconType="copy" color="hollow">
|
||||
{i18n.translate('embeddableExamples.savedBook.numberOfPages', {
|
||||
defaultMessage: '{numberOfPages} pages',
|
||||
values: { numberOfPages },
|
||||
})}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText>{synopsis}</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return savedBookEmbeddableFactory;
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
HasEditCapabilities,
|
||||
HasInPlaceLibraryTransforms,
|
||||
SerializedTitles,
|
||||
StateComparators,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export interface BookAttributes {
|
||||
bookTitle: string;
|
||||
authorName: string;
|
||||
numberOfPages: number;
|
||||
bookSynopsis?: string;
|
||||
}
|
||||
|
||||
export type BookAttributesManager = {
|
||||
[key in keyof Required<BookAttributes>]: BehaviorSubject<BookAttributes[key]>;
|
||||
} & { comparators: StateComparators<BookAttributes> };
|
||||
|
||||
export interface BookByValueSerializedState {
|
||||
attributes: BookAttributes;
|
||||
}
|
||||
|
||||
export interface BookByReferenceSerializedState {
|
||||
savedBookId: string;
|
||||
}
|
||||
|
||||
export type BookSerializedState = SerializedTitles &
|
||||
(BookByValueSerializedState | BookByReferenceSerializedState);
|
||||
|
||||
/**
|
||||
* Book runtime state is a flattened version of all possible state keys.
|
||||
*/
|
||||
export interface BookRuntimeState
|
||||
extends BookAttributes,
|
||||
Partial<BookByReferenceSerializedState>,
|
||||
SerializedTitles {}
|
||||
|
||||
export type BookApi = DefaultEmbeddableApi<BookSerializedState> &
|
||||
HasEditCapabilities &
|
||||
HasInPlaceLibraryTransforms;
|
|
@ -31,6 +31,7 @@ export function SearchEmbeddableRenderer(props: Props) {
|
|||
const parentApi = useMemo(() => {
|
||||
return {
|
||||
timeRange$: new BehaviorSubject(props.timeRange),
|
||||
getSerializedStateForChild: () => initialState,
|
||||
};
|
||||
// only run onMount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
@ -43,8 +44,7 @@ export function SearchEmbeddableRenderer(props: Props) {
|
|||
return (
|
||||
<ReactEmbeddableRenderer<SearchSerializedState, SearchApi>
|
||||
type={SEARCH_EMBEDDABLE_ID}
|
||||
state={initialState}
|
||||
parentApi={parentApi}
|
||||
getParentApi={() => parentApi}
|
||||
hidePanelChrome={true}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -38,6 +38,8 @@
|
|||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/react-kibana-context-render",
|
||||
"@kbn/unified-data-table",
|
||||
"@kbn/kibana-utils-plugin"
|
||||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/core-mount-utils-browser",
|
||||
"@kbn/react-kibana-mount"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -8,10 +8,15 @@
|
|||
|
||||
export { apiCanAddNewPanel, type CanAddNewPanel } from './interfaces/can_add_new_panel';
|
||||
export {
|
||||
apiPublishesLastSavedState,
|
||||
getLastSavedStateSubjectForChild,
|
||||
type PublishesLastSavedState,
|
||||
} from './interfaces/last_saved_state';
|
||||
apiHasRuntimeChildState,
|
||||
apiHasSerializedChildState,
|
||||
type HasRuntimeChildState,
|
||||
type HasSerializedChildState,
|
||||
} from './interfaces/child_state';
|
||||
export {
|
||||
apiHasSaveNotification,
|
||||
type HasSaveNotification,
|
||||
} from './interfaces/has_save_notification';
|
||||
export {
|
||||
apiCanDuplicatePanels,
|
||||
apiCanExpandPanels,
|
||||
|
@ -25,13 +30,14 @@ export {
|
|||
type PanelPackage,
|
||||
type PresentationContainer,
|
||||
} from './interfaces/presentation_container';
|
||||
export {
|
||||
canTrackContentfulRender,
|
||||
type TrackContentfulRender,
|
||||
} from './interfaces/track_contentful_render';
|
||||
export {
|
||||
apiHasSerializableState,
|
||||
type HasSerializableState,
|
||||
type HasSnapshottableState,
|
||||
type SerializedPanelState,
|
||||
} from './interfaces/serialized_state';
|
||||
export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays';
|
||||
export {
|
||||
canTrackContentfulRender,
|
||||
type TrackContentfulRender,
|
||||
} from './interfaces/track_contentful_render';
|
||||
|
|
|
@ -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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { SerializedPanelState } from './serialized_state';
|
||||
|
||||
export interface HasSerializedChildState<SerializedState extends object = object> {
|
||||
getSerializedStateForChild: (childId: string) => SerializedPanelState<SerializedState>;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const apiHasRuntimeChildState = <RuntimeState extends object = object>(
|
||||
api: unknown
|
||||
): api is HasRuntimeChildState<RuntimeState> => {
|
||||
return Boolean(api && (api as HasRuntimeChildState).getRuntimeStateForChild);
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { 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$);
|
||||
};
|
|
@ -1,56 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PublishingSubject } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { filter, map } from 'rxjs';
|
||||
import { SerializedPanelState } from './serialized_state';
|
||||
|
||||
export interface PublishesLastSavedState {
|
||||
lastSavedState: Subject<void>; // a notification that the last saved state has changed
|
||||
getLastSavedStateForChild: <SerializedState extends object = object>(
|
||||
childId: string
|
||||
) => SerializedPanelState<SerializedState> | undefined;
|
||||
}
|
||||
|
||||
export const apiPublishesLastSavedState = (api: unknown): api is PublishesLastSavedState => {
|
||||
return Boolean(
|
||||
api &&
|
||||
(api as PublishesLastSavedState).lastSavedState &&
|
||||
(api as PublishesLastSavedState).getLastSavedStateForChild
|
||||
);
|
||||
};
|
||||
|
||||
export const getLastSavedStateSubjectForChild = <
|
||||
SerializedState extends object = object,
|
||||
RuntimeState extends object = object
|
||||
>(
|
||||
parentApi: unknown,
|
||||
childId: string,
|
||||
deserializer: (state: SerializedPanelState<SerializedState>) => RuntimeState
|
||||
): PublishingSubject<RuntimeState | undefined> | undefined => {
|
||||
if (!parentApi) return;
|
||||
const fetchLastSavedState = (): RuntimeState | undefined => {
|
||||
if (!apiPublishesLastSavedState(parentApi)) return;
|
||||
const rawLastSavedState = parentApi.getLastSavedStateForChild<SerializedState>(childId);
|
||||
if (rawLastSavedState === undefined) return;
|
||||
return deserializer(rawLastSavedState);
|
||||
};
|
||||
|
||||
const lastSavedStateForChild = new BehaviorSubject<RuntimeState | undefined>(
|
||||
fetchLastSavedState()
|
||||
);
|
||||
if (!apiPublishesLastSavedState(parentApi)) return;
|
||||
parentApi.lastSavedState
|
||||
.pipe(
|
||||
map(() => fetchLastSavedState()),
|
||||
filter((rawLastSavedState) => rawLastSavedState !== undefined)
|
||||
)
|
||||
.subscribe(lastSavedStateForChild);
|
||||
return lastSavedStateForChild;
|
||||
};
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { Reference } from '@kbn/content-management-utils';
|
||||
import { MaybePromise } from '@kbn/utility-types';
|
||||
|
||||
/**
|
||||
* A package containing the serialized Embeddable state, with references extracted. When saving Embeddables using any
|
||||
|
@ -17,10 +18,22 @@ export interface SerializedPanelState<RawStateType extends object = object> {
|
|||
rawState: RawStateType;
|
||||
}
|
||||
|
||||
export interface HasSerializableState<StateType extends object = object> {
|
||||
serializeState: () => SerializedPanelState<StateType>;
|
||||
export interface HasSerializableState<State extends object = object> {
|
||||
/**
|
||||
* Serializes all state into a format that can be saved into
|
||||
* some external store. The opposite of `deserialize` in the {@link ReactEmbeddableFactory}
|
||||
*/
|
||||
serializeState: () => MaybePromise<SerializedPanelState<State>>;
|
||||
}
|
||||
|
||||
export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => {
|
||||
return Boolean((api as HasSerializableState)?.serializeState);
|
||||
};
|
||||
|
||||
export interface HasSnapshottableState<RuntimeState extends object = object> {
|
||||
/**
|
||||
* Serializes all runtime state exactly as it appears. This could be used
|
||||
* to rehydrate a component's state without needing to deserialize it.
|
||||
*/
|
||||
snapshotRuntimeState: () => RuntimeState;
|
||||
}
|
||||
|
|
|
@ -10,5 +10,6 @@
|
|||
"@kbn/presentation-publishing",
|
||||
"@kbn/core-mount-utils-browser",
|
||||
"@kbn/content-management-utils",
|
||||
"@kbn/utility-types",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -29,11 +29,11 @@ export {
|
|||
useInheritedViewMode,
|
||||
type CanAccessViewMode,
|
||||
} from './interfaces/can_access_view_mode';
|
||||
export { fetch$, type FetchContext } from './interfaces/fetch/fetch';
|
||||
export {
|
||||
initializeTimeRange,
|
||||
type SerializedTimeRange,
|
||||
} from './interfaces/fetch/initialize_time_range';
|
||||
export { fetch$, type FetchContext } from './interfaces/fetch/fetch';
|
||||
export {
|
||||
apiPublishesPartialUnifiedSearch,
|
||||
apiPublishesTimeRange,
|
||||
|
@ -50,9 +50,15 @@ export {
|
|||
} from './interfaces/has_app_context';
|
||||
export { apiHasDisableTriggers, type HasDisableTriggers } from './interfaces/has_disable_triggers';
|
||||
export { hasEditCapabilities, type HasEditCapabilities } from './interfaces/has_edit_capabilities';
|
||||
export {
|
||||
apiHasExecutionContext,
|
||||
type HasExecutionContext,
|
||||
} from './interfaces/has_execution_context';
|
||||
export {
|
||||
apiHasLegacyLibraryTransforms,
|
||||
apiHasLibraryTransforms,
|
||||
apiHasInPlaceLibraryTransforms,
|
||||
type HasInPlaceLibraryTransforms,
|
||||
type HasLegacyLibraryTransforms,
|
||||
type HasLibraryTransforms,
|
||||
} from './interfaces/has_library_transforms';
|
||||
|
@ -68,10 +74,6 @@ export {
|
|||
type HasTypeDisplayName,
|
||||
} from './interfaces/has_type';
|
||||
export { apiHasUniqueId, type HasUniqueId } from './interfaces/has_uuid';
|
||||
export {
|
||||
apiHasExecutionContext,
|
||||
type HasExecutionContext,
|
||||
} from './interfaces/has_execution_context';
|
||||
export {
|
||||
apiPublishesBlockingError,
|
||||
type PublishesBlockingError,
|
||||
|
|
|
@ -6,16 +6,75 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export interface HasLibraryTransforms<StateT extends object = object> {
|
||||
//
|
||||
// Add to library methods
|
||||
//
|
||||
import { PublishingSubject } from '../publishing_subject';
|
||||
|
||||
interface DuplicateTitleCheck {
|
||||
checkForDuplicateTitle: (
|
||||
newTitle: string,
|
||||
isTitleDuplicateConfirmed: boolean,
|
||||
onTitleDuplicate: () => void
|
||||
) => Promise<void>;
|
||||
}
|
||||
interface LibraryTransformGuards {
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
* True when embeddable is by-value and can be converted to by-reference
|
||||
*/
|
||||
canLinkToLibrary: () => Promise<boolean>;
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
* True when embeddable is by-reference and can be converted to by-value
|
||||
*/
|
||||
canUnlinkFromLibrary: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* APIs that inherit this interface can be linked to and unlinked from the library in place without
|
||||
* re-initialization.
|
||||
*/
|
||||
export interface HasInPlaceLibraryTransforms
|
||||
extends Partial<LibraryTransformGuards>,
|
||||
DuplicateTitleCheck {
|
||||
/**
|
||||
* The id of the library item that this embeddable is linked to.
|
||||
*/
|
||||
libraryId$: PublishingSubject<string | undefined>;
|
||||
|
||||
/**
|
||||
* Save embeddable to library
|
||||
*
|
||||
* @returns {Promise<string>} id of persisted library item
|
||||
*/
|
||||
saveToLibrary: (title: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Un-links this embeddable from the library. This method is optional, and only needed if the Embeddable
|
||||
* is not meant to be re-initialized as part of the unlink operation. If the embeddable needs to be re-initialized
|
||||
* after unlinking, the getByValueState method should be used instead.
|
||||
*/
|
||||
unlinkFromLibrary: () => void;
|
||||
}
|
||||
|
||||
export const apiHasInPlaceLibraryTransforms = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is HasInPlaceLibraryTransforms => {
|
||||
return Boolean(
|
||||
unknownApi &&
|
||||
Boolean((unknownApi as HasInPlaceLibraryTransforms)?.libraryId$) &&
|
||||
typeof (unknownApi as HasInPlaceLibraryTransforms).saveToLibrary === 'function' &&
|
||||
typeof (unknownApi as HasInPlaceLibraryTransforms).unlinkFromLibrary === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* APIs that inherit this interface can be linked to and unlinked from the library. After the save or unlink
|
||||
* operation, the embeddable will be reinitialized.
|
||||
*/
|
||||
export interface HasLibraryTransforms<StateT extends object = object>
|
||||
extends LibraryTransformGuards,
|
||||
DuplicateTitleCheck {
|
||||
/**
|
||||
* Save embeddable to library
|
||||
*
|
||||
|
@ -25,28 +84,15 @@ export interface HasLibraryTransforms<StateT extends object = object> {
|
|||
/**
|
||||
*
|
||||
* @returns {StateT}
|
||||
* by-reference embeddable state replacing by-value embeddable state
|
||||
* by-reference embeddable state replacing by-value embeddable state. After
|
||||
* the save operation, the embeddable will be reinitialized with the results of this method.
|
||||
*/
|
||||
getByReferenceState: (libraryId: string) => StateT;
|
||||
checkForDuplicateTitle: (
|
||||
newTitle: string,
|
||||
isTitleDuplicateConfirmed: boolean,
|
||||
onTitleDuplicate: () => void
|
||||
) => Promise<void>;
|
||||
|
||||
//
|
||||
// Unlink from library methods
|
||||
//
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
* True when embeddable is by-reference and can be converted to by-value
|
||||
*/
|
||||
canUnlinkFromLibrary: () => Promise<boolean>;
|
||||
/**
|
||||
*
|
||||
* @returns {StateT}
|
||||
* by-value embeddable state replacing by-reference embeddable state
|
||||
* by-value embeddable state replacing by-reference embeddable state. After
|
||||
* the unlink operation, the embeddable will be reinitialized with the results of this method.
|
||||
*/
|
||||
getByValueState: () => StateT;
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ import {
|
|||
HasParentApi,
|
||||
apiHasUniqueId,
|
||||
apiHasParentApi,
|
||||
HasInPlaceLibraryTransforms,
|
||||
apiHasInPlaceLibraryTransforms,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import {
|
||||
OnSaveProps,
|
||||
|
@ -40,14 +42,14 @@ export const ACTION_ADD_TO_LIBRARY = 'saveToLibrary';
|
|||
export type AddPanelToLibraryActionApi = CanAccessViewMode &
|
||||
HasType &
|
||||
HasUniqueId &
|
||||
HasLibraryTransforms &
|
||||
(HasLibraryTransforms | HasInPlaceLibraryTransforms) &
|
||||
HasParentApi<Pick<PresentationContainer, 'replacePanel'>> &
|
||||
Partial<PublishesPanelTitle & HasTypeDisplayName>;
|
||||
|
||||
const isApiCompatible = (api: unknown | null): api is AddPanelToLibraryActionApi =>
|
||||
Boolean(
|
||||
apiCanAccessViewMode(api) &&
|
||||
apiHasLibraryTransforms(api) &&
|
||||
(apiHasLibraryTransforms(api) || apiHasInPlaceLibraryTransforms(api)) &&
|
||||
apiHasType(api) &&
|
||||
apiHasUniqueId(api) &&
|
||||
apiHasParentApi(api) &&
|
||||
|
@ -79,7 +81,7 @@ export class AddToLibraryAction implements Action<EmbeddableApiContext> {
|
|||
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) return false;
|
||||
return getInheritedViewMode(embeddable) === 'edit' && (await embeddable.canLinkToLibrary());
|
||||
return getInheritedViewMode(embeddable) === 'edit' && (await this.canLinkToLibrary(embeddable));
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
|
@ -87,7 +89,7 @@ export class AddToLibraryAction implements Action<EmbeddableApiContext> {
|
|||
const title = getPanelTitle(embeddable);
|
||||
|
||||
try {
|
||||
const byRefState = await new Promise<object>((resolve, reject) => {
|
||||
const byRefState = await new Promise<object | undefined>((resolve, reject) => {
|
||||
const onSave = async (props: OnSaveProps): Promise<SaveResult> => {
|
||||
await embeddable.checkForDuplicateTitle(
|
||||
props.newTitle,
|
||||
|
@ -96,7 +98,10 @@ export class AddToLibraryAction implements Action<EmbeddableApiContext> {
|
|||
);
|
||||
try {
|
||||
const libraryId = await embeddable.saveToLibrary(props.newTitle);
|
||||
resolve({ ...embeddable.getByReferenceState(libraryId), title: props.newTitle });
|
||||
if (apiHasLibraryTransforms(embeddable)) {
|
||||
resolve({ ...embeddable.getByReferenceState(libraryId), title: props.newTitle });
|
||||
}
|
||||
resolve(undefined);
|
||||
return { id: libraryId };
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
|
@ -118,10 +123,16 @@ export class AddToLibraryAction implements Action<EmbeddableApiContext> {
|
|||
/>
|
||||
);
|
||||
});
|
||||
await embeddable.parentApi.replacePanel(embeddable.uuid, {
|
||||
panelType: embeddable.type,
|
||||
initialState: byRefState,
|
||||
});
|
||||
/**
|
||||
* If byRefState is defined, this embeddable type must be re-initialized with the
|
||||
* newly provided state.
|
||||
*/
|
||||
if (byRefState) {
|
||||
await embeddable.parentApi.replacePanel(embeddable.uuid, {
|
||||
panelType: embeddable.type,
|
||||
initialState: byRefState,
|
||||
});
|
||||
}
|
||||
this.toastsService.addSuccess({
|
||||
title: dashboardAddToLibraryActionStrings.getSuccessMessage(title ? `'${title}'` : ''),
|
||||
'data-test-subj': 'addPanelToLibrarySuccess',
|
||||
|
@ -133,4 +144,14 @@ export class AddToLibraryAction implements Action<EmbeddableApiContext> {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async canLinkToLibrary(api: AddPanelToLibraryActionApi) {
|
||||
if (apiHasLibraryTransforms(api)) {
|
||||
return api.canLinkToLibrary?.();
|
||||
} else if (apiHasInPlaceLibraryTransforms(api)) {
|
||||
const canLink = api.canLinkToLibrary ? await api.canLinkToLibrary() : true;
|
||||
return api.libraryId$.value === undefined && canLink;
|
||||
}
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,14 +9,16 @@
|
|||
import React from 'react';
|
||||
|
||||
import {
|
||||
apiHasInPlaceLibraryTransforms,
|
||||
EmbeddableApiContext,
|
||||
getInheritedViewMode,
|
||||
getViewModeSubject,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
import { BehaviorSubject, combineLatest } from 'rxjs';
|
||||
import { LibraryNotificationPopover } from './library_notification_popover';
|
||||
import { dashboardLibraryNotificationStrings } from './_dashboard_actions_strings';
|
||||
import { isApiCompatible, UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
import { dashboardLibraryNotificationStrings } from './_dashboard_actions_strings';
|
||||
|
||||
export const ACTION_LIBRARY_NOTIFICATION = 'ACTION_LIBRARY_NOTIFICATION';
|
||||
|
||||
|
@ -37,22 +39,27 @@ export class LibraryNotificationAction implements Action<EmbeddableApiContext> {
|
|||
return isApiCompatible(embeddable);
|
||||
}
|
||||
|
||||
public subscribeToCompatibilityChanges(
|
||||
public subscribeToCompatibilityChanges = (
|
||||
{ embeddable }: EmbeddableApiContext,
|
||||
onChange: (isCompatible: boolean, action: LibraryNotificationAction) => void
|
||||
) {
|
||||
) => {
|
||||
if (!isApiCompatible(embeddable)) return;
|
||||
const libraryIdSubject = apiHasInPlaceLibraryTransforms(embeddable)
|
||||
? embeddable.libraryId$
|
||||
: new BehaviorSubject<string | undefined>(undefined);
|
||||
const viewModeSubject = getViewModeSubject(embeddable);
|
||||
if (!viewModeSubject) throw new IncompatibleActionError();
|
||||
|
||||
/**
|
||||
* TODO: Upgrade this action by subscribing to changes in the existance of a saved object id. Currently,
|
||||
* this is unnecessary because a link or unlink operation will cause the panel to unmount and remount.
|
||||
*/
|
||||
return getViewModeSubject(embeddable)?.subscribe((viewMode) => {
|
||||
embeddable.canUnlinkFromLibrary().then((canUnlink) => {
|
||||
return combineLatest([libraryIdSubject, viewModeSubject]).subscribe(([libraryId, viewMode]) => {
|
||||
this.unlinkAction.canUnlinkFromLibrary(embeddable).then((canUnlink) => {
|
||||
onChange(viewMode === 'edit' && canUnlink, this);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
|
@ -66,7 +73,10 @@ export class LibraryNotificationAction implements Action<EmbeddableApiContext> {
|
|||
|
||||
public isCompatible = async ({ embeddable }: EmbeddableApiContext) => {
|
||||
if (!isApiCompatible(embeddable)) return false;
|
||||
return getInheritedViewMode(embeddable) === 'edit' && embeddable.canUnlinkFromLibrary();
|
||||
return (
|
||||
getInheritedViewMode(embeddable) === 'edit' &&
|
||||
this.unlinkAction.canUnlinkFromLibrary(embeddable)
|
||||
);
|
||||
};
|
||||
|
||||
public execute = async () => {};
|
||||
|
|
|
@ -70,7 +70,10 @@ export function LibraryNotificationPopover({ unlinkAction, api }: LibraryNotific
|
|||
data-test-subj={'libraryNotificationUnlinkButton'}
|
||||
size="s"
|
||||
fill
|
||||
onClick={() => unlinkAction.execute({ embeddable: api })}
|
||||
onClick={() => {
|
||||
setIsPopoverOpen(false);
|
||||
unlinkAction.execute({ embeddable: api });
|
||||
}}
|
||||
>
|
||||
{unlinkAction.getDisplayName({ embeddable: api })}
|
||||
</EuiButton>
|
||||
|
|
|
@ -23,6 +23,8 @@ import {
|
|||
apiHasUniqueId,
|
||||
HasType,
|
||||
apiHasType,
|
||||
HasInPlaceLibraryTransforms,
|
||||
apiHasInPlaceLibraryTransforms,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { PresentationContainer } from '@kbn/presentation-containers';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
|
@ -31,7 +33,7 @@ import { dashboardUnlinkFromLibraryActionStrings } from './_dashboard_actions_st
|
|||
export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
|
||||
|
||||
export type UnlinkPanelFromLibraryActionApi = CanAccessViewMode &
|
||||
HasLibraryTransforms &
|
||||
(HasLibraryTransforms | HasInPlaceLibraryTransforms) &
|
||||
HasType &
|
||||
HasUniqueId &
|
||||
HasParentApi<Pick<PresentationContainer, 'replacePanel'>> &
|
||||
|
@ -40,7 +42,7 @@ export type UnlinkPanelFromLibraryActionApi = CanAccessViewMode &
|
|||
export const isApiCompatible = (api: unknown | null): api is UnlinkPanelFromLibraryActionApi =>
|
||||
Boolean(
|
||||
apiCanAccessViewMode(api) &&
|
||||
apiHasLibraryTransforms(api) &&
|
||||
(apiHasLibraryTransforms(api) || apiHasInPlaceLibraryTransforms(api)) &&
|
||||
apiHasUniqueId(api) &&
|
||||
apiHasType(api) &&
|
||||
apiHasParentApi(api) &&
|
||||
|
@ -70,19 +72,40 @@ export class UnlinkFromLibraryAction implements Action<EmbeddableApiContext> {
|
|||
return 'folderExclamation';
|
||||
}
|
||||
|
||||
public async canUnlinkFromLibrary(api: UnlinkPanelFromLibraryActionApi) {
|
||||
if (apiHasLibraryTransforms(api)) {
|
||||
return api.canUnlinkFromLibrary();
|
||||
} else if (apiHasInPlaceLibraryTransforms(api)) {
|
||||
const canUnLink = api.canUnlinkFromLibrary ? await api.canUnlinkFromLibrary() : true;
|
||||
return canUnLink && api.libraryId$.value !== undefined;
|
||||
}
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) return false;
|
||||
return getInheritedViewMode(embeddable) === 'edit' && (await embeddable.canUnlinkFromLibrary());
|
||||
if (!isApiCompatible(embeddable)) {
|
||||
// either a an `unlinkFromLibrary` method or a `getByValueState` method is required
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getInheritedViewMode(embeddable) === 'edit' && (await this.canUnlinkFromLibrary(embeddable))
|
||||
);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
const title = getPanelTitle(embeddable);
|
||||
try {
|
||||
await embeddable.parentApi.replacePanel(embeddable.uuid, {
|
||||
panelType: embeddable.type,
|
||||
initialState: { ...embeddable.getByValueState(), title },
|
||||
});
|
||||
if (apiHasLibraryTransforms(embeddable)) {
|
||||
await embeddable.parentApi.replacePanel(embeddable.uuid, {
|
||||
panelType: embeddable.type,
|
||||
initialState: { ...embeddable.getByValueState(), title },
|
||||
});
|
||||
} else if (apiHasInPlaceLibraryTransforms(embeddable)) {
|
||||
embeddable.unlinkFromLibrary();
|
||||
} else {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
this.toastsService.addSuccess({
|
||||
title: dashboardUnlinkFromLibraryActionStrings.getSuccessMessage(title ? `'${title}'` : ''),
|
||||
'data-test-subj': 'unlinkPanelSuccess',
|
||||
|
|
|
@ -70,7 +70,9 @@ describe('ShowShareModal', () => {
|
|||
const getPropsAndShare = (
|
||||
unsavedState?: Partial<DashboardContainerInput>
|
||||
): ShowShareModalProps => {
|
||||
pluginServices.getServices().dashboardBackup.getState = jest.fn().mockReturnValue(unsavedState);
|
||||
pluginServices.getServices().dashboardBackup.getState = jest
|
||||
.fn()
|
||||
.mockReturnValue({ dashboardState: unsavedState });
|
||||
return {
|
||||
isDirty: true,
|
||||
anchorElement: document.createElement('div'),
|
||||
|
|
|
@ -23,7 +23,7 @@ import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
|
|||
import { dashboardUrlParams } from '../../dashboard_router';
|
||||
import { shareModalStrings } from '../../_dashboard_app_strings';
|
||||
import { pluginServices } from '../../../services/plugin_services';
|
||||
import { convertPanelMapToSavedPanels } from '../../../../common';
|
||||
import { convertPanelMapToSavedPanels, DashboardPanelMap } from '../../../../common';
|
||||
import { DashboardLocatorParams } from '../../../dashboard_container';
|
||||
|
||||
const showFilterBarId = 'showFilterBar';
|
||||
|
@ -123,18 +123,23 @@ export function ShowShareModal({
|
|||
};
|
||||
|
||||
let unsavedStateForLocator: DashboardLocatorParams = {};
|
||||
const unsavedDashboardState = dashboardBackup.getState(savedObjectId);
|
||||
const { dashboardState: unsavedDashboardState, panels } =
|
||||
dashboardBackup.getState(savedObjectId) ?? {};
|
||||
|
||||
const allPanels: DashboardPanelMap = {
|
||||
...(unsavedDashboardState?.panels ?? {}),
|
||||
...((panels as DashboardPanelMap) ?? {}),
|
||||
};
|
||||
|
||||
if (unsavedDashboardState) {
|
||||
unsavedStateForLocator = {
|
||||
query: unsavedDashboardState.query,
|
||||
filters: unsavedDashboardState.filters,
|
||||
controlGroupInput: unsavedDashboardState.controlGroupInput as SerializableControlGroupInput,
|
||||
panels: unsavedDashboardState.panels
|
||||
? (convertPanelMapToSavedPanels(
|
||||
unsavedDashboardState.panels
|
||||
) as DashboardLocatorParams['panels'])
|
||||
: undefined,
|
||||
panels:
|
||||
allPanels && Object.keys(allPanels).length > 0
|
||||
? (convertPanelMapToSavedPanels(allPanels) as DashboardLocatorParams['panels'])
|
||||
: undefined,
|
||||
|
||||
// options
|
||||
useMargins: unsavedDashboardState?.useMargins,
|
||||
|
|
|
@ -13,7 +13,6 @@ import { PhaseEvent } from '@kbn/presentation-publishing';
|
|||
import classNames from 'classnames';
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { getReferencesForPanelId } from '../../../../common/dashboard_container/persistable_state/dashboard_container_references';
|
||||
import { pluginServices } from '../../../services/plugin_services';
|
||||
import { useDashboardContainer } from '../../embeddable/dashboard_container';
|
||||
|
||||
|
@ -52,7 +51,6 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
|
|||
const scrollToPanelId = container.select((state) => state.componentState.scrollToPanelId);
|
||||
const highlightPanelId = container.select((state) => state.componentState.highlightPanelId);
|
||||
const useMargins = container.select((state) => state.explicitInput.useMargins);
|
||||
const panel = container.select((state) => state.explicitInput.panels[id]);
|
||||
|
||||
const expandPanel = expandedPanelId !== undefined && expandedPanelId === id;
|
||||
const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id;
|
||||
|
@ -99,7 +97,6 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
|
|||
const {
|
||||
embeddable: { reactEmbeddableRegistryHasKey },
|
||||
} = pluginServices.getServices();
|
||||
const references = getReferencesForPanelId(id, container.savedObjectReferences);
|
||||
|
||||
const panelProps = {
|
||||
showBadges: true,
|
||||
|
@ -114,11 +111,10 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
|
|||
<ReactEmbeddableRenderer
|
||||
type={type}
|
||||
maybeId={id}
|
||||
parentApi={container}
|
||||
getParentApi={() => container}
|
||||
key={`${type}_${id}`}
|
||||
panelProps={panelProps}
|
||||
onApiAvailable={(api) => container.registerChildApi(api)}
|
||||
state={{ rawState: panel.explicitInput as object, references }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -132,7 +128,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
|
|||
{...panelProps}
|
||||
/>
|
||||
);
|
||||
}, [id, container, useMargins, type, index, onPanelStatusChange, panel.explicitInput]);
|
||||
}, [id, container, type, index, useMargins, onPanelStatusChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -10,7 +10,7 @@ import { Reference } from '@kbn/content-management-utils';
|
|||
import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
|
||||
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
|
||||
import { EmbeddableInput, isReferenceOrValueEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { apiHasSerializableState } from '@kbn/presentation-containers';
|
||||
import { apiHasSerializableState, SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { showSaveModal } from '@kbn/saved-objects-plugin/public';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React from 'react';
|
||||
|
@ -36,15 +36,30 @@ const serializeAllPanelState = async (
|
|||
} = pluginServices.getServices();
|
||||
const references: Reference[] = [];
|
||||
const panels = cloneDeep(dashboard.getInput().panels);
|
||||
|
||||
const serializePromises: Array<
|
||||
Promise<{ uuid: string; serialized: SerializedPanelState<object> }>
|
||||
> = [];
|
||||
for (const [uuid, panel] of Object.entries(panels)) {
|
||||
if (!reactEmbeddableRegistryHasKey(panel.type)) continue;
|
||||
const api = dashboard.children$.value[uuid];
|
||||
|
||||
if (api && apiHasSerializableState(api)) {
|
||||
const serializedState = api.serializeState();
|
||||
panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid };
|
||||
references.push(...prefixReferencesFromPanel(uuid, serializedState.references ?? []));
|
||||
serializePromises.push(
|
||||
(async () => {
|
||||
const serialized = await api.serializeState();
|
||||
return { uuid, serialized };
|
||||
})()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const serializeResults = await Promise.all(serializePromises);
|
||||
for (const result of serializeResults) {
|
||||
panels[result.uuid].explicitInput = { ...result.serialized.rawState, id: result.uuid };
|
||||
references.push(...prefixReferencesFromPanel(result.uuid, result.serialized.references ?? []));
|
||||
}
|
||||
|
||||
return { panels, references };
|
||||
};
|
||||
|
||||
|
@ -145,7 +160,7 @@ export function runSaveAs(this: DashboardContainer) {
|
|||
});
|
||||
}
|
||||
this.savedObjectReferences = saveResult.references ?? [];
|
||||
this.lastSavedState.next();
|
||||
this.saveNotification$.next();
|
||||
resolve(saveResult);
|
||||
return saveResult;
|
||||
};
|
||||
|
@ -199,7 +214,7 @@ export async function runQuickSave(this: DashboardContainer) {
|
|||
|
||||
this.savedObjectReferences = saveResult.references ?? [];
|
||||
this.dispatch.setLastSavedInput(dashboardStateToSave);
|
||||
this.lastSavedState.next();
|
||||
this.saveNotification$.next();
|
||||
if (this.controlGroup && persistableControlGroupInput) {
|
||||
this.controlGroup.setSavedState(persistableControlGroupInput);
|
||||
}
|
||||
|
|
|
@ -168,7 +168,7 @@ test('pulls state from backup which overrides state from saved object', async ()
|
|||
});
|
||||
pluginServices.getServices().dashboardBackup.getState = jest
|
||||
.fn()
|
||||
.mockReturnValue({ description: 'wow this description marginally better' });
|
||||
.mockReturnValue({ dashboardState: { description: 'wow this description marginally better' } });
|
||||
const dashboard = await createDashboard({ useSessionStorageIntegration: true }, 0, 'wow-such-id');
|
||||
expect(dashboard).toBeDefined();
|
||||
expect(dashboard!.getState().explicitInput.description).toBe(
|
||||
|
|
|
@ -195,11 +195,18 @@ export const initializeDashboard = async ({
|
|||
// --------------------------------------------------------------------------------------
|
||||
// Gather input from session storage and local storage if integration is used.
|
||||
// --------------------------------------------------------------------------------------
|
||||
const dashboardBackupState = dashboardBackup.getState(loadDashboardReturn.dashboardId);
|
||||
const sessionStorageInput = ((): Partial<SavedDashboardInput> | undefined => {
|
||||
if (!useSessionStorageIntegration) return;
|
||||
return dashboardBackup.getState(loadDashboardReturn.dashboardId);
|
||||
return dashboardBackupState?.dashboardState;
|
||||
})();
|
||||
|
||||
if (useSessionStorageIntegration) {
|
||||
untilDashboardReady().then((dashboardContainer) => {
|
||||
dashboardContainer.restoredRuntimeState = dashboardBackupState?.panels;
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Combine input from saved object, session storage, & passed input to create initial input.
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
|
|
@ -34,7 +34,12 @@ import {
|
|||
} from '@kbn/embeddable-plugin/public';
|
||||
import type { Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { TrackContentfulRender } from '@kbn/presentation-containers';
|
||||
import {
|
||||
HasRuntimeChildState,
|
||||
HasSaveNotification,
|
||||
HasSerializedChildState,
|
||||
TrackContentfulRender,
|
||||
} from '@kbn/presentation-containers';
|
||||
import { apiHasSerializableState, PanelPackage } from '@kbn/presentation-containers';
|
||||
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
|
||||
import { LocatorPublic } from '@kbn/share-plugin/common';
|
||||
|
@ -72,6 +77,7 @@ import {
|
|||
DashboardPublicState,
|
||||
DashboardReduxState,
|
||||
DashboardRenderPerformanceStats,
|
||||
UnsavedPanelState,
|
||||
} from '../types';
|
||||
import {
|
||||
addFromLibrary,
|
||||
|
@ -123,7 +129,12 @@ export const useDashboardContainer = (): DashboardContainer => {
|
|||
|
||||
export class DashboardContainer
|
||||
extends Container<InheritedChildInput, DashboardContainerInput>
|
||||
implements DashboardExternallyAccessibleApi, TrackContentfulRender
|
||||
implements
|
||||
DashboardExternallyAccessibleApi,
|
||||
TrackContentfulRender,
|
||||
HasSaveNotification,
|
||||
HasRuntimeChildState,
|
||||
HasSerializedChildState
|
||||
{
|
||||
public readonly type = DASHBOARD_CONTAINER_TYPE;
|
||||
|
||||
|
@ -575,7 +586,9 @@ export class DashboardContainer
|
|||
if (reactEmbeddableRegistryHasKey(panel.type)) {
|
||||
const child = this.children$.value[panelId];
|
||||
if (!child) throw new PanelNotFoundError();
|
||||
const serialized = apiHasSerializableState(child) ? child.serializeState() : { rawState: {} };
|
||||
const serialized = apiHasSerializableState(child)
|
||||
? await child.serializeState()
|
||||
: { rawState: {} };
|
||||
return {
|
||||
type: panel.type,
|
||||
explicitInput: { ...panel.explicitInput, ...serialized.rawState },
|
||||
|
@ -806,17 +819,19 @@ export class DashboardContainer
|
|||
});
|
||||
};
|
||||
|
||||
public lastSavedState: Subject<void> = new Subject();
|
||||
public getLastSavedStateForChild = (childId: string) => {
|
||||
const {
|
||||
componentState: {
|
||||
lastSavedInput: { panels },
|
||||
},
|
||||
} = this.getState();
|
||||
const panel: DashboardPanelState | undefined = panels[childId];
|
||||
public saveNotification$: Subject<void> = new Subject<void>();
|
||||
|
||||
public getSerializedStateForChild = (childId: string) => {
|
||||
const references = getReferencesForPanelId(childId, this.savedObjectReferences);
|
||||
return { rawState: panel?.explicitInput, version: panel?.version, references };
|
||||
return {
|
||||
rawState: this.getInput().panels[childId].explicitInput,
|
||||
references,
|
||||
};
|
||||
};
|
||||
|
||||
public restoredRuntimeState: UnsavedPanelState | undefined = undefined;
|
||||
public getRuntimeStateForChild = (childId: string) => {
|
||||
return this.restoredRuntimeState?.[childId];
|
||||
};
|
||||
|
||||
public removePanel(id: string) {
|
||||
|
@ -857,6 +872,7 @@ export class DashboardContainer
|
|||
};
|
||||
|
||||
public resetAllReactEmbeddables = () => {
|
||||
this.restoredRuntimeState = undefined;
|
||||
let resetChangedPanelCount = false;
|
||||
const currentChildren = this.children$.value;
|
||||
for (const panelId of Object.keys(currentChildren)) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
|
||||
import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { cloneDeep, omit } from 'lodash';
|
||||
import { omit } from 'lodash';
|
||||
import { AnyAction, Middleware } from 'redux';
|
||||
import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs';
|
||||
import { distinctUntilChanged, map } from 'rxjs';
|
||||
|
@ -16,6 +16,7 @@ import { DashboardContainer, DashboardCreationOptions } from '../..';
|
|||
import { DashboardContainerInput } from '../../../../common';
|
||||
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
|
||||
import { pluginServices } from '../../../services/plugin_services';
|
||||
import { UnsavedPanelState } from '../../types';
|
||||
import { dashboardContainerReducers } from '../dashboard_container_reducers';
|
||||
import { isKeyEqualAsync, unsavedChangesDiffingFunctions } from './dashboard_diffing_functions';
|
||||
|
||||
|
@ -151,13 +152,17 @@ export function startDiffingDashboardState(
|
|||
this.dispatch.setHasUnsavedChanges(hasUnsavedChanges);
|
||||
}
|
||||
|
||||
const unsavedPanelState = reactEmbeddableChanges.reduce<UnsavedPanelState>(
|
||||
(acc, { childId, unsavedChanges }) => {
|
||||
acc[childId] = unsavedChanges;
|
||||
return acc;
|
||||
},
|
||||
{} as UnsavedPanelState
|
||||
);
|
||||
|
||||
// backup unsaved changes if configured to do so
|
||||
if (creationOptions?.useSessionStorageIntegration) {
|
||||
backupUnsavedChanges.bind(this)(
|
||||
dashboardChanges,
|
||||
reactEmbeddableChanges,
|
||||
controlGroupChanges
|
||||
);
|
||||
backupUnsavedChanges.bind(this)(dashboardChanges, unsavedPanelState, controlGroupChanges);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
@ -209,36 +214,19 @@ export async function getDashboardUnsavedChanges(
|
|||
function backupUnsavedChanges(
|
||||
this: DashboardContainer,
|
||||
dashboardChanges: Partial<DashboardContainerInput>,
|
||||
reactEmbeddableChanges: Array<{
|
||||
childId: string;
|
||||
unsavedChanges: object | undefined;
|
||||
}>,
|
||||
reactEmbeddableChanges: UnsavedPanelState,
|
||||
controlGroupChanges: PersistableControlGroupInput | undefined
|
||||
) {
|
||||
const { dashboardBackup } = pluginServices.getServices();
|
||||
|
||||
// apply all unsaved state from react embeddables to the unsaved changes object.
|
||||
let hasAnyReactEmbeddableUnsavedChanges = false;
|
||||
const currentPanels = cloneDeep(dashboardChanges.panels ?? this.getInput().panels);
|
||||
for (const { childId, unsavedChanges: childUnsavedChanges } of reactEmbeddableChanges) {
|
||||
if (!childUnsavedChanges) continue;
|
||||
const panelStateToBackup = {
|
||||
...currentPanels[childId],
|
||||
...(dashboardChanges.panels?.[childId] ?? {}),
|
||||
explicitInput: {
|
||||
...currentPanels[childId]?.explicitInput,
|
||||
...(dashboardChanges.panels?.[childId]?.explicitInput ?? {}),
|
||||
...childUnsavedChanges,
|
||||
},
|
||||
};
|
||||
hasAnyReactEmbeddableUnsavedChanges = true;
|
||||
currentPanels[childId] = panelStateToBackup;
|
||||
}
|
||||
const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage);
|
||||
|
||||
dashboardBackup.setState(this.getDashboardSavedObjectId(), {
|
||||
...dashboardStateToBackup,
|
||||
panels: hasAnyReactEmbeddableUnsavedChanges ? currentPanels : dashboardChanges.panels,
|
||||
controlGroupInput: controlGroupChanges,
|
||||
});
|
||||
dashboardBackup.setState(
|
||||
this.getDashboardSavedObjectId(),
|
||||
{
|
||||
...dashboardStateToBackup,
|
||||
panels: dashboardChanges.panels,
|
||||
controlGroupInput: controlGroupChanges,
|
||||
},
|
||||
reactEmbeddableChanges
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,10 @@ import { SerializableRecord } from '@kbn/utility-types';
|
|||
import type { DashboardContainerInput, DashboardOptions } from '../../common';
|
||||
import { SavedDashboardPanel } from '../../common/content_management';
|
||||
|
||||
export interface UnsavedPanelState {
|
||||
[key: string]: object | undefined;
|
||||
}
|
||||
|
||||
export type DashboardReduxState = ReduxEmbeddableState<
|
||||
DashboardContainerInput,
|
||||
DashboardContainerOutput,
|
||||
|
|
|
@ -20,11 +20,15 @@ import type { DashboardBackupServiceType } from './types';
|
|||
import type { DashboardContainerInput } from '../../../common';
|
||||
import { DashboardNotificationsService } from '../notifications/types';
|
||||
import { backupServiceStrings } from '../../dashboard_container/_dashboard_container_strings';
|
||||
import { UnsavedPanelState } from '../../dashboard_container/types';
|
||||
|
||||
export const DASHBOARD_PANELS_UNSAVED_ID = 'unsavedDashboard';
|
||||
const DASHBOARD_PANELS_SESSION_KEY = 'dashboardStateManagerPanels';
|
||||
const DASHBOARD_PANELS_SESSION_KEY = 'dashboardPanels';
|
||||
const DASHBOARD_VIEWMODE_LOCAL_KEY = 'dashboardViewMode';
|
||||
|
||||
// this key is named `panels` for BWC reasons, but actually contains the entire dashboard state
|
||||
const DASHBOARD_STATE_SESSION_KEY = 'dashboardStateManagerPanels';
|
||||
|
||||
interface DashboardBackupRequiredServices {
|
||||
notifications: DashboardNotificationsService;
|
||||
spaces: DashboardSpacesService;
|
||||
|
@ -75,11 +79,22 @@ class DashboardBackupService implements DashboardBackupServiceType {
|
|||
|
||||
public clearState(id = DASHBOARD_PANELS_UNSAVED_ID) {
|
||||
try {
|
||||
const sessionStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY);
|
||||
const sessionStorageForSpace = sessionStorage?.[this.activeSpaceId] || {};
|
||||
if (sessionStorageForSpace[id]) {
|
||||
delete sessionStorageForSpace[id];
|
||||
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStorage);
|
||||
const dashboardStateStorage =
|
||||
this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[this.activeSpaceId] ?? {};
|
||||
if (dashboardStateStorage[id]) {
|
||||
delete dashboardStateStorage[id];
|
||||
this.sessionStorage.set(DASHBOARD_STATE_SESSION_KEY, {
|
||||
[this.activeSpaceId]: dashboardStateStorage,
|
||||
});
|
||||
}
|
||||
|
||||
const panelsStorage =
|
||||
this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] ?? {};
|
||||
if (panelsStorage[id]) {
|
||||
delete panelsStorage[id];
|
||||
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, {
|
||||
[this.activeSpaceId]: panelsStorage,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifications.toasts.addDanger({
|
||||
|
@ -89,9 +104,15 @@ class DashboardBackupService implements DashboardBackupServiceType {
|
|||
}
|
||||
}
|
||||
|
||||
public getState(id = DASHBOARD_PANELS_UNSAVED_ID): Partial<DashboardContainerInput> | undefined {
|
||||
public getState(id = DASHBOARD_PANELS_UNSAVED_ID) {
|
||||
try {
|
||||
return this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[id];
|
||||
const dashboardState = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[
|
||||
this.activeSpaceId
|
||||
]?.[id] as Partial<DashboardContainerInput> | undefined;
|
||||
const panels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[
|
||||
id
|
||||
] as UnsavedPanelState | undefined;
|
||||
return { dashboardState, panels };
|
||||
} catch (e) {
|
||||
this.notifications.toasts.addDanger({
|
||||
title: backupServiceStrings.getPanelsGetError(e.message),
|
||||
|
@ -100,11 +121,19 @@ class DashboardBackupService implements DashboardBackupServiceType {
|
|||
}
|
||||
}
|
||||
|
||||
public setState(id = DASHBOARD_PANELS_UNSAVED_ID, newState: Partial<DashboardContainerInput>) {
|
||||
public setState(
|
||||
id = DASHBOARD_PANELS_UNSAVED_ID,
|
||||
newState: Partial<DashboardContainerInput>,
|
||||
unsavedPanels: UnsavedPanelState
|
||||
) {
|
||||
try {
|
||||
const sessionStateStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) || {};
|
||||
set(sessionStateStorage, [this.activeSpaceId, id], newState);
|
||||
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, sessionStateStorage);
|
||||
const dashboardStateStorage = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY) ?? {};
|
||||
set(dashboardStateStorage, [this.activeSpaceId, id], newState);
|
||||
this.sessionStorage.set(DASHBOARD_STATE_SESSION_KEY, dashboardStateStorage);
|
||||
|
||||
const panelsStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) ?? {};
|
||||
set(panelsStorage, [this.activeSpaceId, id], unsavedPanels);
|
||||
this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, panelsStorage);
|
||||
} catch (e) {
|
||||
this.notifications.toasts.addDanger({
|
||||
title: backupServiceStrings.getPanelsSetError(e.message),
|
||||
|
@ -116,18 +145,25 @@ class DashboardBackupService implements DashboardBackupServiceType {
|
|||
public getDashboardIdsWithUnsavedChanges() {
|
||||
try {
|
||||
const dashboardStatesInSpace =
|
||||
this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] || {};
|
||||
const dashboardsWithUnsavedChanges: string[] = [];
|
||||
this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[this.activeSpaceId] ?? {};
|
||||
const panelStatesInSpace =
|
||||
this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] ?? {};
|
||||
|
||||
Object.keys(dashboardStatesInSpace).map((dashboardId) => {
|
||||
if (
|
||||
dashboardStatesInSpace[dashboardId].viewMode === ViewMode.EDIT &&
|
||||
Object.keys(dashboardStatesInSpace[dashboardId]).some(
|
||||
(stateKey) => stateKey !== 'viewMode'
|
||||
const dashboardsSet: Set<string> = new Set<string>();
|
||||
|
||||
[...Object.keys(panelStatesInSpace), ...Object.keys(dashboardStatesInSpace)].map(
|
||||
(dashboardId) => {
|
||||
if (
|
||||
dashboardStatesInSpace[dashboardId].viewMode === ViewMode.EDIT &&
|
||||
(Object.keys(dashboardStatesInSpace[dashboardId]).some(
|
||||
(stateKey) => stateKey !== 'viewMode'
|
||||
) ||
|
||||
Object.keys(panelStatesInSpace?.[dashboardId]).length > 0)
|
||||
)
|
||||
)
|
||||
dashboardsWithUnsavedChanges.push(dashboardId);
|
||||
});
|
||||
dashboardsSet.add(dashboardId);
|
||||
}
|
||||
);
|
||||
const dashboardsWithUnsavedChanges = [...dashboardsSet];
|
||||
|
||||
/**
|
||||
* Because we are storing these unsaved dashboard IDs in React component state, we only want things to be re-rendered
|
||||
|
|
|
@ -7,12 +7,22 @@
|
|||
*/
|
||||
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { UnsavedPanelState } from '../../dashboard_container/types';
|
||||
import { SavedDashboardInput } from '../dashboard_content_management/types';
|
||||
|
||||
export interface DashboardBackupServiceType {
|
||||
clearState: (id?: string) => void;
|
||||
getState: (id: string | undefined) => Partial<SavedDashboardInput> | undefined;
|
||||
setState: (id: string | undefined, newState: Partial<SavedDashboardInput>) => void;
|
||||
getState: (id: string | undefined) =>
|
||||
| {
|
||||
dashboardState?: Partial<SavedDashboardInput>;
|
||||
panels?: UnsavedPanelState;
|
||||
}
|
||||
| undefined;
|
||||
setState: (
|
||||
id: string | undefined,
|
||||
dashboardState: Partial<SavedDashboardInput>,
|
||||
panels: UnsavedPanelState
|
||||
) => void;
|
||||
getViewMode: () => ViewMode;
|
||||
storeViewMode: (viewMode: ViewMode) => void;
|
||||
getDashboardIdsWithUnsavedChanges: () => string[];
|
||||
|
|
|
@ -1,21 +1,26 @@
|
|||
Embeddables are React components that manage their own state, can be serialized and deserialized, and return an API that can be used to interact with them imperatively.
|
||||
|
||||
#### Guiding principles
|
||||
* **Coupled to React:** Kibana is a React application, and the minimum unit of sharing is the React component. Embeddables enforce this by requiring a React component during registration.
|
||||
* **Composition over inheritence:** Rather than an inheritance-based system with classes, imperative APIs are plain old typescript objects that implement any number of shared interfaces. Interfaces are enforced via type guards and are shared via Packages.
|
||||
* **Internal state management:** Each embeddable manages its own state. This is because the embeddable system allows a page to render a registry of embeddable types that can change over time. This makes it untenable for a single page to manage state for every type of embeddable. The page is only responsible for persisting and providing the last persisted state to the embeddable on startup.
|
||||
|
||||
- **Coupled to React:** Kibana is a React application, and the minimum unit of sharing is the React component. Embeddables enforce this by requiring a React component during registration.
|
||||
- **Composition over inheritence:** Rather than an inheritance-based system with classes, imperative APIs are plain old typescript objects that implement any number of shared interfaces. Interfaces are enforced via type guards and are shared via Packages.
|
||||
- **Internal state management:** Each embeddable manages its own state. This is because the embeddable system allows a page to render a registry of embeddable types that can change over time. This makes it untenable for a single page to manage state for every type of embeddable. The page is only responsible for persisting and providing the last persisted state to the embeddable on startup.
|
||||
|
||||
#### Best practices
|
||||
* **Do not use Embeddables to share Components between plugins: ** Only create an embeddable if your Component is rendered on a page that persists embeddable state and renders multiple embeddable types. For example, create an embeddable to render your Component on a Dashboard. Otherwise, use a vanilla React Component to share Components between plugins.
|
||||
* **Do not use Embeddables to avoid circular plugin dependencies: ** Break your Component into a Package or another plugin to avoid circular plugin dependencies.
|
||||
* **Minimal API surface area: ** Embeddable APIs are accessable to all Kibana systems and all embeddable siblings and parents. Functions and state that are internal to an embeddable including any child components should not be added to the API. Consider passing internal state to child as props or react context.
|
||||
|
||||
- **Do not use Embeddables to share Components between plugins: ** Only create an embeddable if your Component is rendered on a page that persists embeddable state and renders multiple embeddable types. For example, create an embeddable to render your Component on a Dashboard. Otherwise, use a vanilla React Component to share Components between plugins.
|
||||
- **Do not use Embeddables to avoid circular plugin dependencies: ** Break your Component into a Package or another plugin to avoid circular plugin dependencies.
|
||||
- **Minimal API surface area: ** Embeddable APIs are accessable to all Kibana systems and all embeddable siblings and parents. Functions and state that are internal to an embeddable including any child components should not be added to the API. Consider passing internal state to child as props or react context.
|
||||
|
||||
#### Examples
|
||||
|
||||
Examples available at [/examples/embeddable_examples](https://github.com/elastic/kibana/tree/main/examples/embeddable_examples)
|
||||
* [Register an embeddable](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/register_search_embeddable.ts)
|
||||
* [Embeddable that responds to Unified search](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/search_react_embeddable.tsx)
|
||||
* [Embeddable that interacts with sibling embeddables](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/data_table/data_table_react_embeddable.tsx)
|
||||
* [Render an embeddable](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/search_embeddable_renderer.tsx)
|
||||
|
||||
- [Register an embeddable](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/register_search_embeddable.ts)
|
||||
- [Embeddable that responds to Unified search](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/search_react_embeddable.tsx)
|
||||
- [Embeddable that interacts with sibling embeddables](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/data_table/data_table_react_embeddable.tsx)
|
||||
- [Embeddable that can be by value or by reference](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/saved_book/saved_book_react_embeddable.tsx)
|
||||
- [Render an embeddable](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/search_embeddable_renderer.tsx)
|
||||
|
||||
Run examples with `yarn start --run-examples`
|
||||
To access example embeddables, create a new dashboard, click "Add panel" and finally select "Embeddable examples".
|
||||
To access example embeddables, create a new dashboard, click "Add panel" and finally select "Embeddable examples".
|
||||
|
|
|
@ -98,8 +98,6 @@ export {
|
|||
ReactEmbeddableRenderer,
|
||||
type DefaultEmbeddableApi,
|
||||
type ReactEmbeddableFactory,
|
||||
type ReactEmbeddableRegistration,
|
||||
startTrackingEmbeddableUnsavedChanges,
|
||||
} from './react_embeddable_system';
|
||||
|
||||
export function plugin(initializerContext: PluginInitializerContext) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { isEqual, xor } from 'lodash';
|
||||
import { BehaviorSubject, EMPTY, merge, Subject, Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, EMPTY, merge, Subscription } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
combineLatestWith,
|
||||
|
@ -22,7 +22,7 @@ import {
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { PanelPackage } from '@kbn/presentation-containers';
|
||||
import { PresentationContainer, SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { PresentationContainer } from '@kbn/presentation-containers';
|
||||
|
||||
import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable';
|
||||
import { EmbeddableStart } from '../../plugin';
|
||||
|
@ -64,10 +64,6 @@ export abstract class Container<
|
|||
private subscription: Subscription | undefined;
|
||||
private readonly anyChildOutputChange$;
|
||||
|
||||
public lastSavedState: Subject<void> = new Subject();
|
||||
public getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined = () =>
|
||||
undefined;
|
||||
|
||||
constructor(
|
||||
input: TContainerInput,
|
||||
output: TContainerOutput,
|
||||
|
|
|
@ -58,8 +58,6 @@ import {
|
|||
import { getAllMigrations } from '../common/lib/get_all_migrations';
|
||||
import { setKibanaServices } from './kibana_services';
|
||||
import {
|
||||
DefaultEmbeddableApi,
|
||||
ReactEmbeddableFactory,
|
||||
reactEmbeddableRegistryHasKey,
|
||||
registerReactEmbeddableFactory,
|
||||
} from './react_embeddable_system';
|
||||
|
@ -108,13 +106,7 @@ export interface EmbeddableSetup {
|
|||
/**
|
||||
* Registers an async {@link ReactEmbeddableFactory} getter.
|
||||
*/
|
||||
registerReactEmbeddableFactory: <
|
||||
StateType extends object = object,
|
||||
APIType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
|
||||
>(
|
||||
type: string,
|
||||
getFactory: () => Promise<ReactEmbeddableFactory<StateType, APIType>>
|
||||
) => void;
|
||||
registerReactEmbeddableFactory: typeof registerReactEmbeddableFactory;
|
||||
|
||||
/**
|
||||
* @deprecated use {@link registerReactEmbeddableFactory} instead.
|
||||
|
|
|
@ -11,9 +11,4 @@ export {
|
|||
registerReactEmbeddableFactory,
|
||||
} from './react_embeddable_registry';
|
||||
export { ReactEmbeddableRenderer } from './react_embeddable_renderer';
|
||||
export { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
|
||||
export type {
|
||||
DefaultEmbeddableApi,
|
||||
ReactEmbeddableFactory,
|
||||
ReactEmbeddableRegistration,
|
||||
} from './types';
|
||||
export type { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types';
|
||||
|
|
|
@ -14,16 +14,17 @@ const registry: { [key: string]: () => Promise<ReactEmbeddableFactory<any, any>>
|
|||
/**
|
||||
* Registers a new React embeddable factory. This should be called at plugin start time.
|
||||
*
|
||||
* @param type The key to register the factory under. This should be the same as the `type` key in the factory definition.
|
||||
* @param type The key to register the factory under.
|
||||
* @param getFactory an async function that gets the factory definition for this key. This should always async import the
|
||||
* actual factory definition file to avoid polluting page load.
|
||||
*/
|
||||
export const registerReactEmbeddableFactory = <
|
||||
StateType extends object = object,
|
||||
APIType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
|
||||
SerializedState extends object = object,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>,
|
||||
RuntimeState extends object = SerializedState
|
||||
>(
|
||||
type: string,
|
||||
getFactory: () => Promise<ReactEmbeddableFactory<StateType, APIType>>
|
||||
getFactory: () => Promise<ReactEmbeddableFactory<SerializedState, Api, RuntimeState>>
|
||||
) => {
|
||||
if (registry[type] !== undefined)
|
||||
throw new Error(
|
||||
|
@ -38,11 +39,12 @@ export const registerReactEmbeddableFactory = <
|
|||
export const reactEmbeddableRegistryHasKey = (key: string) => registry[key] !== undefined;
|
||||
|
||||
export const getReactEmbeddableFactory = async <
|
||||
StateType extends object = object,
|
||||
ApiType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
|
||||
SerializedState extends object = object,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>,
|
||||
RuntimeState extends object = SerializedState
|
||||
>(
|
||||
key: string
|
||||
): Promise<ReactEmbeddableFactory<StateType, ApiType>> => {
|
||||
): Promise<ReactEmbeddableFactory<SerializedState, Api, RuntimeState>> => {
|
||||
if (registry[key] === undefined)
|
||||
throw new Error(
|
||||
i18n.translate('embeddableApi.reactEmbeddable.factoryNotFoundError', {
|
||||
|
|
|
@ -54,8 +54,19 @@ describe('react embeddable renderer', () => {
|
|||
setupPresentationPanelServices();
|
||||
});
|
||||
|
||||
it('deserializes given state', async () => {
|
||||
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { bork: 'blorp?' } }} />);
|
||||
it('deserializes unsaved state provided by the parent', async () => {
|
||||
render(
|
||||
<ReactEmbeddableRenderer
|
||||
type={'test'}
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {
|
||||
bork: 'blorp?',
|
||||
},
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(testEmbeddableFactory.deserializeState).toHaveBeenCalledWith({
|
||||
rawState: { bork: 'blorp?' },
|
||||
|
@ -65,13 +76,24 @@ describe('react embeddable renderer', () => {
|
|||
|
||||
it('builds the embeddable', async () => {
|
||||
const buildEmbeddableSpy = jest.spyOn(testEmbeddableFactory, 'buildEmbeddable');
|
||||
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { bork: 'blorp?' } }} />);
|
||||
render(
|
||||
<ReactEmbeddableRenderer
|
||||
type={'test'}
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {
|
||||
bork: 'blorp?',
|
||||
},
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(buildEmbeddableSpy).toHaveBeenCalledWith(
|
||||
{ bork: 'blorp?' },
|
||||
expect.any(Function),
|
||||
expect.any(String),
|
||||
undefined
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -82,7 +104,13 @@ describe('react embeddable renderer', () => {
|
|||
<ReactEmbeddableRenderer
|
||||
type={'test'}
|
||||
maybeId={'12345'}
|
||||
state={{ rawState: { bork: 'blorp?' } }}
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {
|
||||
bork: 'blorp?',
|
||||
},
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
|
@ -90,21 +118,22 @@ describe('react embeddable renderer', () => {
|
|||
{ bork: 'blorp?' },
|
||||
expect.any(Function),
|
||||
'12345',
|
||||
undefined
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the embeddable, providing a parent', async () => {
|
||||
const buildEmbeddableSpy = jest.spyOn(testEmbeddableFactory, 'buildEmbeddable');
|
||||
const parentApi = getMockPresentationContainer();
|
||||
render(
|
||||
<ReactEmbeddableRenderer
|
||||
type={'test'}
|
||||
state={{ rawState: { bork: 'blorp?' } }}
|
||||
parentApi={parentApi}
|
||||
/>
|
||||
);
|
||||
const parentApi = {
|
||||
...getMockPresentationContainer(),
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: {
|
||||
bork: 'blorp?',
|
||||
},
|
||||
}),
|
||||
};
|
||||
render(<ReactEmbeddableRenderer type={'test'} getParentApi={() => parentApi} />);
|
||||
await waitFor(() => {
|
||||
expect(buildEmbeddableSpy).toHaveBeenCalledWith(
|
||||
{ bork: 'blorp?' },
|
||||
|
@ -119,7 +148,11 @@ describe('react embeddable renderer', () => {
|
|||
render(
|
||||
<ReactEmbeddableRenderer
|
||||
type={'test'}
|
||||
state={{ rawState: { name: 'Kuni Garu', bork: 'Dara' } }}
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: { name: 'Kuni Garu', bork: 'Dara' },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
|
@ -136,17 +169,22 @@ describe('react embeddable renderer', () => {
|
|||
type={'test'}
|
||||
maybeId={'12345'}
|
||||
onApiAvailable={onApiAvailable}
|
||||
state={{ rawState: { name: 'Kuni Garu' } }}
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: { name: 'Kuni Garu' },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onApiAvailable).toHaveBeenCalledWith({
|
||||
type: 'test',
|
||||
uuid: '12345',
|
||||
parentApi: undefined,
|
||||
parentApi: expect.any(Object),
|
||||
unsavedChanges: expect.any(Object),
|
||||
serializeState: expect.any(Function),
|
||||
resetUnsavedChanges: expect.any(Function),
|
||||
snapshotRuntimeState: expect.any(Function),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
@ -157,7 +195,11 @@ describe('react embeddable renderer', () => {
|
|||
<ReactEmbeddableRenderer
|
||||
type={'test'}
|
||||
onApiAvailable={onApiAvailable}
|
||||
state={{ rawState: { name: 'Kuni Garu' } }}
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: { name: 'Kuni Garu' },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await waitFor(() =>
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { HasSerializedChildState, SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public';
|
||||
import { ComparatorDefinition, StateComparators } from '@kbn/presentation-publishing';
|
||||
import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react';
|
||||
import { combineLatest, debounceTime, skip } from 'rxjs';
|
||||
import { combineLatest, debounceTime, skip, switchMap } from 'rxjs';
|
||||
import { v4 as generateId } from 'uuid';
|
||||
import { getReactEmbeddableFactory } from './react_embeddable_registry';
|
||||
import { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
|
||||
import { initializeReactEmbeddableState } from './react_embeddable_state';
|
||||
import { DefaultEmbeddableApi, ReactEmbeddableApiRegistration } from './types';
|
||||
|
||||
const ON_STATE_CHANGE_DEBOUNCE = 100;
|
||||
|
@ -24,25 +24,25 @@ const ON_STATE_CHANGE_DEBOUNCE = 100;
|
|||
* TODO: Rename this to simply `Embeddable` when the legacy Embeddable system is removed.
|
||||
*/
|
||||
export const ReactEmbeddableRenderer = <
|
||||
StateType extends object = object,
|
||||
ApiType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
|
||||
SerializedState extends object = object,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>,
|
||||
RuntimeState extends object = SerializedState,
|
||||
ParentApi extends HasSerializedChildState<SerializedState> = HasSerializedChildState<SerializedState>
|
||||
>({
|
||||
maybeId,
|
||||
type,
|
||||
state,
|
||||
parentApi,
|
||||
onApiAvailable,
|
||||
maybeId,
|
||||
getParentApi,
|
||||
panelProps,
|
||||
onAnyStateChange,
|
||||
onApiAvailable,
|
||||
hidePanelChrome,
|
||||
}: {
|
||||
maybeId?: string;
|
||||
type: string;
|
||||
state: SerializedPanelState<StateType>;
|
||||
parentApi?: unknown;
|
||||
onApiAvailable?: (api: ApiType) => void;
|
||||
maybeId?: string;
|
||||
getParentApi: () => ParentApi;
|
||||
onApiAvailable?: (api: Api) => void;
|
||||
panelProps?: Pick<
|
||||
PresentationPanelProps<ApiType>,
|
||||
PresentationPanelProps<Api>,
|
||||
| 'showShadow'
|
||||
| 'showBorder'
|
||||
| 'showBadges'
|
||||
|
@ -55,57 +55,73 @@ export const ReactEmbeddableRenderer = <
|
|||
* This `onAnyStateChange` callback allows the parent to keep track of the state of the embeddable
|
||||
* as it changes. This is **not** expected to change over the lifetime of the component.
|
||||
*/
|
||||
onAnyStateChange?: (state: SerializedPanelState<StateType>) => void;
|
||||
onAnyStateChange?: (state: SerializedPanelState<SerializedState>) => void;
|
||||
}) => {
|
||||
const cleanupFunction = useRef<(() => void) | null>(null);
|
||||
|
||||
const componentPromise = useMemo(
|
||||
() =>
|
||||
(async () => {
|
||||
const parentApi = getParentApi();
|
||||
const uuid = maybeId ?? generateId();
|
||||
const factory = await getReactEmbeddableFactory<StateType, ApiType>(type);
|
||||
const registerApi = (
|
||||
apiRegistration: ReactEmbeddableApiRegistration<StateType, ApiType>,
|
||||
comparators: StateComparators<StateType>
|
||||
) => {
|
||||
const { unsavedChanges, resetUnsavedChanges, cleanup } =
|
||||
startTrackingEmbeddableUnsavedChanges(
|
||||
uuid,
|
||||
parentApi,
|
||||
comparators,
|
||||
factory.deserializeState
|
||||
);
|
||||
const factory = await getReactEmbeddableFactory<SerializedState, Api, RuntimeState>(type);
|
||||
|
||||
const { initialState, startStateDiffing } = await initializeReactEmbeddableState<
|
||||
SerializedState,
|
||||
Api,
|
||||
RuntimeState
|
||||
>(uuid, factory, parentApi);
|
||||
|
||||
const buildApi = (
|
||||
apiRegistration: ReactEmbeddableApiRegistration<SerializedState, Api>,
|
||||
comparators: StateComparators<RuntimeState>
|
||||
) => {
|
||||
if (onAnyStateChange) {
|
||||
/**
|
||||
* To avoid unnecessary re-renders, only subscribe to the comparator publishing subjects if
|
||||
* an `onAnyStateChange` callback is provided
|
||||
*/
|
||||
const comparatorDefinitions: Array<ComparatorDefinition<StateType, keyof StateType>> =
|
||||
Object.values(comparators);
|
||||
const comparatorDefinitions: Array<
|
||||
ComparatorDefinition<RuntimeState, keyof RuntimeState>
|
||||
> = Object.values(comparators);
|
||||
combineLatest(comparatorDefinitions.map((comparator) => comparator[0]))
|
||||
.pipe(skip(1), debounceTime(ON_STATE_CHANGE_DEBOUNCE))
|
||||
.subscribe(() => {
|
||||
onAnyStateChange(apiRegistration.serializeState());
|
||||
.pipe(
|
||||
skip(1),
|
||||
debounceTime(ON_STATE_CHANGE_DEBOUNCE),
|
||||
switchMap(() => {
|
||||
const isAsync =
|
||||
apiRegistration.serializeState.prototype?.name === 'AsyncFunction';
|
||||
return isAsync
|
||||
? (apiRegistration.serializeState() as Promise<
|
||||
SerializedPanelState<SerializedState>
|
||||
>)
|
||||
: Promise.resolve(apiRegistration.serializeState());
|
||||
})
|
||||
)
|
||||
.subscribe((serializedState) => {
|
||||
onAnyStateChange(serializedState);
|
||||
});
|
||||
}
|
||||
|
||||
const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } =
|
||||
startStateDiffing(comparators);
|
||||
const fullApi = {
|
||||
...apiRegistration,
|
||||
uuid,
|
||||
parentApi,
|
||||
unsavedChanges,
|
||||
resetUnsavedChanges,
|
||||
type: factory.type,
|
||||
} as unknown as ApiType;
|
||||
resetUnsavedChanges,
|
||||
snapshotRuntimeState,
|
||||
} as unknown as Api;
|
||||
cleanupFunction.current = () => cleanup();
|
||||
onApiAvailable?.(fullApi);
|
||||
return fullApi;
|
||||
};
|
||||
|
||||
const { api, Component } = await factory.buildEmbeddable(
|
||||
factory.deserializeState(state),
|
||||
registerApi,
|
||||
initialState,
|
||||
buildApi,
|
||||
uuid,
|
||||
parentApi
|
||||
);
|
||||
|
@ -132,7 +148,7 @@ export const ReactEmbeddableRenderer = <
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<PresentationPanel<ApiType, {}>
|
||||
<PresentationPanel<Api, {}>
|
||||
hidePanelChrome={hidePanelChrome}
|
||||
{...panelProps}
|
||||
Component={componentPromise}
|
||||
|
|
|
@ -7,15 +7,17 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
HasRuntimeChildState,
|
||||
HasSaveNotification,
|
||||
HasSerializedChildState,
|
||||
PresentationContainer,
|
||||
PublishesLastSavedState,
|
||||
SerializedPanelState,
|
||||
} from '@kbn/presentation-containers';
|
||||
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
|
||||
import { StateComparators } from '@kbn/presentation-publishing';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
|
||||
import { initializeReactEmbeddableState } from './react_embeddable_state';
|
||||
import { ReactEmbeddableFactory } from './types';
|
||||
|
||||
interface SuperTestStateType {
|
||||
name: string;
|
||||
|
@ -24,29 +26,36 @@ interface SuperTestStateType {
|
|||
}
|
||||
|
||||
describe('react embeddable unsaved changes', () => {
|
||||
let initialState: SuperTestStateType;
|
||||
let lastSavedState: SuperTestStateType;
|
||||
let serializedStateForChild: SuperTestStateType;
|
||||
|
||||
let comparators: StateComparators<SuperTestStateType>;
|
||||
let deserializeState: (state: SerializedPanelState) => SuperTestStateType;
|
||||
let parentApi: (PresentationContainer & PublishesLastSavedState) | null;
|
||||
let parentApi: PresentationContainer &
|
||||
HasSerializedChildState<SuperTestStateType> &
|
||||
Partial<HasRuntimeChildState<SuperTestStateType>> &
|
||||
HasSaveNotification;
|
||||
|
||||
beforeEach(() => {
|
||||
initialState = {
|
||||
serializedStateForChild = {
|
||||
name: 'Sir Testsalot',
|
||||
age: 42,
|
||||
tagline: 'A glutton for testing!',
|
||||
tagline: `Oh he's a glutton for testing!`,
|
||||
};
|
||||
lastSavedState = {
|
||||
name: 'Sir Testsalot',
|
||||
age: 42,
|
||||
tagline: 'A glutton for testing!',
|
||||
parentApi = {
|
||||
saveNotification$: new Subject<void>(),
|
||||
...getMockPresentationContainer(),
|
||||
getSerializedStateForChild: () => ({ rawState: serializedStateForChild }),
|
||||
getRuntimeStateForChild: () => undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const initializeDefaultComparators = () => {
|
||||
const nameSubject = new BehaviorSubject<string>(initialState.name);
|
||||
const ageSubject = new BehaviorSubject<number>(initialState.age);
|
||||
const taglineSubject = new BehaviorSubject<string>(initialState.tagline);
|
||||
const latestState: SuperTestStateType = {
|
||||
...serializedStateForChild,
|
||||
...(parentApi.getRuntimeStateForChild?.('uuid') ?? {}),
|
||||
};
|
||||
const nameSubject = new BehaviorSubject<string>(latestState.name);
|
||||
const ageSubject = new BehaviorSubject<number>(latestState.age);
|
||||
const taglineSubject = new BehaviorSubject<string>(latestState.tagline);
|
||||
const defaultComparators: StateComparators<SuperTestStateType> = {
|
||||
name: [nameSubject, jest.fn((nextName) => nameSubject.next(nextName))],
|
||||
age: [ageSubject, jest.fn((nextAge) => ageSubject.next(nextAge))],
|
||||
|
@ -55,49 +64,58 @@ describe('react embeddable unsaved changes', () => {
|
|||
return defaultComparators;
|
||||
};
|
||||
|
||||
const startTrackingUnsavedChanges = (
|
||||
const startTrackingUnsavedChanges = async (
|
||||
customComparators?: StateComparators<SuperTestStateType>
|
||||
) => {
|
||||
comparators = customComparators ?? initializeDefaultComparators();
|
||||
deserializeState = jest.fn((state) => state.rawState as SuperTestStateType);
|
||||
|
||||
parentApi = {
|
||||
...getMockPresentationContainer(),
|
||||
getLastSavedStateForChild: <SerializedState extends object>() => ({
|
||||
rawState: lastSavedState as SerializedState,
|
||||
}),
|
||||
lastSavedState: new Subject<void>(),
|
||||
const factory: ReactEmbeddableFactory<SuperTestStateType> = {
|
||||
type: 'superTest',
|
||||
deserializeState: jest.fn().mockImplementation((state) => state.rawState),
|
||||
buildEmbeddable: async (runtimeState, buildApi) => {
|
||||
const api = buildApi({ serializeState: jest.fn() }, comparators);
|
||||
return { api, Component: () => null };
|
||||
},
|
||||
};
|
||||
return startTrackingEmbeddableUnsavedChanges('id', parentApi, comparators, deserializeState);
|
||||
const { startStateDiffing } = await initializeReactEmbeddableState('uuid', factory, parentApi);
|
||||
return startStateDiffing(comparators);
|
||||
};
|
||||
|
||||
it('should return undefined unsaved changes when used without a parent context to provide the last saved state', async () => {
|
||||
parentApi = null;
|
||||
const unsavedChangesApi = startTrackingUnsavedChanges();
|
||||
it('should return undefined unsaved changes when parent API does not provide runtime state', async () => {
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges();
|
||||
parentApi.getRuntimeStateForChild = undefined;
|
||||
expect(unsavedChangesApi).toBeDefined();
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
});
|
||||
|
||||
it('runs factory deserialize function on last saved state', async () => {
|
||||
startTrackingUnsavedChanges();
|
||||
expect(deserializeState).toHaveBeenCalledWith({ rawState: lastSavedState });
|
||||
it('should return undefined unsaved changes when parent API does not have runtime state for this child', async () => {
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges();
|
||||
// no change here becuase getRuntimeStateForChild already returns undefined
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return unsaved changes subject initialized to undefined when no unsaved changes are detected', async () => {
|
||||
const unsavedChangesApi = startTrackingUnsavedChanges();
|
||||
parentApi.getRuntimeStateForChild = () => ({
|
||||
name: 'Sir Testsalot',
|
||||
age: 42,
|
||||
tagline: `Oh he's a glutton for testing!`,
|
||||
});
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges();
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return unsaved changes subject initialized with diff when unsaved changes are detected', async () => {
|
||||
initialState.tagline = 'Testing is my speciality!';
|
||||
const unsavedChangesApi = startTrackingUnsavedChanges();
|
||||
parentApi.getRuntimeStateForChild = () => ({
|
||||
tagline: 'Testing is my speciality!',
|
||||
});
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges();
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
|
||||
tagline: 'Testing is my speciality!',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect unsaved changes when state changes during the lifetime of the component', async () => {
|
||||
const unsavedChangesApi = startTrackingUnsavedChanges();
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges();
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
|
||||
comparators.tagline[1]('Testing is my speciality!');
|
||||
|
@ -108,22 +126,25 @@ describe('react embeddable unsaved changes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should detect unsaved changes when last saved state changes during the lifetime of the component', async () => {
|
||||
const unsavedChangesApi = startTrackingUnsavedChanges();
|
||||
it('current runtime state should become last saved state when parent save notification is triggered', async () => {
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges();
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
|
||||
lastSavedState.tagline = 'Some other tagline';
|
||||
parentApi?.lastSavedState.next();
|
||||
comparators.tagline[1]('Testing is my speciality!');
|
||||
await waitFor(() => {
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
|
||||
// we expect `A glutton for testing!` here because that is the current state of the component.
|
||||
tagline: 'A glutton for testing!',
|
||||
tagline: 'Testing is my speciality!',
|
||||
});
|
||||
});
|
||||
|
||||
parentApi.saveNotification$.next();
|
||||
await waitFor(() => {
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset unsaved changes, calling given setters with last saved values. This should remove all unsaved state', async () => {
|
||||
const unsavedChangesApi = startTrackingUnsavedChanges();
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges();
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
|
||||
comparators.tagline[1]('Testing is my speciality!');
|
||||
|
@ -134,16 +155,18 @@ describe('react embeddable unsaved changes', () => {
|
|||
});
|
||||
|
||||
unsavedChangesApi.resetUnsavedChanges();
|
||||
expect(comparators.tagline[1]).toHaveBeenCalledWith('A glutton for testing!');
|
||||
expect(comparators.tagline[1]).toHaveBeenCalledWith(`Oh he's a glutton for testing!`);
|
||||
await waitFor(() => {
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a custom comparator when supplied', async () => {
|
||||
lastSavedState.age = 20;
|
||||
initialState.age = 50;
|
||||
const ageSubject = new BehaviorSubject(initialState.age);
|
||||
serializedStateForChild.age = 20;
|
||||
parentApi.getRuntimeStateForChild = () => ({
|
||||
age: 50,
|
||||
});
|
||||
const ageSubject = new BehaviorSubject(50);
|
||||
const customComparators: StateComparators<SuperTestStateType> = {
|
||||
...initializeDefaultComparators(),
|
||||
age: [
|
||||
|
@ -153,7 +176,7 @@ describe('react embeddable unsaved changes', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const unsavedChangesApi = startTrackingUnsavedChanges(customComparators);
|
||||
const unsavedChangesApi = await startTrackingUnsavedChanges(customComparators);
|
||||
|
||||
// here we expect there to be no unsaved changes, both unsaved state and last saved state have two digits.
|
||||
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiHasRuntimeChildState,
|
||||
apiHasSaveNotification,
|
||||
HasSerializedChildState,
|
||||
} from '@kbn/presentation-containers';
|
||||
import {
|
||||
getInitialValuesFromComparators,
|
||||
PublishingSubject,
|
||||
runComparators,
|
||||
StateComparators,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
combineLatestWith,
|
||||
debounceTime,
|
||||
map,
|
||||
Subscription,
|
||||
} from 'rxjs';
|
||||
import { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types';
|
||||
|
||||
export const initializeReactEmbeddableState = async <
|
||||
SerializedState extends object = object,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>,
|
||||
RuntimeState extends object = SerializedState
|
||||
>(
|
||||
uuid: string,
|
||||
factory: ReactEmbeddableFactory<SerializedState, Api, RuntimeState>,
|
||||
parentApi: HasSerializedChildState<SerializedState>
|
||||
) => {
|
||||
const lastSavedRuntimeState = await factory.deserializeState(
|
||||
parentApi.getSerializedStateForChild(uuid)
|
||||
);
|
||||
|
||||
// If the parent provides runtime state for the child (usually as a state backup or cache),
|
||||
// we merge it with the last saved runtime state.
|
||||
const partialRuntimeState = apiHasRuntimeChildState<RuntimeState>(parentApi)
|
||||
? parentApi.getRuntimeStateForChild(uuid) ?? ({} as Partial<RuntimeState>)
|
||||
: ({} as Partial<RuntimeState>);
|
||||
|
||||
const initialRuntimeState = { ...lastSavedRuntimeState, ...partialRuntimeState };
|
||||
|
||||
const startStateDiffing = (comparators: StateComparators<RuntimeState>) => {
|
||||
const subscription = new Subscription();
|
||||
const snapshotRuntimeState = () => {
|
||||
const comparatorKeys = Object.keys(comparators) as Array<keyof RuntimeState>;
|
||||
return comparatorKeys.reduce((acc, key) => {
|
||||
acc[key] = comparators[key][0].value as RuntimeState[typeof key];
|
||||
return acc;
|
||||
}, {} as RuntimeState);
|
||||
};
|
||||
|
||||
// the last saved state subject is always initialized with the deserialized state from the parent.
|
||||
const lastSavedState$ = new BehaviorSubject<RuntimeState | undefined>(lastSavedRuntimeState);
|
||||
if (apiHasSaveNotification(parentApi)) {
|
||||
subscription.add(
|
||||
// any time the parent saves, the current state becomes the last saved state...
|
||||
parentApi.saveNotification$.subscribe(() => {
|
||||
lastSavedState$.next(snapshotRuntimeState());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const comparatorSubjects: Array<PublishingSubject<unknown>> = [];
|
||||
const comparatorKeys: Array<keyof RuntimeState> = [];
|
||||
for (const key of Object.keys(comparators) as Array<keyof RuntimeState>) {
|
||||
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
|
||||
comparatorSubjects.push(comparatorSubject as PublishingSubject<unknown>);
|
||||
comparatorKeys.push(key);
|
||||
}
|
||||
|
||||
const unsavedChanges = new BehaviorSubject<Partial<RuntimeState> | undefined>(
|
||||
runComparators(
|
||||
comparators,
|
||||
comparatorKeys,
|
||||
lastSavedState$.getValue() as RuntimeState,
|
||||
getInitialValuesFromComparators(comparators, comparatorKeys)
|
||||
)
|
||||
);
|
||||
|
||||
subscription.add(
|
||||
combineLatest(comparatorSubjects)
|
||||
.pipe(
|
||||
debounceTime(100),
|
||||
map((latestStates) =>
|
||||
comparatorKeys.reduce((acc, key, index) => {
|
||||
acc[key] = latestStates[index] as RuntimeState[typeof key];
|
||||
return acc;
|
||||
}, {} as Partial<RuntimeState>)
|
||||
),
|
||||
combineLatestWith(lastSavedState$)
|
||||
)
|
||||
.subscribe(([latest, last]) => {
|
||||
unsavedChanges.next(runComparators(comparators, comparatorKeys, last, latest));
|
||||
})
|
||||
);
|
||||
return {
|
||||
unsavedChanges,
|
||||
resetUnsavedChanges: () => {
|
||||
const lastSaved = lastSavedState$.getValue();
|
||||
for (const key of comparatorKeys) {
|
||||
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
|
||||
setter(lastSaved?.[key] as RuntimeState[typeof key]);
|
||||
}
|
||||
},
|
||||
snapshotRuntimeState,
|
||||
cleanup: () => subscription.unsubscribe(),
|
||||
};
|
||||
};
|
||||
|
||||
return { initialState: initialRuntimeState, startStateDiffing };
|
||||
};
|
|
@ -1,93 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
getLastSavedStateSubjectForChild,
|
||||
SerializedPanelState,
|
||||
} from '@kbn/presentation-containers';
|
||||
import {
|
||||
getInitialValuesFromComparators,
|
||||
PublishingSubject,
|
||||
runComparators,
|
||||
StateComparators,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject, combineLatest } from 'rxjs';
|
||||
import { combineLatestWith, debounceTime, map } from 'rxjs';
|
||||
|
||||
const getDefaultDiffingApi = () => {
|
||||
return {
|
||||
unsavedChanges: new BehaviorSubject<object | undefined>(undefined),
|
||||
resetUnsavedChanges: () => {},
|
||||
cleanup: () => {},
|
||||
};
|
||||
};
|
||||
|
||||
export const startTrackingEmbeddableUnsavedChanges = <
|
||||
SerializedState extends object = object,
|
||||
RuntimeState extends object = object
|
||||
>(
|
||||
uuid: string,
|
||||
parentApi: unknown,
|
||||
comparators: StateComparators<RuntimeState>,
|
||||
deserializeState: (state: SerializedPanelState<SerializedState>) => RuntimeState
|
||||
) => {
|
||||
if (Object.keys(comparators).length === 0) return getDefaultDiffingApi();
|
||||
|
||||
const lastSavedStateSubject = getLastSavedStateSubjectForChild<SerializedState, RuntimeState>(
|
||||
parentApi,
|
||||
uuid,
|
||||
deserializeState
|
||||
);
|
||||
if (!lastSavedStateSubject) return getDefaultDiffingApi();
|
||||
|
||||
const comparatorSubjects: Array<PublishingSubject<unknown>> = [];
|
||||
const comparatorKeys: Array<keyof RuntimeState> = [];
|
||||
for (const key of Object.keys(comparators) as Array<keyof RuntimeState>) {
|
||||
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
|
||||
comparatorSubjects.push(comparatorSubject as PublishingSubject<unknown>);
|
||||
comparatorKeys.push(key);
|
||||
}
|
||||
|
||||
const unsavedChanges = new BehaviorSubject<Partial<RuntimeState> | undefined>(
|
||||
runComparators(
|
||||
comparators,
|
||||
comparatorKeys,
|
||||
lastSavedStateSubject?.getValue(),
|
||||
getInitialValuesFromComparators(comparators, comparatorKeys)
|
||||
)
|
||||
);
|
||||
|
||||
const subscription = combineLatest(comparatorSubjects)
|
||||
.pipe(
|
||||
debounceTime(100),
|
||||
map((latestStates) =>
|
||||
comparatorKeys.reduce((acc, key, index) => {
|
||||
acc[key] = latestStates[index] as RuntimeState[typeof key];
|
||||
return acc;
|
||||
}, {} as Partial<RuntimeState>)
|
||||
),
|
||||
combineLatestWith(lastSavedStateSubject)
|
||||
)
|
||||
.subscribe(([latestStates, lastSavedState]) => {
|
||||
unsavedChanges.next(
|
||||
runComparators(comparators, comparatorKeys, lastSavedState, latestStates)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
unsavedChanges,
|
||||
resetUnsavedChanges: () => {
|
||||
const lastSaved = lastSavedStateSubject?.getValue();
|
||||
for (const key of comparatorKeys) {
|
||||
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
|
||||
setter(lastSaved?.[key] as RuntimeState[typeof key]);
|
||||
}
|
||||
},
|
||||
cleanup: () => subscription.unsubscribe(),
|
||||
};
|
||||
};
|
|
@ -5,34 +5,41 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import { HasSerializableState, SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import {
|
||||
HasSerializableState,
|
||||
HasSnapshottableState,
|
||||
SerializedPanelState,
|
||||
} from '@kbn/presentation-containers';
|
||||
import { DefaultPresentationPanelApi } from '@kbn/presentation-panel-plugin/public/panel_component/types';
|
||||
import { HasType, PublishesUnsavedChanges, StateComparators } from '@kbn/presentation-publishing';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
export type ReactEmbeddableRegistration<
|
||||
ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi
|
||||
> = (ref: React.ForwardedRef<ApiType>) => ReactElement | null;
|
||||
import { MaybePromise } from '@kbn/utility-types';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* The default embeddable API that all Embeddables must implement.
|
||||
*
|
||||
* Before adding anything to this interface, please be certain that it belongs in *every* embeddable.
|
||||
*/
|
||||
export interface DefaultEmbeddableApi<SerializedState extends object = object>
|
||||
extends DefaultPresentationPanelApi,
|
||||
export interface DefaultEmbeddableApi<
|
||||
SerializedState extends object = object,
|
||||
RuntimeState extends object = SerializedState
|
||||
> extends DefaultPresentationPanelApi,
|
||||
HasType,
|
||||
PublishesUnsavedChanges,
|
||||
HasSerializableState<SerializedState> {}
|
||||
HasSerializableState<SerializedState>,
|
||||
HasSnapshottableState<RuntimeState> {}
|
||||
|
||||
/**
|
||||
* A subset of the default embeddable API used in registration to allow implementors to omit aspects
|
||||
* of the API that will be automatically added by the system.
|
||||
*/
|
||||
export type ReactEmbeddableApiRegistration<
|
||||
StateType extends object = object,
|
||||
ApiType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
|
||||
> = Omit<ApiType, 'uuid' | 'parent' | 'type' | 'unsavedChanges' | 'resetUnsavedChanges'>;
|
||||
SerializedState extends object = object,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>
|
||||
> = Omit<
|
||||
Api,
|
||||
'uuid' | 'parent' | 'type' | 'unsavedChanges' | 'resetUnsavedChanges' | 'snapshotRuntimeState'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The React Embeddable Factory interface is used to register a series of functions that
|
||||
|
@ -43,7 +50,7 @@ export type ReactEmbeddableApiRegistration<
|
|||
**/
|
||||
export interface ReactEmbeddableFactory<
|
||||
SerializedState extends object = object,
|
||||
ApiType extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>,
|
||||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>,
|
||||
RuntimeState extends object = SerializedState
|
||||
> {
|
||||
/**
|
||||
|
@ -53,16 +60,16 @@ export interface ReactEmbeddableFactory<
|
|||
type: string;
|
||||
|
||||
/**
|
||||
* A required synchronous function that transforms serialized state into runtime state.
|
||||
* This will be used twice - once for the parent state, and once for the last saved state
|
||||
* for comparison.
|
||||
*
|
||||
* This can also be used to:
|
||||
* A required asynchronous function that transforms serialized state into runtime state.
|
||||
*
|
||||
* This could be used to:
|
||||
* - Load state from some external store
|
||||
* - Inject references provided by the parent
|
||||
* - Migrate the state to a newer version (this must be undone when serializing)
|
||||
*/
|
||||
deserializeState: (state: SerializedPanelState<SerializedState>) => RuntimeState;
|
||||
deserializeState: (
|
||||
panelState: SerializedPanelState<SerializedState>
|
||||
) => MaybePromise<RuntimeState>;
|
||||
|
||||
/**
|
||||
* A required async function that builds your embeddable component and a linked API instance. The API
|
||||
|
@ -75,10 +82,10 @@ export interface ReactEmbeddableFactory<
|
|||
buildEmbeddable: (
|
||||
initialState: RuntimeState,
|
||||
buildApi: (
|
||||
apiRegistration: ReactEmbeddableApiRegistration<SerializedState, ApiType>,
|
||||
apiRegistration: ReactEmbeddableApiRegistration<SerializedState, Api>,
|
||||
comparators: StateComparators<RuntimeState>
|
||||
) => ApiType,
|
||||
) => Api,
|
||||
uuid: string,
|
||||
parentApi?: unknown
|
||||
) => Promise<{ Component: React.FC<{}>; api: ApiType }>;
|
||||
) => Promise<{ Component: React.FC<{}>; api: Api }>;
|
||||
}
|
||||
|
|
|
@ -65,9 +65,13 @@ const renderReactEmbeddable = ({
|
|||
<ReactEmbeddableRenderer
|
||||
type={type}
|
||||
maybeId={uuid}
|
||||
parentApi={canvasApi}
|
||||
getParentApi={(): CanvasContainerApi => ({
|
||||
...container,
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: input,
|
||||
}),
|
||||
})}
|
||||
key={`${type}_${uuid}`}
|
||||
state={{ rawState: input }}
|
||||
onAnyStateChange={(newState) => {
|
||||
const newExpression = embeddableInputToExpression(
|
||||
newState.rawState as unknown as EmbeddableInput,
|
||||
|
|
|
@ -9,7 +9,8 @@ import { useCallback, useMemo } from 'react';
|
|||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/common';
|
||||
import { EmbeddableInput } from '@kbn/embeddable-plugin/common';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
|
||||
import { embeddableInputToExpression } from '../../../canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression';
|
||||
import { CanvasContainerApi } from '../../../types';
|
||||
|
@ -35,9 +36,9 @@ export const useCanvasApi: () => CanvasContainerApi = () => {
|
|||
[selectedPageId, dispatch]
|
||||
);
|
||||
|
||||
const getCanvasApi = useCallback(() => {
|
||||
const getCanvasApi = useCallback((): CanvasContainerApi => {
|
||||
return {
|
||||
viewMode: new BehaviorSubject<ViewMode>(ViewMode.EDIT), // always in edit mode
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'), // always in edit mode
|
||||
addNewPanel: async ({
|
||||
panelType,
|
||||
initialState,
|
||||
|
@ -47,7 +48,11 @@ export const useCanvasApi: () => CanvasContainerApi = () => {
|
|||
}) => {
|
||||
createNewEmbeddable(panelType, initialState);
|
||||
},
|
||||
} as CanvasContainerApi;
|
||||
/**
|
||||
* getSerializedStateForChild is left out here because we cannot access the state here. That method
|
||||
* is injected in `x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx`
|
||||
*/
|
||||
} as unknown as CanvasContainerApi;
|
||||
}, [createNewEmbeddable]);
|
||||
|
||||
return useMemo(() => getCanvasApi(), [getCanvasApi]);
|
||||
|
|
|
@ -9,7 +9,7 @@ import type { TimeRange } from '@kbn/es-query';
|
|||
import { Filter } from '@kbn/es-query';
|
||||
import { EmbeddableInput as Input } from '@kbn/embeddable-plugin/common';
|
||||
import { HasAppContext, PublishesViewMode } from '@kbn/presentation-publishing';
|
||||
import { CanAddNewPanel } from '@kbn/presentation-containers';
|
||||
import { CanAddNewPanel, HasSerializedChildState } from '@kbn/presentation-containers';
|
||||
|
||||
export type EmbeddableInput = Input & {
|
||||
timeRange?: TimeRange;
|
||||
|
@ -17,4 +17,7 @@ export type EmbeddableInput = Input & {
|
|||
savedObjectId?: string;
|
||||
};
|
||||
|
||||
export type CanvasContainerApi = PublishesViewMode & CanAddNewPanel & Partial<HasAppContext>;
|
||||
export type CanvasContainerApi = PublishesViewMode &
|
||||
CanAddNewPanel &
|
||||
HasSerializedChildState &
|
||||
Partial<HasAppContext>;
|
||||
|
|
|
@ -86,16 +86,16 @@ export const initComponent = memoize((fieldFormats: FieldFormatsStart) => {
|
|||
<ReactEmbeddableRenderer<AnomalySwimLaneEmbeddableState, AnomalySwimLaneEmbeddableApi>
|
||||
maybeId={inputProps.id}
|
||||
type={CASE_ATTACHMENT_TYPE_ID_ANOMALY_SWIMLANE}
|
||||
state={{
|
||||
rawState: inputProps,
|
||||
}}
|
||||
parentApi={{
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({
|
||||
rawState: inputProps,
|
||||
}),
|
||||
executionContext: {
|
||||
type: 'cases',
|
||||
description: caseData.title,
|
||||
id: caseData.id,
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -100,16 +100,14 @@ describe('getAnomalySwimLaneEmbeddableFactory', () => {
|
|||
<ReactEmbeddableRenderer<AnomalySwimLaneEmbeddableState, AnomalySwimLaneEmbeddableApi>
|
||||
maybeId={'maybe_id'}
|
||||
type={ANOMALY_SWIMLANE_EMBEDDABLE_TYPE}
|
||||
state={{
|
||||
rawState,
|
||||
}}
|
||||
onApiAvailable={onApiAvailable}
|
||||
parentApi={{
|
||||
getParentApi={() => ({
|
||||
getSerializedStateForChild: () => ({ rawState }),
|
||||
executionContext: {
|
||||
type: 'dashboard',
|
||||
id: 'dashboard-id',
|
||||
},
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import type { KibanaExecutionContext } from '@kbn/core/public';
|
|||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import type { PublishesWritableUnifiedSearch } from '@kbn/presentation-publishing';
|
||||
import type { HasSerializedChildState } from '@kbn/presentation-containers';
|
||||
import React, { useEffect, useMemo, useRef, type FC } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type {
|
||||
|
@ -72,13 +73,16 @@ export const AnomalySwimLane: FC<AnomalySwimLaneProps> = ({
|
|||
);
|
||||
|
||||
const parentApi = useMemo<
|
||||
PublishesWritableUnifiedSearch & { executionContext: KibanaExecutionContext }
|
||||
PublishesWritableUnifiedSearch & {
|
||||
executionContext: KibanaExecutionContext;
|
||||
} & HasSerializedChildState<AnomalySwimLaneEmbeddableState>
|
||||
>(() => {
|
||||
const filters$ = new BehaviorSubject<Filter[] | undefined>(filters);
|
||||
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(query);
|
||||
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(timeRange);
|
||||
|
||||
return {
|
||||
getSerializedStateForChild: () => ({ rawState }),
|
||||
filters$,
|
||||
setFilters: (newFilters) => {
|
||||
filters$.next(newFilters);
|
||||
|
@ -115,10 +119,7 @@ export const AnomalySwimLane: FC<AnomalySwimLaneProps> = ({
|
|||
<ReactEmbeddableRenderer<AnomalySwimLaneEmbeddableState, AnomalySwimLaneEmbeddableApi>
|
||||
maybeId={id}
|
||||
type={ANOMALY_SWIMLANE_EMBEDDABLE_TYPE}
|
||||
state={{
|
||||
rawState,
|
||||
}}
|
||||
parentApi={parentApi}
|
||||
getParentApi={() => parentApi}
|
||||
onApiAvailable={(api) => {
|
||||
embeddableApi.current = api;
|
||||
}}
|
||||
|
|
|
@ -82,7 +82,7 @@ export function APMEmbeddableRoot({
|
|||
return (
|
||||
<ReactEmbeddableRenderer
|
||||
type={embeddableId}
|
||||
state={{ rawState: input }}
|
||||
getParentApi={() => ({ getSerializedStateForChild: () => ({ rawState: input }) })}
|
||||
hidePanelChrome={true}
|
||||
/>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue