Skip to content

Commit

Permalink
[embeddables] PresentationContainer example (elastic#191891)
Browse files Browse the repository at this point in the history
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 <[email protected]>
Co-authored-by: kibanamachine <[email protected]>
Co-authored-by: Hannah Mudge <[email protected]>
  • Loading branch information
4 people authored Sep 6, 2024
1 parent d177d11 commit 832bc99
Show file tree
Hide file tree
Showing 14 changed files with 745 additions and 70 deletions.
93 changes: 56 additions & 37 deletions examples/embeddable_examples/public/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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>
<EuiPageHeader pageTitle="Embeddables" />
</EuiPageSection>
<EuiPageTemplate.Section>
<EuiPageSection>
<EuiTabs>
<EuiTab
onClick={() => onSelectedTabChanged(OVERVIEW_TAB_ID)}
isSelected={OVERVIEW_TAB_ID === selectedTabId}
>
Embeddables overview
</EuiTab>
<EuiTab
onClick={() => onSelectedTabChanged(REGISTER_EMBEDDABLE_TAB_ID)}
isSelected={REGISTER_EMBEDDABLE_TAB_ID === selectedTabId}
>
Register new embeddable type
</EuiTab>
<EuiTab
onClick={() => onSelectedTabChanged(RENDER_TAB_ID)}
isSelected={RENDER_TAB_ID === selectedTabId}
>
Rendering embeddables in your application
</EuiTab>
<EuiTab
onClick={() => onSelectedTabChanged(PRESENTATION_CONTAINER_EXAMPLE_ID)}
isSelected={PRESENTATION_CONTAINER_EXAMPLE_ID === selectedTabId}
>
PresentationContainer example
</EuiTab>
</EuiTabs>

<EuiSpacer />
<EuiSpacer />

{renderTabContent()}
</EuiPageSection>
</EuiPageTemplate.Section>
</EuiPageBody>
</EuiPage>
{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);
};
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit 832bc99

Please sign in to comment.