Skip to content

Commit

Permalink
[Dashboard] [Collapsable Panels] Add embeddable support (#198413)
Browse files Browse the repository at this point in the history
Closes #190379

## Summary

This PR switches the example grid layout app to render embeddables as
panels rather than the simplified mock panel we were using previously.
In doing so, I had to add the ability for custom panels to add a custom
drag handle via the `renderPanelContents` callback - this required
adding a `setDragHandles` callback to the `ReactEmbeddableRenderer` that
could be passed down to the `PresentationPanel` component.




https://github.com/user-attachments/assets/9e2c68f9-34af-4360-a978-9113701a5ea2



#### New scroll behaviour

In #201867, I introduced a small
"ease" to the auto-scroll effect that happens when you drag a panel to
the top or bottom of the window. However, in that PR, I was using the
`smooth` scrolling behaviour, which unfortunately became **very**
jittery once I switched to embeddables rather than simple panels
(specifically in Chrome - it worked fine in Firefox).

The only way to prevent this jittery scroll was to switch to the default
scroll behaviour, but this lead to a very **abrupt** stop when the
scrollbar reached the top and/or bottom of the page - so, to give the
same "gentle" stop that the `smooth` scroll had, I decided to recreate
this effect by adding a slow down "ease" when close to the top or bottom
of the page:


https://github.com/user-attachments/assets/cb7bf03f-4a9e-4446-be4f-8f54c0bc88ac

This effect is accomplished via the parabola formula `y = a(x-h)2 + k`
and can be roughly visualized with the following, which shows that the
"speed up" ease happens at a much slower pace than the "slow down" ease:


![image](https://github.com/user-attachments/assets/02b4389c-fe78-448d-9c02-c4ec5e722d5e)




#### Notes about parent changes
As I investigated improving the efficiency of the grid layout with
embeddables, one of the main things I noticed was that the grid panel
was **always** remounted when moving a panel from one collapsible
section to another. This lead me (and @ThomThomson) down a rabbit hole
of React-reparenting, and we explored a few different options to see if
we could change the parent of a component **without** having it remount.

In summary, after various experiments and a whole bunch of research, we
determined that, due to the reconciliation of the React tree, this is
unfortunately impossible. So our priorities will instead have to move to
making the remount of `ReactEmbeddableRenderer` **as efficient as
possible** via caching, since the remount is inevitable.

### Checklist

- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

There are no risks to this PR, since the most significant work is
contained in the `examples` plugin. Some changes were made to the
presentation panel to allow for custom drag handles, but this isn't
actually used in Dashboard - so for now, this code is only called in the
example plugin, as well.

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
Heenawter and kibanamachine authored Dec 10, 2024
1 parent 91337c7 commit 2a76fe3
Show file tree
Hide file tree
Showing 27 changed files with 5,389 additions and 300 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
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 { PageApi } from '../types';

export function AddButton({ pageApi, uiActions }: { pageApi: PageApi; uiActions: UiActionsStart }) {
export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions: UiActionsStart }) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [items, setItems] = useState<ReactElement[]>([]);

Expand Down Expand Up @@ -73,7 +72,7 @@ export function AddButton({ pageApi, uiActions }: { pageApi: PageApi; uiActions:
setIsPopoverOpen(!isPopoverOpen);
}}
>
Add
Add panel
</EuiButton>
}
isOpen={isPopoverOpen}
Expand Down
2 changes: 2 additions & 0 deletions examples/embeddable_examples/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@

import { EmbeddableExamplesPlugin } from './plugin';

export { AddButton as AddEmbeddableButton } from './app/presentation_container_example/components/add_button';

export const plugin = () => new EmbeddableExamplesPlugin();
2 changes: 1 addition & 1 deletion examples/grid_example/kibana.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"id": "gridExample",
"server": false,
"browser": true,
"requiredPlugins": ["developerExamples"],
"requiredPlugins": ["developerExamples", "embeddable", "uiActions", "embeddableExamples"],
"requiredBundles": []
}
}
140 changes: 59 additions & 81 deletions examples/grid_example/public/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,28 @@ import deepEqual from 'fast-deep-equal';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { combineLatest, debounceTime } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';

import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiButtonGroup,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiPageTemplate,
EuiProvider,
EuiSpacer,
EuiButtonGroup,
EuiButtonIcon,
} from '@elastic/eui';
import { AppMountParameters } from '@kbn/core-application-browser';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { GridLayout, GridLayoutData, GridAccessMode } from '@kbn/grid-layout';
import { AddEmbeddableButton } from '@kbn/embeddable-examples-plugin/public';
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { GridLayout, GridLayoutData } from '@kbn/grid-layout';
import { i18n } from '@kbn/i18n';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';

import { getPanelId } from './get_panel_id';
import {
clearSerializedDashboardState,
getSerializedDashboardState,
Expand All @@ -45,82 +46,69 @@ const DASHBOARD_MARGIN_SIZE = 8;
const DASHBOARD_GRID_HEIGHT = 20;
const DASHBOARD_GRID_COLUMN_COUNT = 48;

export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
export const GridExample = ({
coreStart,
uiActions,
}: {
coreStart: CoreStart;
uiActions: UiActionsStart;
}) => {
const savedState = useRef<MockSerializedDashboardState>(getSerializedDashboardState());
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [expandedPanelId, setExpandedPanelId] = useState<string | undefined>();
const [accessMode, setAccessMode] = useState<GridAccessMode>('EDIT');
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
dashboardInputToGridLayout(savedState.current)
);

const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current });
const [viewMode, expandedPanelId] = useBatchedPublishingSubjects(
mockDashboardApi.viewMode,
mockDashboardApi.expandedPanelId
);

