mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
d177d11719
commit
832bc99181
14 changed files with 746 additions and 71 deletions
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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'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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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));
|
||||
},
|
||||
};
|
|
@ -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));
|
||||
},
|
||||
};
|
|
@ -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>;
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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)
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue