diff --git a/examples/embeddable_examples/public/app/app.tsx b/examples/embeddable_examples/public/app/app.tsx index e29177b4749e7..6d41c0e3004e6 100644 --- a/examples/embeddable_examples/public/app/app.tsx +++ b/examples/embeddable_examples/public/app/app.tsx @@ -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 ; } + if (selectedTabId === PRESENTATION_CONTAINER_EXAMPLE_ID) { + return ; + } + return ; } return ( - - - - - - + + + - - onSelectedTabChanged(OVERVIEW_TAB_ID)} - isSelected={OVERVIEW_TAB_ID === selectedTabId} - > - Embeddables overview - - onSelectedTabChanged(REGISTER_EMBEDDABLE_TAB_ID)} - isSelected={REGISTER_EMBEDDABLE_TAB_ID === selectedTabId} - > - Register new embeddable type - - onSelectedTabChanged(RENDER_TAB_ID)} - isSelected={RENDER_TAB_ID === selectedTabId} - > - Rendering embeddables in your application - - + + + + + + onSelectedTabChanged(OVERVIEW_TAB_ID)} + isSelected={OVERVIEW_TAB_ID === selectedTabId} + > + Embeddables overview + + onSelectedTabChanged(REGISTER_EMBEDDABLE_TAB_ID)} + isSelected={REGISTER_EMBEDDABLE_TAB_ID === selectedTabId} + > + Register new embeddable type + + onSelectedTabChanged(RENDER_TAB_ID)} + isSelected={RENDER_TAB_ID === selectedTabId} + > + Rendering embeddables in your application + + onSelectedTabChanged(PRESENTATION_CONTAINER_EXAMPLE_ID)} + isSelected={PRESENTATION_CONTAINER_EXAMPLE_ID === selectedTabId} + > + PresentationContainer example + + - + - {renderTabContent()} - - - - + {renderTabContent()} + + + + + ); }; -export const renderApp = (element: AppMountParameters['element']) => { - ReactDOM.render(, element); +export const renderApp = ( + core: CoreStart, + deps: StartDeps, + element: AppMountParameters['element'] +) => { + ReactDOM.render(, element); return () => ReactDOM.unmountComponentAtNode(element); }; diff --git a/examples/embeddable_examples/public/app/presentation_container_example/components/add_button.tsx b/examples/embeddable_examples/public/app/presentation_container_example/components/add_button.tsx new file mode 100644 index 0000000000000..a6ab21ed221e2 --- /dev/null +++ b/examples/embeddable_examples/public/app/presentation_container_example/components/add_button.tsx @@ -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([]); + + 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 ( + { + action.execute(actionContext); + setIsPopoverOpen(false); + }} + > + {action.getDisplayName(actionContext)} + + ); + }); + setItems(nextItems); + }); + + return () => { + cancelled = true; + }; + }, [parentApi, uiActions]); + + return ( + { + setIsPopoverOpen(!isPopoverOpen); + }} + > + Add + + } + isOpen={isPopoverOpen} + closePopover={() => { + setIsPopoverOpen(false); + }} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + ); +} diff --git a/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx b/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx new file mode 100644 index 0000000000000..57e50b648a5ab --- /dev/null +++ b/examples/embeddable_examples/public/app/presentation_container_example/components/presentation_container_example.tsx @@ -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 ( +
+ +

+ At times, you will need to render many embeddables and allow users to add, remove, and + re-arrange embeddables. Use the PresentationContainer and{' '} + CanAddNewPanel interfaces for this functionallity. +

+

+ Each embeddable manages its own state. The page is only responsible for persisting and + providing the last persisted state to the embeddable. Implement{' '} + HasSerializedChildState interface to provide an embeddable with last + persisted state. Implement HasRuntimeChildState interface to provide an + embeddable with a previous session's unsaved changes. +

+

+ This example uses session storage to persist saved state and unsaved changes while a + production implementation may choose to persist state elsewhere. + { + lastSavedStateSessionStorage.clear(); + unsavedChangesSessionStorage.clear(); + window.location.reload(); + }} + > + Reset + +