useEffect(() => {
combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$])
.pipe(debounceTime(0)) // debounce to avoid subscribe being called twice when both panels$ and rows$ publish
.subscribe(([panels, rows]) => {
const hasChanges = !(
deepEqual(panels, savedState.current.panels) && deepEqual(rows, savedState.current.rows)
deepEqual(
Object.values(panels).map(({ gridData }) => ({ row: 0, ...gridData })),
Object.values(savedState.current.panels).map(({ gridData }) => ({
row: 0, // if row is undefined, then default to 0
...gridData,
}))
) && deepEqual(rows, savedState.current.rows)
);
setHasUnsavedChanges(hasChanges);
setCurrentLayout(dashboardInputToGridLayout({ panels, rows }));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const renderBasicPanel = useCallback(
(id: string) => {
const renderPanelContents = useCallback(
(id: string, setDragHandles?: (refs: Array<HTMLElement | null>) => void) => {
const currentPanels = mockDashboardApi.panels$.getValue();

return (
<>
<div style={{ padding: 8 }}>{id}</div>
<EuiButtonEmpty
onClick={() => {
setExpandedPanelId(undefined);
mockDashboardApi.removePanel(id);
}}
>
{i18n.translate('examples.gridExample.deletePanelButton', {
defaultMessage: 'Delete panel',
})}
</EuiButtonEmpty>
<EuiButtonEmpty
onClick={async () => {
setExpandedPanelId(undefined);
const newPanelId = await getPanelId({
coreStart,
suggestion: id,
});
if (newPanelId) mockDashboardApi.replacePanel(id, newPanelId);
}}
>
{i18n.translate('examples.gridExample.replacePanelButton', {
defaultMessage: 'Replace panel',
})}
</EuiButtonEmpty>
<EuiButtonIcon
iconType={expandedPanelId ? 'minimize' : 'expand'}
onClick={() => setExpandedPanelId((expandedId) => (expandedId ? undefined : id))}
aria-label={
expandedPanelId
? i18n.translate('examples.gridExample.minimizePanel', {
defaultMessage: 'Minimize panel {id}',
values: { id },
})
: i18n.translate('examples.gridExample.maximizePanel', {
defaultMessage: 'Maximize panel {id}',
values: { id },
})
}
/>
</>
<ReactEmbeddableRenderer
key={id}
maybeId={id}
type={currentPanels[id].type}
getParentApi={() => mockDashboardApi}
panelProps={{
showBadges: true,
showBorder: true,
showNotifications: true,
showShadow: false,
setDragHandles,
}}
/>
);
},
[coreStart, mockDashboardApi, setExpandedPanelId, expandedPanelId]
[mockDashboardApi]
);

return (
<EuiProvider>
<KibanaRenderContextProvider {...coreStart}>
<EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
<EuiPageTemplate.Header
iconType={'dashboardApp'}
Expand All @@ -131,7 +119,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
<EuiPageTemplate.Section
color="subdued"
contentProps={{
css: { display: 'flex', flexFlow: 'column nowrap', flexGrow: 1 },
css: { flexGrow: 1 },
}}
>
<EuiCallOut
Expand All @@ -156,20 +144,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
onClick={async () => {
setExpandedPanelId(undefined);
const panelId = await getPanelId({
coreStart,
suggestion: uuidv4(),
});
if (panelId) mockDashboardApi.addNewPanel({ id: panelId });
}}
>
{i18n.translate('examples.gridExample.addPanelButton', {
defaultMessage: 'Add a panel',
})}
</EuiButton>
<AddEmbeddableButton pageApi={mockDashboardApi} uiActions={uiActions} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
Expand All @@ -180,24 +155,24 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
})}
options={[
{
id: 'VIEW',
id: 'view',
label: i18n.translate('examples.gridExample.viewOption', {
defaultMessage: 'View',
}),
toolTipContent:
'The layout adjusts when the window is resized. Panel interactivity, such as moving and resizing within the grid, is disabled.',
},
{
id: 'EDIT',
id: 'edit',
label: i18n.translate('examples.gridExample.editOption', {
defaultMessage: 'Edit',
}),
toolTipContent: 'The layout does not adjust when the window is resized.',
},
]}
idSelected={accessMode}
idSelected={viewMode}
onChange={(id) => {
setAccessMode(id as GridAccessMode);
mockDashboardApi.viewMode.next(id);
}}
/>
</EuiFlexItem>
Expand Down Expand Up @@ -245,32 +220,35 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
</EuiFlexGroup>
<EuiSpacer size="m" />
<GridLayout
accessMode={accessMode}
accessMode={viewMode === 'view' ? 'VIEW' : 'EDIT'}
expandedPanelId={expandedPanelId}
layout={currentLayout}
gridSettings={{
gutterSize: DASHBOARD_MARGIN_SIZE,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
}}
renderPanelContents={renderBasicPanel}
renderPanelContents={renderPanelContents}
onLayoutChange={(newLayout) => {
const { panels, rows } = gridLayoutToDashboardPanelMap(newLayout);
const { panels, rows } = gridLayoutToDashboardPanelMap(
mockDashboardApi.panels$.getValue(),
newLayout
);
mockDashboardApi.panels$.next(panels);
mockDashboardApi.rows$.next(rows);
}}
/>
</EuiPageTemplate.Section>
</EuiPageTemplate>
</EuiProvider>
</KibanaRenderContextProvider>
);
};

export const renderGridExampleApp = (
element: AppMountParameters['element'],
coreStart: CoreStart
deps: { uiActions: UiActionsStart; coreStart: CoreStart }
) => {
ReactDOM.render(<GridExample coreStart={coreStart} />, element);
ReactDOM.render(<GridExample {...deps} />, element);

return () => ReactDOM.unmountComponentAtNode(element);
};
Loading

0 comments on commit 2a76fe3

Please sign in to comment.