[embeddables] PresentationContainer example (#191891)

PR adds PresentationContainer to embeddable examples. This example shows
developers how to create a dashboard like experience with embeddable
registry and PresentationContainer interfaces

### Test instructions
1. start kibana with `yarn start --run-examples`
2. install web logs sample data
3. navigate to `http://localhost:5601/app/embeddablesApp`
4. Select `PresentationContainer example` tab

### Presentation container example

Example starts with empty display and users can add embeddables
<img width="800" alt="Screenshot 2024-09-05 at 1 58 55 PM"
src="https://github.com/user-attachments/assets/406651ca-e611-4a3e-8b2d-4207eb5011b1">

Editing time range, adding or removing panels, or changing panel state
by adding custom time ranges will show unsaved changes and allow user to
reset changes or save changes
<img width="800" alt="Screenshot 2024-09-05 at 1 59 51 PM"
src="https://github.com/user-attachments/assets/cd84e39c-6de0-4ac6-832f-7d8e3682610b">


After adding an embeddable, user can use panel actions to remove the
embeddable
<img width="800" alt="Screenshot 2024-09-05 at 2 00 50 PM"
src="https://github.com/user-attachments/assets/63a2515c-f378-419f-b2f5-db71712fdffc">

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-09-06 14:14:45 -06:00 committed by GitHub
parent d177d11719
commit 832bc99181
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 746 additions and 71 deletions

View file

@ -8,8 +8,8 @@
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters } from '@kbn/core-application-browser';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import {
EuiPage,
EuiPageBody,
@ -23,12 +23,15 @@ import {
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';
const OVERVIEW_TAB_ID = 'overview';
const REGISTER_EMBEDDABLE_TAB_ID = 'register';
const RENDER_TAB_ID = 'render';
const PRESENTATION_CONTAINER_EXAMPLE_ID = 'presentationContainerExample';
const App = () => {
const App = ({ core, deps }: { core: CoreStart; deps: StartDeps }) => {
const [selectedTabId, setSelectedTabId] = useState(OVERVIEW_TAB_ID);
function onSelectedTabChanged(tabId: string) {
@ -44,50 +47,66 @@ const App = () => {
return <RegisterEmbeddable />;
}
if (selectedTabId === PRESENTATION_CONTAINER_EXAMPLE_ID) {
return <PresentationContainerExample uiActions={deps.uiActions} />;
}
return <Overview />;
}
return (
<EuiPage>
<EuiPageBody>
<EuiPageSection>
<EuiPageHeader pageTitle="Embeddables" />
</EuiPageSection>
<EuiPageTemplate.Section>
<KibanaRenderContextProvider i18n={core.i18n} theme={core.theme}>
<EuiPage>
<EuiPageBody>
<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>
</EuiTabs>
<EuiSpacer />
{renderTabContent()}
<EuiPageHeader pageTitle="Embeddables" />
</EuiPageSection>
</EuiPageTemplate.Section>
</EuiPageBody>
</EuiPage>
<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>
</KibanaRenderContextProvider>
);
};
export const renderApp = (element: AppMountParameters['element']) => {
ReactDOM.render(<App />, element);
export const renderApp = (
core: CoreStart,
deps: StartDeps,
element: AppMountParameters['element']
) => {
ReactDOM.render(<App core={core} deps={deps} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,94 @@
/*
* 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 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';
export function AddButton({
parentApi,
uiActions,
}: {
parentApi: ParentApi;
uiActions: UiActionsStart;
}) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [items, setItems] = useState<ReactElement[]>([]);
useEffect(() => {
let cancelled = false;
const actionContext = {
embeddable: parentApi,
trigger: {
id: ADD_PANEL_TRIGGER,
},
};
const actionsPromises = uiActions.getTriggerActions(ADD_PANEL_TRIGGER).map(async (action) => {
return {
isCompatible: await action.isCompatible(actionContext),
action,
};
});
Promise.all(actionsPromises).then((actions) => {
if (cancelled) {
return;
}
const nextItems = actions
.filter(
({ action, isCompatible }) => isCompatible && action.id !== 'ACTION_CREATE_ESQL_CHART'
)
.map(({ action }) => {
return (
<EuiContextMenuItem
key={action.id}
icon="share"
onClick={() => {
action.execute(actionContext);
setIsPopoverOpen(false);
}}
>
{action.getDisplayName(actionContext)}
</EuiContextMenuItem>
);
});
setItems(nextItems);
});
return () => {
cancelled = true;
};
}, [parentApi, uiActions]);
return (
<EuiPopover
button={
<EuiButton
iconType="arrowDown"
iconSide="right"
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
>
Add
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={() => {
setIsPopoverOpen(false);
}}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
);
}

View file

@ -0,0 +1,126 @@
/*
* 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 React, { useEffect, useMemo } from 'react';
import {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiSuperDatePicker,
} from '@elastic/eui';
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 { 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();
}, []);
useEffect(() => {
return () => {
cleanUp();
};
}, [cleanUp]);
const [dataLoading, panels, timeRange] = useBatchedPublishingSubjects(
parentApi.dataLoading,
componentApi.panels$,
parentApi.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{' '}
<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&apos;s unsaved changes.
</p>
<p>
This example uses session storage to persist saved state and unsaved changes while a
production implementation may choose to persist state elsewhere.
<EuiButtonEmpty
color={'warning'}
onClick={() => {
lastSavedStateSessionStorage.clear();
unsavedChangesSessionStorage.clear();
window.location.reload();
}}
>
Reset
</EuiButtonEmpty>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiSuperDatePicker
isLoading={dataLoading}
start={timeRange?.from}
end={timeRange?.to}
onTimeChange={({ start, end }) => {
componentApi.setTimeRange({
from: start,
to: end,
});
}}
onRefresh={() => {
componentApi.onReload();
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TopNav
onSave={componentApi.onSave}
resetUnsavedChanges={parentApi.resetUnsavedChanges}
unsavedChanges$={parentApi.unsavedChanges}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{panels.map(({ id, type }) => {
return (
<div key={id} style={{ height: '200' }}>
<ReactEmbeddableRenderer
type={type}
maybeId={id}
getParentApi={() => parentApi}
hidePanelChrome={false}
onApiAvailable={(api) => {
componentApi.setChild(id, api);
}}
/>
<EuiSpacer size="s" />
</div>
);
})}
<AddButton parentApi={parentApi} uiActions={uiActions} />
</div>
);
};

View file

@ -0,0 +1,62 @@
/*
* 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 React, { useEffect, useState } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { EuiBadge, EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { PublishesUnsavedChanges } from '@kbn/presentation-publishing';
interface Props {
onSave: () => Promise<void>;
resetUnsavedChanges: () => void;
unsavedChanges$: PublishesUnsavedChanges['unsavedChanges'];
}
export function TopNav(props: Props) {
const isMounted = useMountedState();
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useEffect(() => {
const subscription = props.unsavedChanges$.subscribe((unsavedChanges) => {
setHasUnsavedChanges(unsavedChanges !== undefined);
});
return () => {
subscription.unsubscribe();
};
}, [props.unsavedChanges$]);
return (
<EuiFlexGroup>
{hasUnsavedChanges && (
<>
<EuiFlexItem grow={false}>
<EuiBadge color="warning">Unsaved changes</EuiBadge>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty disabled={isSaving} onClick={props.resetUnsavedChanges}>
Reset
</EuiButtonEmpty>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
<EuiButton
disabled={isSaving || !hasUnsavedChanges}
onClick={async () => {
setIsSaving(true);
await props.onSave();
if (isMounted()) setIsSaving(false);
}}
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,261 @@
/*
* 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, Subject, combineLatest, map, merge } from 'rxjs';
import { v4 as generateId } from 'uuid';
import { asyncForEach } from '@kbn/std';
import { TimeRange } from '@kbn/es-query';
import {
PanelPackage,
apiHasSerializableState,
childrenUnsavedChanges$,
combineCompatibleChildrenApis,
} from '@kbn/presentation-containers';
import { isEqual, omit } from 'lodash';
import {
PublishesDataLoading,
PublishingSubject,
ViewMode,
apiPublishesDataLoading,
apiPublishesUnsavedChanges,
} 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';
export function getParentApi() {
const initialUnsavedChanges = unsavedChangesSessionStorage.load();
const initialSavedState = lastSavedStateSessionStorage.load();
let newPanels: Record<string, object> = {};
const lastSavedState$ = new BehaviorSubject<
LastSavedState & { panels: Array<{ id: string; type: string }> }
>({
...initialSavedState,
panels: initialSavedState.panelsState.map(({ id, type }) => {
return { id, type };
}),
});
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
const panels$ = new BehaviorSubject<Array<{ id: string; type: string }>>(
initialUnsavedChanges.panels ?? lastSavedState$.value.panels
);
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(
initialUnsavedChanges.timeRange ?? initialSavedState.timeRange
);
const reload$ = new Subject<void>();
const saveNotification$ = new Subject<void>();
function untilChildLoaded(childId: string): unknown | Promise<unknown | undefined> {
if (children$.value[childId]) {
return children$.value[childId];
}
return new Promise((resolve) => {
const subscription = merge(children$, panels$).subscribe(() => {
if (children$.value[childId]) {
subscription.unsubscribe();
resolve(children$.value[childId]);
return;
}
const panelExists = panels$.value.some(({ id }) => id === childId);
if (!panelExists) {
// panel removed before finished loading.
subscription.unsubscribe();
resolve(undefined);
}
});
});
}
const childrenDataLoadingSubscripiton = combineCompatibleChildrenApis<
PublishesDataLoading,
boolean | undefined
>(
{ children$ },
'dataLoading',
apiPublishesDataLoading,
undefined,
// flatten method
(values) => {
return values.some((isLoading) => isLoading);
}
).subscribe((isAtLeastOneChildLoading) => {
dataLoading$.next(isAtLeastOneChildLoading);
});
// One could use `initializeUnsavedChanges` to set up unsaved changes observable.
// Instead, decided to manually setup unsaved changes observable
// since only timeRange and panels array need to be monitored.
const timeRangeUnsavedChanges$ = combineLatest([timeRange$, lastSavedState$]).pipe(
map(([currentTimeRange, lastSavedState]) => {
const hasChanges = !isEqual(currentTimeRange, lastSavedState.timeRange);
return hasChanges ? { timeRange: currentTimeRange } : undefined;
})
);
const panelsUnsavedChanges$ = combineLatest([panels$, lastSavedState$]).pipe(
map(([currentPanels, lastSavedState]) => {
const hasChanges = !isEqual(currentPanels, lastSavedState.panels);
return hasChanges ? { panels: currentPanels } : undefined;
})
);
const unsavedChanges$ = combineLatest([
timeRangeUnsavedChanges$,
panelsUnsavedChanges$,
childrenUnsavedChanges$(children$),
]).pipe(
map(([timeRangeUnsavedChanges, panelsChanges, childrenUnsavedChanges]) => {
const nextUnsavedChanges: UnsavedChanges = {};
if (timeRangeUnsavedChanges) {
nextUnsavedChanges.timeRange = timeRangeUnsavedChanges.timeRange;
}
if (panelsChanges) {
nextUnsavedChanges.panels = panelsChanges.panels;
}
if (childrenUnsavedChanges) {
nextUnsavedChanges.panelUnsavedChanges = childrenUnsavedChanges;
}
return Object.keys(nextUnsavedChanges).length ? nextUnsavedChanges : undefined;
})
);
const unsavedChangesSubscription = unsavedChanges$.subscribe((nextUnsavedChanges) => {
unsavedChangesSessionStorage.save(nextUnsavedChanges ?? {});
});
return {
cleanUp: () => {
childrenDataLoadingSubscripiton.unsubscribe();
unsavedChangesSubscription.unsubscribe();
},
/**
* api's needed by component that should not be shared with children
*/
componentApi: {
onReload: () => {
reload$.next();
},
onSave: async () => {
const panelsState: LastSavedState['panelsState'] = [];
await asyncForEach(panels$.value, async ({ id, type }) => {
try {
const childApi = children$.value[id];
if (apiHasSerializableState(childApi)) {
panelsState.push({
id,
type,
panelState: await childApi.serializeState(),
});
}
} catch (error) {
// Unable to serialize panel state, just ignore since this is an example
}
});
const savedState = {
timeRange: timeRange$.value ?? DEFAULT_STATE.timeRange,
panelsState,
};
lastSavedState$.next({
...savedState,
panels: panelsState.map(({ id, type }) => {
return { id, type };
}),
});
lastSavedStateSessionStorage.save(savedState);
saveNotification$.next();
},
panels$,
setChild: (id: string, api: unknown) => {
children$.next({
...children$.value,
[id]: api,
});
},
setTimeRange: (timeRange: TimeRange) => {
timeRange$.next(timeRange);
},
},
parentApi: {
addNewPanel: async ({ panelType, initialState }: PanelPackage) => {
const id = generateId();
panels$.next([...panels$.value, { id, type: panelType }]);
newPanels[id] = initialState ?? {};
return await untilChildLoaded(id);
},
canRemovePanels: () => true,
children$,
dataLoading: dataLoading$,
executionContext: {
type: 'presentationContainerEmbeddableExample',
},
getAllDataViews: () => {
// TODO remove once dashboard converted to API and use `PublishesDataViews` interface
return [];
},
getPanelCount: () => {
return panels$.value.length;
},
replacePanel: async (idToRemove: string, newPanel: PanelPackage<object>) => {
// TODO remove method from interface? It should not be required
return '';
},
reload$: reload$ as unknown as PublishingSubject<void>,
removePanel: (id: string) => {
panels$.next(panels$.value.filter(({ id: panelId }) => panelId !== id));
children$.next(omit(children$.value, id));
},
saveNotification$,
viewMode: new BehaviorSubject<ViewMode>('edit'),
/**
* return last saved embeddable state
*/
getSerializedStateForChild: (childId: string) => {
const panel = initialSavedState.panelsState.find(({ id }) => {
return id === childId;
});
return panel ? panel.panelState : undefined;
},
/**
* return previous session's unsaved changes for embeddable
*/
getRuntimeStateForChild: (childId: string) => {
return newPanels[childId] ?? initialUnsavedChanges.panelUnsavedChanges?.[childId];
},
resetUnsavedChanges: () => {
timeRange$.next(lastSavedState$.value.timeRange);
panels$.next(lastSavedState$.value.panels);
lastSavedState$.value.panels.forEach(({ id }) => {
const childApi = children$.value[id];
if (apiPublishesUnsavedChanges(childApi)) {
childApi.resetUnsavedChanges();
}
});
const nextPanelIds = lastSavedState$.value.panels.map(({ id }) => id);
const children = { ...children$.value };
let modifiedChildren = false;
Object.keys(children).forEach((controlId) => {
if (!nextPanelIds.includes(controlId)) {
// remove children that no longer exist after reset
delete children[controlId];
modifiedChildren = true;
}
});
if (modifiedChildren) {
children$.next(children);
}
newPanels = {};
},
timeRange$,
unsavedChanges: unsavedChanges$ as PublishingSubject<object | undefined>,
} as ParentApi,
};
}

View file

@ -0,0 +1,33 @@
/*
* 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 { LastSavedState } from '../types';
const SAVED_STATE_SESSION_STORAGE_KEY =
'kibana.examples.embeddables.presentationContainerExample.savedState';
export const DEFAULT_STATE: LastSavedState = {
timeRange: {
from: 'now-15m',
to: 'now',
},
panelsState: [],
};
export const lastSavedStateSessionStorage = {
clear: () => {
sessionStorage.removeItem(SAVED_STATE_SESSION_STORAGE_KEY);
},
load: (): LastSavedState => {
const savedState = sessionStorage.getItem(SAVED_STATE_SESSION_STORAGE_KEY);
return savedState ? JSON.parse(savedState) : { ...DEFAULT_STATE };
},
save: (state: LastSavedState) => {
sessionStorage.setItem(SAVED_STATE_SESSION_STORAGE_KEY, JSON.stringify(state));
},
};

View file

@ -0,0 +1,25 @@
/*
* 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 { UnsavedChanges } from '../types';
const UNSAVED_CHANGES_SESSION_STORAGE_KEY =
'kibana.examples.embeddables.presentationContainerExample.unsavedChanges';
export const unsavedChangesSessionStorage = {
clear: () => {
sessionStorage.removeItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
},
load: (): UnsavedChanges => {
const unsavedChanges = sessionStorage.getItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY);
return unsavedChanges ? JSON.parse(unsavedChanges) : {};
},
save: (unsavedChanges: UnsavedChanges) => {
sessionStorage.setItem(UNSAVED_CHANGES_SESSION_STORAGE_KEY, JSON.stringify(unsavedChanges));
},
};

View file

@ -0,0 +1,51 @@
/*
* 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 type { DataView } from '@kbn/data-views-plugin/public';
import { TimeRange } from '@kbn/es-query';
import {
CanAddNewPanel,
HasSerializedChildState,
HasRuntimeChildState,
PresentationContainer,
SerializedPanelState,
HasSaveNotification,
} from '@kbn/presentation-containers';
import {
HasExecutionContext,
PublishesDataLoading,
PublishesTimeRange,
PublishesUnsavedChanges,
PublishesViewMode,
} from '@kbn/presentation-publishing';
import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
export type ParentApi = PresentationContainer &
CanAddNewPanel &
HasExecutionContext &
HasSaveNotification &
HasSerializedChildState &
HasRuntimeChildState &
PublishesDataLoading &
PublishesViewMode &
PublishesReload &
PublishesTimeRange &
PublishesUnsavedChanges & {
getAllDataViews: () => DataView[];
};
export interface LastSavedState {
timeRange: TimeRange;
panelsState: Array<{ id: string; type: string; panelState: SerializedPanelState }>;
}
export interface UnsavedChanges {
timeRange?: TimeRange;
panels?: Array<{ id: string; type: string }>;
panelUnsavedChanges?: Record<string, object>;
}

View file

@ -20,7 +20,8 @@ export function setupApp(core: CoreSetup<StartDeps>, developerExamples: Develope
visibleIn: [],
async mount(params: AppMountParameters) {
const { renderApp } = await import('./app');
return renderApp(params.element);
const [coreStart, deps] = await core.getStartServices();
return renderApp(coreStart, deps, params.element);
},
});
developerExamples.register({

View file

@ -30,7 +30,6 @@
"@kbn/presentation-util-plugin",
"@kbn/unified-field-list",
"@kbn/presentation-containers",
"@kbn/core-application-browser",
"@kbn/developer-examples-plugin",
"@kbn/data-view-field-editor-plugin",
"@kbn/discover-utils",
@ -40,6 +39,7 @@
"@kbn/unified-data-table",
"@kbn/kibana-utils-plugin",
"@kbn/core-mount-utils-browser",
"@kbn/react-kibana-mount"
"@kbn/react-kibana-mount",
"@kbn/std"
]
}

View file

@ -44,15 +44,21 @@ Embeddable APIs are accessable to all Kibana systems and all embeddable siblings
#### Error handling
Embeddables should never throw. Instead, use [PublishesBlockingError](https://github.com/elastic/kibana/blob/main/packages/presentation/presentation_publishing/interfaces/publishes_blocking_error.ts) interface to surface unrecoverable errors. When an embeddable publishes a blocking error, the parent component will display an error component instead of the embeddable Component. Be thoughtful about which errors are surfaced with the PublishesBlockingError interface. If the embeddable can still render, use less invasive error handling such as a warning toast or notifications in the embeddable Component UI.
### Examples
### 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)
- [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".
#### Embeddable factory examples
Use the following examples to learn how to create new Embeddable types. To access new Embeddable types, create a new dashboard, click "Add panel" and finally select "Embeddable examples".
- [Register a new embeddable type](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/register_search_embeddable.ts)
- [Create an embeddable that responds to Unified search](https://github.com/elastic/kibana/blob/main/examples/embeddable_examples/public/react_embeddables/search/search_react_embeddable.tsx)
- [Create an 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)
- [Create an 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)
#### Rendering embeddable examples
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)
- [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)

View file

@ -6,11 +6,16 @@
* Side Public License, v 1.
*/
import { apiIsPresentationContainer, PresentationContainer } from '@kbn/presentation-containers';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import {
apiPublishesPanelDescription,
apiPublishesPanelTitle,
apiPublishesSavedObjectId,
} from '@kbn/presentation-publishing';
import { LinksParentApi } from '../types';
export const compatibilityCheck = (
api: EmbeddableApiContext['embeddable']
): api is PresentationContainer => {
return apiIsPresentationContainer(api);
};
export const isParentApiCompatible = (parentApi: unknown): parentApi is LinksParentApi =>
apiIsPresentationContainer(parentApi) &&
apiPublishesSavedObjectId(parentApi) &&
apiPublishesPanelTitle(parentApi) &&
apiPublishesPanelDescription(parentApi);

View file

@ -20,12 +20,12 @@ export const registerCreateLinksPanelAction = () => {
getIconType: () => APP_ICON,
order: 10,
isCompatible: async ({ embeddable }) => {
const { compatibilityCheck } = await import('./compatibility_check');
return compatibilityCheck(embeddable);
const { isParentApiCompatible } = await import('./compatibility_check');
return isParentApiCompatible(embeddable);
},
execute: async ({ embeddable }) => {
const { compatibilityCheck } = await import('./compatibility_check');
if (!compatibilityCheck(embeddable)) throw new IncompatibleActionError();
const { isParentApiCompatible } = await import('./compatibility_check');
if (!isParentApiCompatible(embeddable)) throw new IncompatibleActionError();
const { openEditorFlyout } = await import('../editor/open_editor_flyout');
const runtimeState = await openEditorFlyout({
parentDashboard: embeddable,

View file

@ -14,14 +14,11 @@ import { EuiListGroup, EuiPanel } from '@elastic/eui';
import { PanelIncompatibleError, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import {
apiPublishesPanelDescription,
apiPublishesPanelTitle,
apiPublishesSavedObjectId,
initializeTitles,
useBatchedOptionalPublishingSubjects,
} from '@kbn/presentation-publishing';
import { apiIsPresentationContainer, SerializedPanelState } from '@kbn/presentation-containers';
import { SerializedPanelState } from '@kbn/presentation-containers';
import {
CONTENT_ID,
@ -52,15 +49,10 @@ import {
linksSerializeStateIsByReference,
} from '../lib/deserialize_from_library';
import { serializeLinksAttributes } from '../lib/serialize_attributes';
import { isParentApiCompatible } from '../actions/compatibility_check';
export const LinksContext = createContext<LinksApi | null>(null);
const isParentApiCompatible = (parentApi: unknown): parentApi is LinksParentApi =>
apiIsPresentationContainer(parentApi) &&
apiPublishesSavedObjectId(parentApi) &&
apiPublishesPanelTitle(parentApi) &&
apiPublishesPanelDescription(parentApi);
export const getLinksEmbeddableFactory = () => {
const linksEmbeddableFactory: ReactEmbeddableFactory<
LinksSerializedState,