mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[embeddables] state management example (#192587)
Extend embeddable examples with a state management example. PR also refactors embeddable examples to use side nav instead of tabs. <img width="800" alt="Screenshot 2024-09-11 at 8 38 28 AM" src="https://github.com/user-attachments/assets/ac46600f-2c45-4f9e-b4f8-a5c03f4eef2f"> --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
8d90d989d7
commit
32c9913c2f
15 changed files with 401 additions and 141 deletions
|
@ -7,107 +7,98 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { AppMountParameters, CoreStart } from '@kbn/core/public';
|
||||
import {
|
||||
EuiPage,
|
||||
EuiPageBody,
|
||||
EuiPageHeader,
|
||||
EuiPageSection,
|
||||
EuiPageTemplate,
|
||||
EuiSpacer,
|
||||
EuiTab,
|
||||
EuiTabs,
|
||||
} from '@elastic/eui';
|
||||
import { BrowserRouter as Router, Routes, Route } from '@kbn/shared-ux-router';
|
||||
import { EuiPageTemplate, EuiTitle } from '@elastic/eui';
|
||||
import { Overview } from './overview';
|
||||
import { RegisterEmbeddable } from './register_embeddable';
|
||||
import { RenderExamples } from './render_examples';
|
||||
import { PresentationContainerExample } from './presentation_container_example/components/presentation_container_example';
|
||||
import { StartDeps } from '../plugin';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { StateManagementExample } from './state_management_example/state_management_example';
|
||||
|
||||
const OVERVIEW_TAB_ID = 'overview';
|
||||
const REGISTER_EMBEDDABLE_TAB_ID = 'register';
|
||||
const RENDER_TAB_ID = 'render';
|
||||
const PRESENTATION_CONTAINER_EXAMPLE_ID = 'presentationContainerExample';
|
||||
const App = ({
|
||||
core,
|
||||
deps,
|
||||
mountParams,
|
||||
}: {
|
||||
core: CoreStart;
|
||||
deps: StartDeps;
|
||||
mountParams: AppMountParameters;
|
||||
}) => {
|
||||
const pages = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
title: 'Embeddables overview',
|
||||
component: <Overview />,
|
||||
},
|
||||
{
|
||||
id: 'registerEmbeddable',
|
||||
title: 'Register a new embeddable type',
|
||||
component: <RegisterEmbeddable />,
|
||||
},
|
||||
{
|
||||
id: 'renderEmbeddable',
|
||||
title: 'Render embeddables in your application',
|
||||
component: <RenderExamples />,
|
||||
},
|
||||
{
|
||||
id: 'stateManagement',
|
||||
title: 'Embeddable state management',
|
||||
component: <StateManagementExample uiActions={deps.uiActions} />,
|
||||
},
|
||||
{
|
||||
id: 'presentationContainer',
|
||||
title: 'Create a dashboard like experience with embeddables',
|
||||
component: <PresentationContainerExample uiActions={deps.uiActions} />,
|
||||
},
|
||||
];
|
||||
}, [deps.uiActions]);
|
||||
|
||||
const App = ({ core, deps }: { core: CoreStart; deps: StartDeps }) => {
|
||||
const [selectedTabId, setSelectedTabId] = useState(OVERVIEW_TAB_ID);
|
||||
|
||||
function onSelectedTabChanged(tabId: string) {
|
||||
setSelectedTabId(tabId);
|
||||
}
|
||||
|
||||
function renderTabContent() {
|
||||
if (selectedTabId === RENDER_TAB_ID) {
|
||||
return <RenderExamples />;
|
||||
}
|
||||
|
||||
if (selectedTabId === REGISTER_EMBEDDABLE_TAB_ID) {
|
||||
return <RegisterEmbeddable />;
|
||||
}
|
||||
|
||||
if (selectedTabId === PRESENTATION_CONTAINER_EXAMPLE_ID) {
|
||||
return <PresentationContainerExample uiActions={deps.uiActions} />;
|
||||
}
|
||||
|
||||
return <Overview />;
|
||||
}
|
||||
const routes = useMemo(() => {
|
||||
return pages.map((page) => (
|
||||
<Route
|
||||
key={page.id}
|
||||
path={`/${page.id}`}
|
||||
render={(props) => (
|
||||
<>
|
||||
<EuiPageTemplate.Header>
|
||||
<EuiTitle size="l">
|
||||
<h1 data-test-subj="responseStreamPageTitle">{page.title}</h1>
|
||||
</EuiTitle>
|
||||
</EuiPageTemplate.Header>
|
||||
<EuiPageTemplate.Section>{page.component}</EuiPageTemplate.Section>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
}, [pages]);
|
||||
|
||||
return (
|
||||
<KibanaRenderContextProvider i18n={core.i18n} theme={core.theme}>
|
||||
<EuiPage>
|
||||
<EuiPageBody>
|
||||
<EuiPageSection>
|
||||
<EuiPageHeader pageTitle="Embeddables" />
|
||||
</EuiPageSection>
|
||||
<EuiPageTemplate.Section>
|
||||
<EuiPageSection>
|
||||
<EuiTabs>
|
||||
<EuiTab
|
||||
onClick={() => onSelectedTabChanged(OVERVIEW_TAB_ID)}
|
||||
isSelected={OVERVIEW_TAB_ID === selectedTabId}
|
||||
>
|
||||
Embeddables overview
|
||||
</EuiTab>
|
||||
<EuiTab
|
||||
onClick={() => onSelectedTabChanged(REGISTER_EMBEDDABLE_TAB_ID)}
|
||||
isSelected={REGISTER_EMBEDDABLE_TAB_ID === selectedTabId}
|
||||
>
|
||||
Register new embeddable type
|
||||
</EuiTab>
|
||||
<EuiTab
|
||||
onClick={() => onSelectedTabChanged(RENDER_TAB_ID)}
|
||||
isSelected={RENDER_TAB_ID === selectedTabId}
|
||||
>
|
||||
Rendering embeddables in your application
|
||||
</EuiTab>
|
||||
<EuiTab
|
||||
onClick={() => onSelectedTabChanged(PRESENTATION_CONTAINER_EXAMPLE_ID)}
|
||||
isSelected={PRESENTATION_CONTAINER_EXAMPLE_ID === selectedTabId}
|
||||
>
|
||||
PresentationContainer example
|
||||
</EuiTab>
|
||||
</EuiTabs>
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
{renderTabContent()}
|
||||
</EuiPageSection>
|
||||
</EuiPageTemplate.Section>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
<Router basename={mountParams.appBasePath}>
|
||||
<EuiPageTemplate restrictWidth={true} offset={0}>
|
||||
<EuiPageTemplate.Sidebar sticky={true}>
|
||||
<Sidebar pages={pages} />
|
||||
</EuiPageTemplate.Sidebar>
|
||||
<Routes>
|
||||
{routes}
|
||||
<Redirect to="/overview" />
|
||||
</Routes>
|
||||
</EuiPageTemplate>
|
||||
</Router>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderApp = (
|
||||
core: CoreStart,
|
||||
deps: StartDeps,
|
||||
element: AppMountParameters['element']
|
||||
) => {
|
||||
ReactDOM.render(<App core={core} deps={deps} />, element);
|
||||
export const renderApp = (core: CoreStart, deps: StartDeps, mountParams: AppMountParameters) => {
|
||||
ReactDOM.render(<App core={core} deps={deps} mountParams={mountParams} />, mountParams.element);
|
||||
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
return () => ReactDOM.unmountComponentAtNode(mountParams.element);
|
||||
};
|
||||
|
|
|
@ -10,15 +10,9 @@
|
|||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import { ADD_PANEL_TRIGGER, UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { ParentApi } from '../types';
|
||||
import { PageApi } from '../types';
|
||||
|
||||
export function AddButton({
|
||||
parentApi,
|
||||
uiActions,
|
||||
}: {
|
||||
parentApi: ParentApi;
|
||||
uiActions: UiActionsStart;
|
||||
}) {
|
||||
export function AddButton({ pageApi, uiActions }: { pageApi: PageApi; uiActions: UiActionsStart }) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [items, setItems] = useState<ReactElement[]>([]);
|
||||
|
||||
|
@ -26,7 +20,7 @@ export function AddButton({
|
|||
let cancelled = false;
|
||||
|
||||
const actionContext = {
|
||||
embeddable: parentApi,
|
||||
embeddable: pageApi,
|
||||
trigger: {
|
||||
id: ADD_PANEL_TRIGGER,
|
||||
},
|
||||
|
@ -67,7 +61,7 @@ export function AddButton({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [parentApi, uiActions]);
|
||||
}, [pageApi, uiActions]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
|
|
|
@ -19,15 +19,15 @@ import {
|
|||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { getParentApi } from '../parent_api';
|
||||
import { getPageApi } from '../page_api';
|
||||
import { AddButton } from './add_button';
|
||||
import { TopNav } from './top_nav';
|
||||
import { lastSavedStateSessionStorage } from '../session_storage/last_saved_state';
|
||||
import { unsavedChangesSessionStorage } from '../session_storage/unsaved_changes';
|
||||
|
||||
export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActionsStart }) => {
|
||||
const { cleanUp, componentApi, parentApi } = useMemo(() => {
|
||||
return getParentApi();
|
||||
const { cleanUp, componentApi, pageApi } = useMemo(() => {
|
||||
return getPageApi();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -37,25 +37,23 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
|
|||
}, [cleanUp]);
|
||||
|
||||
const [dataLoading, panels, timeRange] = useBatchedPublishingSubjects(
|
||||
parentApi.dataLoading,
|
||||
pageApi.dataLoading,
|
||||
componentApi.panels$,
|
||||
parentApi.timeRange$
|
||||
pageApi.timeRange$
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EuiCallOut title="Presentation Container interfaces">
|
||||
<p>
|
||||
At times, you will need to render many embeddables and allow users to add, remove, and
|
||||
re-arrange embeddables. Use the <strong>PresentationContainer</strong> and{' '}
|
||||
At times, you will need to render many embeddables and allow users to add and remove
|
||||
embeddables. Use the <strong>PresentationContainer</strong> and{' '}
|
||||
<strong>CanAddNewPanel</strong> interfaces for this functionallity.
|
||||
</p>
|
||||
<p>
|
||||
Each embeddable manages its own state. The page is only responsible for persisting and
|
||||
providing the last persisted state to the embeddable. Implement{' '}
|
||||
<strong>HasSerializedChildState</strong> interface to provide an embeddable with last
|
||||
persisted state. Implement <strong>HasRuntimeChildState</strong> interface to provide an
|
||||
embeddable with a previous session's unsaved changes.
|
||||
New embeddable state is provided to the page by calling{' '}
|
||||
<strong>pageApi.addNewPanel</strong>. The page provides new embeddable state to the
|
||||
embeddable with <strong>pageApi.getRuntimeStateForChild</strong>.
|
||||
</p>
|
||||
<p>
|
||||
This example uses session storage to persist saved state and unsaved changes while a
|
||||
|
@ -96,8 +94,8 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
|
|||
<EuiFlexItem grow={false}>
|
||||
<TopNav
|
||||
onSave={componentApi.onSave}
|
||||
resetUnsavedChanges={parentApi.resetUnsavedChanges}
|
||||
unsavedChanges$={parentApi.unsavedChanges}
|
||||
resetUnsavedChanges={pageApi.resetUnsavedChanges}
|
||||
unsavedChanges$={pageApi.unsavedChanges}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
@ -110,7 +108,7 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
|
|||
<ReactEmbeddableRenderer
|
||||
type={type}
|
||||
maybeId={id}
|
||||
getParentApi={() => parentApi}
|
||||
getParentApi={() => pageApi}
|
||||
hidePanelChrome={false}
|
||||
onApiAvailable={(api) => {
|
||||
componentApi.setChild(id, api);
|
||||
|
@ -121,7 +119,7 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
|
|||
);
|
||||
})}
|
||||
|
||||
<AddButton parentApi={parentApi} uiActions={uiActions} />
|
||||
<AddButton pageApi={pageApi} uiActions={uiActions} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -27,9 +27,9 @@ import {
|
|||
} from '@kbn/presentation-publishing';
|
||||
import { DEFAULT_STATE, lastSavedStateSessionStorage } from './session_storage/last_saved_state';
|
||||
import { unsavedChangesSessionStorage } from './session_storage/unsaved_changes';
|
||||
import { LastSavedState, ParentApi, UnsavedChanges } from './types';
|
||||
import { LastSavedState, PageApi, UnsavedChanges } from './types';
|
||||
|
||||
export function getParentApi() {
|
||||
export function getPageApi() {
|
||||
const initialUnsavedChanges = unsavedChangesSessionStorage.load();
|
||||
const initialSavedState = lastSavedStateSessionStorage.load();
|
||||
let newPanels: Record<string, object> = {};
|
||||
|
@ -185,7 +185,7 @@ export function getParentApi() {
|
|||
timeRange$.next(timeRange);
|
||||
},
|
||||
},
|
||||
parentApi: {
|
||||
pageApi: {
|
||||
addNewPanel: async ({ panelType, initialState }: PanelPackage) => {
|
||||
const id = generateId();
|
||||
panels$.next([...panels$.value, { id, type: panelType }]);
|
||||
|
@ -257,6 +257,6 @@ export function getParentApi() {
|
|||
},
|
||||
timeRange$,
|
||||
unsavedChanges: unsavedChanges$ as PublishingSubject<object | undefined>,
|
||||
} as ParentApi,
|
||||
} as PageApi,
|
||||
};
|
||||
}
|
|
@ -26,7 +26,7 @@ import {
|
|||
} from '@kbn/presentation-publishing';
|
||||
import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
|
||||
|
||||
export type ParentApi = PresentationContainer &
|
||||
export type PageApi = PresentationContainer &
|
||||
CanAddNewPanel &
|
||||
HasExecutionContext &
|
||||
HasSaveNotification &
|
||||
|
|
|
@ -19,10 +19,10 @@ export function setupApp(core: CoreSetup<StartDeps>, developerExamples: Develope
|
|||
id: APP_ID,
|
||||
title,
|
||||
visibleIn: [],
|
||||
async mount(params: AppMountParameters) {
|
||||
async mount(mountParams: AppMountParameters) {
|
||||
const { renderApp } = await import('./app');
|
||||
const [coreStart, deps] = await core.getStartServices();
|
||||
return renderApp(coreStart, deps, params.element);
|
||||
return renderApp(coreStart, deps, mountParams);
|
||||
},
|
||||
});
|
||||
developerExamples.register({
|
||||
|
|
39
examples/embeddable_examples/public/app/sidebar.tsx
Normal file
39
examples/embeddable_examples/public/app/sidebar.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { EuiSideNav } from '@elastic/eui';
|
||||
|
||||
export function Sidebar({ pages }: { pages: Array<{ id: string; title: string }> }) {
|
||||
const history = useHistory();
|
||||
|
||||
const items = useMemo(() => {
|
||||
return pages.map((page) => {
|
||||
return {
|
||||
id: page.id,
|
||||
name: page.title,
|
||||
onClick: () => history.push(`/${page.id}`),
|
||||
};
|
||||
});
|
||||
}, [pages, history]);
|
||||
|
||||
return (
|
||||
<EuiSideNav
|
||||
css={{
|
||||
css: css`
|
||||
maxwidth: '85%';
|
||||
`,
|
||||
}}
|
||||
truncate={false}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { SerializedPanelState } from '@kbn/presentation-containers';
|
||||
import { BookSerializedState } from '../../react_embeddables/saved_book/types';
|
||||
|
||||
const SAVED_STATE_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.stateManagementExample.savedState';
|
||||
|
||||
export const lastSavedStateSessionStorage = {
|
||||
clear: () => {
|
||||
sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
},
|
||||
load: (): SerializedPanelState<BookSerializedState> | undefined => {
|
||||
const savedState = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY);
|
||||
return savedState ? JSON.parse(savedState) : undefined;
|
||||
},
|
||||
save: (state: SerializedPanelState<BookSerializedState>) => {
|
||||
sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(state));
|
||||
},
|
||||
};
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import { SAVED_BOOK_ID } from '../../react_embeddables/saved_book/constants';
|
||||
import {
|
||||
BookApi,
|
||||
BookRuntimeState,
|
||||
BookSerializedState,
|
||||
} from '../../react_embeddables/saved_book/types';
|
||||
import { lastSavedStateSessionStorage } from './last_saved_state';
|
||||
import { unsavedChangesSessionStorage } from './unsaved_changes';
|
||||
|
||||
export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStart }) => {
|
||||
const isMounted = useMountedState();
|
||||
const saveNotification$ = useMemo(() => {
|
||||
return new Subject<void>();
|
||||
}, []);
|
||||
|
||||
const [bookApi, setBookApi] = useState<BookApi | undefined>();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookApi || !bookApi.unsavedChanges) {
|
||||
return;
|
||||
}
|
||||
const subscription = bookApi.unsavedChanges.subscribe((unsavedChanges) => {
|
||||
setHasUnsavedChanges(unsavedChanges !== undefined);
|
||||
unsavedChangesSessionStorage.save(unsavedChanges ?? {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [bookApi]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EuiCallOut>
|
||||
<p>
|
||||
Each embeddable manages its own state. The page is only responsible for persisting and
|
||||
providing the last persisted state to the embeddable.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The page renders the embeddable with <strong>ReactEmbeddableRenderer</strong> component.
|
||||
On mount, ReactEmbeddableRenderer component calls{' '}
|
||||
<strong>pageApi.getSerializedStateForChild</strong> to get the last saved state.
|
||||
ReactEmbeddableRenderer component then calls{' '}
|
||||
<strong>pageApi.getRuntimeStateForChild</strong> to get the last session's unsaved
|
||||
changes. ReactEmbeddableRenderer merges last saved state with unsaved changes and passes
|
||||
the merged state to the embeddable factory. ReactEmbeddableRender passes the embeddableApi
|
||||
to the page by calling <strong>onApiAvailable</strong>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The page subscribes to <strong>embeddableApi.unsavedChanges</strong> to receive embeddable
|
||||
unsaved changes. The page persists unsaved changes in session storage. The page provides
|
||||
unsaved changes to the embeddable with <strong>pageApi.getRuntimeStateForChild</strong>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The page gets embeddable state by calling <strong>embeddableApi.serializeState</strong>.
|
||||
The page persists embeddable state in session storage. The page provides last saved state
|
||||
to the embeddable with <strong>pageApi.getSerializedStateForChild</strong>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<EuiButtonEmpty
|
||||
color={'warning'}
|
||||
onClick={() => {
|
||||
lastSavedStateSessionStorage.clear();
|
||||
unsavedChangesSessionStorage.clear();
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Reset example
|
||||
</EuiButtonEmpty>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
{hasUnsavedChanges && (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge color="warning">Unsaved changes</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
disabled={isSaving || !bookApi}
|
||||
onClick={() => {
|
||||
bookApi?.resetUnsavedChanges?.();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
onClick={async () => {
|
||||
if (!bookApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
const bookSerializedState = await bookApi.serializeState();
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
lastSavedStateSessionStorage.save(bookSerializedState);
|
||||
saveNotification$.next(); // signals embeddable unsaved change tracking to update last saved state
|
||||
setHasUnsavedChanges(false);
|
||||
setIsSaving(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<ReactEmbeddableRenderer<BookSerializedState, BookRuntimeState, BookApi>
|
||||
type={SAVED_BOOK_ID}
|
||||
getParentApi={() => {
|
||||
return {
|
||||
/**
|
||||
* return last saved embeddable state
|
||||
*/
|
||||
getSerializedStateForChild: (childId: string) => {
|
||||
return lastSavedStateSessionStorage.load();
|
||||
},
|
||||
/**
|
||||
* return previous session's unsaved changes for embeddable
|
||||
*/
|
||||
getRuntimeStateForChild: (childId: string) => {
|
||||
return unsavedChangesSessionStorage.load();
|
||||
},
|
||||
saveNotification$,
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
};
|
||||
}}
|
||||
onApiAvailable={(api) => {
|
||||
setBookApi(api);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BookRuntimeState } from '../../react_embeddables/saved_book/types';
|
||||
|
||||
const UNSAVED_CHANGES_SESSION_STORAGE_KEY =
|
||||
'kibana.examples.embeddables.stateManagementExample.unsavedChanges';
|
||||
|
||||
export const unsavedChangesSessionStorage = {
|
||||
clear: () => {
|
||||
sessionStorage.removeItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
|
||||
},
|
||||
load: (): Partial<BookRuntimeState> | undefined => {
|
||||
const unsavedChanges = sessionStorage.getItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
|
||||
return unsavedChanges ? JSON.parse(unsavedChanges) : undefined;
|
||||
},
|
||||
save: (unsavedChanges: Partial<BookRuntimeState>) => {
|
||||
sessionStorage.setItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY, JSON.stringify(unsavedChanges));
|
||||
},
|
||||
};
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
|
||||
import { apiCanAddNewPanel } from '@kbn/presentation-containers';
|
||||
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
|
||||
import { IncompatibleActionError, ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
|
||||
import { UiActionsPublicStart } from '@kbn/ui-actions-plugin/public/plugin';
|
||||
|
@ -30,10 +30,10 @@ export const registerCreateSavedBookAction = (uiActions: UiActionsPublicStart, c
|
|||
getIconType: () => 'folderClosed',
|
||||
grouping: [embeddableExamplesGrouping],
|
||||
isCompatible: async ({ embeddable }) => {
|
||||
return apiIsPresentationContainer(embeddable);
|
||||
return apiCanAddNewPanel(embeddable);
|
||||
},
|
||||
execute: async ({ embeddable }) => {
|
||||
if (!apiIsPresentationContainer(embeddable)) throw new IncompatibleActionError();
|
||||
if (!apiCanAddNewPanel(embeddable)) throw new IncompatibleActionError();
|
||||
const newPanelStateManager = stateManagerFromAttributes(defaultBookAttributes);
|
||||
|
||||
const { addToLibrary } = await openSavedBookEditor(newPanelStateManager, true, core, {
|
||||
|
|
|
@ -135,7 +135,7 @@ export const SavedBookEditor = ({
|
|||
})}
|
||||
>
|
||||
<EuiFieldText
|
||||
value={authorName}
|
||||
value={authorName ?? ''}
|
||||
onChange={(e) => attributesManager.authorName.next(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
@ -145,7 +145,7 @@ export const SavedBookEditor = ({
|
|||
})}
|
||||
>
|
||||
<EuiFieldText
|
||||
value={bookTitle}
|
||||
value={bookTitle ?? ''}
|
||||
onChange={(e) => attributesManager.bookTitle.next(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
@ -155,7 +155,7 @@ export const SavedBookEditor = ({
|
|||
})}
|
||||
>
|
||||
<EuiFieldNumber
|
||||
value={numberOfPages}
|
||||
value={numberOfPages ?? ''}
|
||||
onChange={(e) => attributesManager.numberOfPages.next(+e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
@ -165,7 +165,7 @@ export const SavedBookEditor = ({
|
|||
})}
|
||||
>
|
||||
<EuiTextArea
|
||||
value={synopsis}
|
||||
value={synopsis ?? ''}
|
||||
onChange={(e) => attributesManager.bookSynopsis.next(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
|
|
@ -13,6 +13,7 @@ import { CoreStart } from '@kbn/core-lifecycle-browser';
|
|||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
apiHasParentApi,
|
||||
initializeTitles,
|
||||
SerializedTitles,
|
||||
useBatchedPublishingSubjects,
|
||||
|
@ -20,6 +21,7 @@ import {
|
|||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { PresentationContainer } from '@kbn/presentation-containers';
|
||||
import { serializeBookAttributes, stateManagerFromAttributes } from './book_state';
|
||||
import { SAVED_BOOK_ID } from './constants';
|
||||
import { openSavedBookEditor } from './saved_book_editor';
|
||||
|
@ -137,6 +139,10 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => {
|
|||
}
|
||||
);
|
||||
|
||||
const showLibraryCallout =
|
||||
apiHasParentApi(api) &&
|
||||
typeof (api.parentApi as PresentationContainer)?.replacePanel === 'function';
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
|
@ -155,20 +161,22 @@ export const getSavedBookEmbeddableFactory = (core: CoreStart) => {
|
|||
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'}
|
||||
/>
|
||||
{showLibraryCallout && (
|
||||
<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};
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/core-mount-utils-browser",
|
||||
"@kbn/react-kibana-mount",
|
||||
"@kbn/std"
|
||||
"@kbn/std",
|
||||
"@kbn/shared-ux-router"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ Kibana is a React application, and the minimum unit of sharing is the React comp
|
|||
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.
|
||||
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. For implementation details, see [Embeddable state management example](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/app/state_management_example/state_management_example.tsx).
|
||||
|
||||
### Key concepts
|
||||
|
||||
|
@ -61,4 +61,5 @@ Use the following examples to learn how to create new Embeddable types. To acces
|
|||
Use the following examples to render embeddables in your application. To run embeddable examples, navigate to `http://localhost:5601/app/embeddablesApp`
|
||||
|
||||
- [Render a single embeddable](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/search_embeddable_renderer.tsx)
|
||||
- [Embeddable state management](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/app/state_management_example/state_management_example.tsx)
|
||||
- [Create a dashboard like application that renders many embeddables and allows users to add and remove embeddables](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx)
|
Loading…
Add table
Add a link
Reference in a new issue