+
+ + + + + + { + componentApi.setTimeRange({ + from: start, + to: end, + }); + }} + onRefresh={() => { + componentApi.onReload(); + }} + /> + + + + + + + + + + {panels.map(({ id, type }) => { + return ( +
+ parentApi} + hidePanelChrome={false} + onApiAvailable={(api) => { + componentApi.setChild(id, api); + }} + /> + +
+ ); + })} + + +
+ ); +}; diff --git a/examples/embeddable_examples/public/app/presentation_container_example/components/top_nav.tsx b/examples/embeddable_examples/public/app/presentation_container_example/components/top_nav.tsx new file mode 100644 index 0000000000000..4c33c1139056b --- /dev/null +++ b/examples/embeddable_examples/public/app/presentation_container_example/components/top_nav.tsx @@ -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; + 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 ( + + {hasUnsavedChanges && ( + <> + + Unsaved changes + + + + Reset + + + + )} + + { + setIsSaving(true); + await props.onSave(); + if (isMounted()) setIsSaving(false); + }} + > + Save + + + + ); +} diff --git a/examples/embeddable_examples/public/app/presentation_container_example/parent_api.ts b/examples/embeddable_examples/public/app/presentation_container_example/parent_api.ts new file mode 100644 index 0000000000000..cc09767be99aa --- /dev/null +++ b/examples/embeddable_examples/public/app/presentation_container_example/parent_api.ts @@ -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 = {}; + 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(false); + const panels$ = new BehaviorSubject>( + initialUnsavedChanges.panels ?? lastSavedState$.value.panels + ); + const timeRange$ = new BehaviorSubject( + initialUnsavedChanges.timeRange ?? initialSavedState.timeRange + ); + + const reload$ = new Subject(); + + const saveNotification$ = new Subject(); + + function untilChildLoaded(childId: string): unknown | Promise { + 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) => { + // TODO remove method from interface? It should not be required + return ''; + }, + reload$: reload$ as unknown as PublishingSubject, + removePanel: (id: string) => { + panels$.next(panels$.value.filter(({ id: panelId }) => panelId !== id)); + children$.next(omit(children$.value, id)); + }, + saveNotification$, + viewMode: new BehaviorSubject('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, + } as ParentApi, + }; +} diff --git a/examples/embeddable_examples/public/app/presentation_container_example/session_storage/last_saved_state.ts b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/last_saved_state.ts new file mode 100644 index 0000000000000..886414562b664 --- /dev/null +++ b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/last_saved_state.ts @@ -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)); + }, +}; diff --git a/examples/embeddable_examples/public/app/presentation_container_example/session_storage/unsaved_changes.ts b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/unsaved_changes.ts new file mode 100644 index 0000000000000..744078eac112e --- /dev/null +++ b/examples/embeddable_examples/public/app/presentation_container_example/session_storage/unsaved_changes.ts @@ -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)); + }, +}; diff --git a/examples/embeddable_examples/public/app/presentation_container_example/types.ts b/examples/embeddable_examples/public/app/presentation_container_example/types.ts new file mode 100644 index 0000000000000..58b21fd5d54ba --- /dev/null +++ b/examples/embeddable_examples/public/app/presentation_container_example/types.ts @@ -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; +} diff --git a/examples/embeddable_examples/public/app/setup_app.ts b/examples/embeddable_examples/public/app/setup_app.ts index c489b603c877b..69f668b96dd87 100644 --- a/examples/embeddable_examples/public/app/setup_app.ts +++ b/examples/embeddable_examples/public/app/setup_app.ts @@ -20,7 +20,8 @@ export function setupApp(core: CoreSetup, 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({ diff --git a/examples/embeddable_examples/tsconfig.json b/examples/embeddable_examples/tsconfig.json index 2df5e6534bd27..f771b69e15fc5 100644 --- a/examples/embeddable_examples/tsconfig.json +++ b/examples/embeddable_examples/tsconfig.json @@ -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" ] } diff --git a/src/plugins/embeddable/README.md b/src/plugins/embeddable/README.md index 91627a5f0e6e9..07f5be93e4b3d 100644 --- a/src/plugins/embeddable/README.md +++ b/src/plugins/embeddable/README.md @@ -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". \ No newline at end of file + +#### 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) \ No newline at end of file diff --git a/src/plugins/links/public/actions/compatibility_check.ts b/src/plugins/links/public/actions/compatibility_check.ts index 986c91368abfa..a16ca5ac7b5cc 100644 --- a/src/plugins/links/public/actions/compatibility_check.ts +++ b/src/plugins/links/public/actions/compatibility_check.ts @@ -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); diff --git a/src/plugins/links/public/actions/create_links_panel_action.ts b/src/plugins/links/public/actions/create_links_panel_action.ts index 02d157055f758..ec4eccc2988d7 100644 --- a/src/plugins/links/public/actions/create_links_panel_action.ts +++ b/src/plugins/links/public/actions/create_links_panel_action.ts @@ -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, diff --git a/src/plugins/links/public/embeddable/links_embeddable.tsx b/src/plugins/links/public/embeddable/links_embeddable.tsx index 8fc6d464cd50f..25782ea3755a9 100644 --- a/src/plugins/links/public/embeddable/links_embeddable.tsx +++ b/src/plugins/links/public/embeddable/links_embeddable.tsx @@ -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(null); -const isParentApiCompatible = (parentApi: unknown): parentApi is LinksParentApi => - apiIsPresentationContainer(parentApi) && - apiPublishesSavedObjectId(parentApi) && - apiPublishesPanelTitle(parentApi) && - apiPublishesPanelDescription(parentApi); - export const getLinksEmbeddableFactory = () => { const linksEmbeddableFactory: ReactEmbeddableFactory< LinksSerializedState,