[Dashboard] [Collapsable Panels] Add embeddable support (#198413)

Closes https://github.com/elastic/kibana/issues/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 https://github.com/elastic/kibana/pull/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 <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2024-12-10 10:14:31 -07:00 committed by GitHub
parent 91337c74a3
commit 2a76fe3ee4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 5389 additions and 300 deletions

View file

@ -10,9 +10,8 @@
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { ADD_PANEL_TRIGGER, UiActionsStart } from '@kbn/ui-actions-plugin/public'; 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 [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [items, setItems] = useState<ReactElement[]>([]); const [items, setItems] = useState<ReactElement[]>([]);
@ -73,7 +72,7 @@ export function AddButton({ pageApi, uiActions }: { pageApi: PageApi; uiActions:
setIsPopoverOpen(!isPopoverOpen); setIsPopoverOpen(!isPopoverOpen);
}} }}
> >
Add Add panel
</EuiButton> </EuiButton>
} }
isOpen={isPopoverOpen} isOpen={isPopoverOpen}

View file

@ -9,4 +9,6 @@
import { EmbeddableExamplesPlugin } from './plugin'; import { EmbeddableExamplesPlugin } from './plugin';
export { AddButton as AddEmbeddableButton } from './app/presentation_container_example/components/add_button';
export const plugin = () => new EmbeddableExamplesPlugin(); export const plugin = () => new EmbeddableExamplesPlugin();

View file

@ -7,7 +7,7 @@
"id": "gridExample", "id": "gridExample",
"server": false, "server": false,
"browser": true, "browser": true,
"requiredPlugins": ["developerExamples"], "requiredPlugins": ["developerExamples", "embeddable", "uiActions", "embeddableExamples"],
"requiredBundles": [] "requiredBundles": []
} }
} }

View file

@ -11,27 +11,28 @@ import deepEqual from 'fast-deep-equal';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { combineLatest, debounceTime } from 'rxjs'; import { combineLatest, debounceTime } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { import {
EuiBadge, EuiBadge,
EuiButton, EuiButton,
EuiButtonEmpty, EuiButtonEmpty,
EuiButtonGroup,
EuiCallOut, EuiCallOut,
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
EuiPageTemplate, EuiPageTemplate,
EuiProvider,
EuiSpacer, EuiSpacer,
EuiButtonGroup,
EuiButtonIcon,
} from '@elastic/eui'; } from '@elastic/eui';
import { AppMountParameters } from '@kbn/core-application-browser'; import { AppMountParameters } from '@kbn/core-application-browser';
import { CoreStart } from '@kbn/core-lifecycle-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 { 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 { import {
clearSerializedDashboardState, clearSerializedDashboardState,
getSerializedDashboardState, getSerializedDashboardState,
@ -45,23 +46,37 @@ const DASHBOARD_MARGIN_SIZE = 8;
const DASHBOARD_GRID_HEIGHT = 20; const DASHBOARD_GRID_HEIGHT = 20;
const DASHBOARD_GRID_COLUMN_COUNT = 48; 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 savedState = useRef<MockSerializedDashboardState>(getSerializedDashboardState());
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [expandedPanelId, setExpandedPanelId] = useState<string | undefined>();
const [accessMode, setAccessMode] = useState<GridAccessMode>('EDIT');
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>( const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
dashboardInputToGridLayout(savedState.current) dashboardInputToGridLayout(savedState.current)
); );
const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current }); const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current });
const [viewMode, expandedPanelId] = useBatchedPublishingSubjects(
mockDashboardApi.viewMode,
mockDashboardApi.expandedPanelId
);
useEffect(() => { useEffect(() => {
combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$]) combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$])
.pipe(debounceTime(0)) // debounce to avoid subscribe being called twice when both panels$ and rows$ publish .pipe(debounceTime(0)) // debounce to avoid subscribe being called twice when both panels$ and rows$ publish
.subscribe(([panels, rows]) => { .subscribe(([panels, rows]) => {
const hasChanges = !( 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); setHasUnsavedChanges(hasChanges);
setCurrentLayout(dashboardInputToGridLayout({ panels, rows })); setCurrentLayout(dashboardInputToGridLayout({ panels, rows }));
@ -69,58 +84,31 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const renderBasicPanel = useCallback( const renderPanelContents = useCallback(
(id: string) => { (id: string, setDragHandles?: (refs: Array<HTMLElement | null>) => void) => {
const currentPanels = mockDashboardApi.panels$.getValue();
return ( return (
<> <ReactEmbeddableRenderer
<div style={{ padding: 8 }}>{id}</div> key={id}
<EuiButtonEmpty maybeId={id}
onClick={() => { type={currentPanels[id].type}
setExpandedPanelId(undefined); getParentApi={() => mockDashboardApi}
mockDashboardApi.removePanel(id); panelProps={{
}} showBadges: true,
> showBorder: true,
{i18n.translate('examples.gridExample.deletePanelButton', { showNotifications: true,
defaultMessage: 'Delete panel', showShadow: false,
})} setDragHandles,
</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 },
})
}
/>
</>
); );
}, },
[coreStart, mockDashboardApi, setExpandedPanelId, expandedPanelId] [mockDashboardApi]
); );
return ( return (
<EuiProvider> <KibanaRenderContextProvider {...coreStart}>
<EuiPageTemplate grow={false} offset={0} restrictWidth={false}> <EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
<EuiPageTemplate.Header <EuiPageTemplate.Header
iconType={'dashboardApp'} iconType={'dashboardApp'}
@ -131,7 +119,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
<EuiPageTemplate.Section <EuiPageTemplate.Section
color="subdued" color="subdued"
contentProps={{ contentProps={{
css: { display: 'flex', flexFlow: 'column nowrap', flexGrow: 1 }, css: { flexGrow: 1 },
}} }}
> >
<EuiCallOut <EuiCallOut
@ -156,20 +144,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center"> <EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiButton <AddEmbeddableButton pageApi={mockDashboardApi} uiActions={uiActions} />
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>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center"> <EuiFlexGroup gutterSize="xs" alignItems="center">
@ -180,7 +155,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
})} })}
options={[ options={[
{ {
id: 'VIEW', id: 'view',
label: i18n.translate('examples.gridExample.viewOption', { label: i18n.translate('examples.gridExample.viewOption', {
defaultMessage: 'View', defaultMessage: 'View',
}), }),
@ -188,16 +163,16 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
'The layout adjusts when the window is resized. Panel interactivity, such as moving and resizing within the grid, is disabled.', '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', { label: i18n.translate('examples.gridExample.editOption', {
defaultMessage: 'Edit', defaultMessage: 'Edit',
}), }),
toolTipContent: 'The layout does not adjust when the window is resized.', toolTipContent: 'The layout does not adjust when the window is resized.',
}, },
]} ]}
idSelected={accessMode} idSelected={viewMode}
onChange={(id) => { onChange={(id) => {
setAccessMode(id as GridAccessMode); mockDashboardApi.viewMode.next(id);
}} }}
/> />
</EuiFlexItem> </EuiFlexItem>
@ -245,7 +220,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
</EuiFlexGroup> </EuiFlexGroup>
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<GridLayout <GridLayout
accessMode={accessMode} accessMode={viewMode === 'view' ? 'VIEW' : 'EDIT'}
expandedPanelId={expandedPanelId} expandedPanelId={expandedPanelId}
layout={currentLayout} layout={currentLayout}
gridSettings={{ gridSettings={{
@ -253,24 +228,27 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
rowHeight: DASHBOARD_GRID_HEIGHT, rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT, columnCount: DASHBOARD_GRID_COLUMN_COUNT,
}} }}
renderPanelContents={renderBasicPanel} renderPanelContents={renderPanelContents}
onLayoutChange={(newLayout) => { onLayoutChange={(newLayout) => {
const { panels, rows } = gridLayoutToDashboardPanelMap(newLayout); const { panels, rows } = gridLayoutToDashboardPanelMap(
mockDashboardApi.panels$.getValue(),
newLayout
);
mockDashboardApi.panels$.next(panels); mockDashboardApi.panels$.next(panels);
mockDashboardApi.rows$.next(rows); mockDashboardApi.rows$.next(rows);
}} }}
/> />
</EuiPageTemplate.Section> </EuiPageTemplate.Section>
</EuiPageTemplate> </EuiPageTemplate>
</EuiProvider> </KibanaRenderContextProvider>
); );
}; };
export const renderGridExampleApp = ( export const renderGridExampleApp = (
element: AppMountParameters['element'], 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); return () => ReactDOM.unmountComponentAtNode(element);
}; };

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
export const GRID_EXAMPLE_APP_ID = 'gridExample'; export const GRID_EXAMPLE_APP_ID = 'gridExample';
const gridExampleTitle = 'Grid Example'; const gridExampleTitle = 'Grid Example';
@ -17,20 +18,28 @@ interface GridExamplePluginSetupDependencies {
developerExamples: DeveloperExamplesSetup; developerExamples: DeveloperExamplesSetup;
} }
export interface GridExamplePluginStartDependencies {
uiActions: UiActionsStart;
}
export class GridExamplePlugin export class GridExamplePlugin
implements Plugin<void, void, GridExamplePluginSetupDependencies, {}> implements
Plugin<void, void, GridExamplePluginSetupDependencies, GridExamplePluginStartDependencies>
{ {
public setup(core: CoreSetup<{}>, { developerExamples }: GridExamplePluginSetupDependencies) { public setup(
core: CoreSetup<GridExamplePluginStartDependencies>,
{ developerExamples }: GridExamplePluginSetupDependencies
) {
core.application.register({ core.application.register({
id: GRID_EXAMPLE_APP_ID, id: GRID_EXAMPLE_APP_ID,
title: gridExampleTitle, title: gridExampleTitle,
visibleIn: [], visibleIn: [],
async mount(params: AppMountParameters) { async mount(params: AppMountParameters) {
const [{ renderGridExampleApp }, [coreStart]] = await Promise.all([ const [{ renderGridExampleApp }, [coreStart, deps]] = await Promise.all([
import('./app'), import('./app'),
core.getStartServices(), core.getStartServices(),
]); ]);
return renderGridExampleApp(params.element, coreStart); return renderGridExampleApp(params.element, { coreStart, uiActions: deps.uiActions });
}, },
}); });
developerExamples.register({ developerExamples.register({

View file

@ -9,6 +9,8 @@
import { MockSerializedDashboardState } from './types'; import { MockSerializedDashboardState } from './types';
import logsPanels from './logs_dashboard_panels.json';
const STATE_SESSION_STORAGE_KEY = 'kibana.examples.gridExample.state'; const STATE_SESSION_STORAGE_KEY = 'kibana.examples.gridExample.state';
export function clearSerializedDashboardState() { export function clearSerializedDashboardState() {
@ -25,21 +27,11 @@ export function setSerializedGridLayout(state: MockSerializedDashboardState) {
} }
const initialState: MockSerializedDashboardState = { const initialState: MockSerializedDashboardState = {
panels: { panels: logsPanels,
panel1: { id: 'panel1', gridData: { i: 'panel1', x: 0, y: 0, w: 12, h: 6, row: 0 } },
panel2: { id: 'panel2', gridData: { i: 'panel2', x: 0, y: 6, w: 8, h: 4, row: 0 } },
panel3: { id: 'panel3', gridData: { i: 'panel3', x: 8, y: 6, w: 12, h: 4, row: 0 } },
panel4: { id: 'panel4', gridData: { i: 'panel4', x: 0, y: 10, w: 48, h: 4, row: 0 } },
panel5: { id: 'panel5', gridData: { i: 'panel5', x: 12, y: 0, w: 36, h: 6, row: 0 } },
panel6: { id: 'panel6', gridData: { i: 'panel6', x: 24, y: 6, w: 24, h: 4, row: 0 } },
panel7: { id: 'panel7', gridData: { i: 'panel7', x: 20, y: 6, w: 4, h: 2, row: 0 } },
panel8: { id: 'panel8', gridData: { i: 'panel8', x: 20, y: 8, w: 4, h: 2, row: 0 } },
panel9: { id: 'panel9', gridData: { i: 'panel9', x: 0, y: 0, w: 12, h: 16, row: 1 } },
panel10: { id: 'panel10', gridData: { i: 'panel10', x: 24, y: 0, w: 12, h: 6, row: 2 } },
},
rows: [ rows: [
{ title: 'Large section', collapsed: false }, { title: 'Request Sizes', collapsed: false },
{ title: 'Small section', collapsed: false }, { title: 'Visitors', collapsed: false },
{ title: 'Another small section', collapsed: false }, { title: 'Response Codes', collapsed: false },
{ title: 'Entire Flights Dashboard', collapsed: true },
], ],
}; };

View file

@ -15,8 +15,15 @@ export interface DashboardGridData {
i: string; i: string;
} }
interface DashboardPanelState {
type: string;
gridData: DashboardGridData & { row?: number };
explicitInput: Partial<any> & { id: string };
version?: string;
}
export interface MockedDashboardPanelMap { export interface MockedDashboardPanelMap {
[key: string]: { id: string; gridData: DashboardGridData & { row: number } }; [key: string]: DashboardPanelState;
} }
export type MockedDashboardRowMap = Array<{ title: string; collapsed: boolean }>; export type MockedDashboardRowMap = Array<{ title: string; collapsed: boolean }>;

View file

@ -10,6 +10,10 @@
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { v4 } from 'uuid';
import { TimeRange } from '@kbn/es-query';
import { PanelPackage } from '@kbn/presentation-containers';
import { import {
MockSerializedDashboardState, MockSerializedDashboardState,
@ -27,24 +31,50 @@ export const useMockDashboardApi = ({
savedState: MockSerializedDashboardState; savedState: MockSerializedDashboardState;
}) => { }) => {
const mockDashboardApi = useMemo(() => { const mockDashboardApi = useMemo(() => {
const panels$ = new BehaviorSubject<MockedDashboardPanelMap>(savedState.panels);
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
return { return {
getSerializedStateForChild: (id: string) => {
return {
rawState: panels$.getValue()[id].explicitInput,
references: [],
};
},
children$: new BehaviorSubject({}),
timeRange$: new BehaviorSubject<TimeRange>({
from: 'now-24h',
to: 'now',
}),
viewMode: new BehaviorSubject('edit'), viewMode: new BehaviorSubject('edit'),
panels$: new BehaviorSubject<MockedDashboardPanelMap>(savedState.panels), panels$,
rows$: new BehaviorSubject<MockedDashboardRowMap>(savedState.rows), rows$: new BehaviorSubject<MockedDashboardRowMap>(savedState.rows),
expandedPanelId: expandedPanelId$,
expandPanel: (id: string) => {
if (expandedPanelId$.getValue()) {
expandedPanelId$.next(undefined);
} else {
expandedPanelId$.next(id);
}
},
removePanel: (id: string) => { removePanel: (id: string) => {
const panels = { ...mockDashboardApi.panels$.getValue() }; const panels = { ...mockDashboardApi.panels$.getValue() };
delete panels[id]; // the grid layout component will handle compacting, if necessary delete panels[id]; // the grid layout component will handle compacting, if necessary
mockDashboardApi.panels$.next(panels); mockDashboardApi.panels$.next(panels);
}, },
replacePanel: (oldId: string, newId: string) => { replacePanel: (id: string, newPanel: PanelPackage) => {
const currentPanels = mockDashboardApi.panels$.getValue(); const currentPanels = mockDashboardApi.panels$.getValue();
const otherPanels = { ...currentPanels }; const otherPanels = { ...currentPanels };
const oldPanel = currentPanels[oldId]; const oldPanel = currentPanels[id];
delete otherPanels[oldId]; delete otherPanels[id];
otherPanels[newId] = { id: newId, gridData: { ...oldPanel.gridData, i: newId } }; const newId = v4();
otherPanels[newId] = {
...oldPanel,
explicitInput: { ...newPanel.initialState, id: newId },
};
mockDashboardApi.panels$.next(otherPanels); mockDashboardApi.panels$.next(otherPanels);
}, },
addNewPanel: ({ id: newId }: { id: string }) => { addNewPanel: async (panelPackage: PanelPackage) => {
// we are only implementing "place at top" here, for demo purposes // we are only implementing "place at top" here, for demo purposes
const currentPanels = mockDashboardApi.panels$.getValue(); const currentPanels = mockDashboardApi.panels$.getValue();
const otherPanels = { ...currentPanels }; const otherPanels = { ...currentPanels };
@ -53,17 +83,22 @@ export const useMockDashboardApi = ({
currentPanel.gridData.y = currentPanel.gridData.y + DEFAULT_PANEL_HEIGHT; currentPanel.gridData.y = currentPanel.gridData.y + DEFAULT_PANEL_HEIGHT;
otherPanels[id] = currentPanel; otherPanels[id] = currentPanel;
} }
const newId = v4();
mockDashboardApi.panels$.next({ mockDashboardApi.panels$.next({
...otherPanels, ...otherPanels,
[newId]: { [newId]: {
id: newId, type: panelPackage.panelType,
gridData: { gridData: {
i: newId,
row: 0, row: 0,
x: 0, x: 0,
y: 0, y: 0,
w: DEFAULT_PANEL_WIDTH, w: DEFAULT_PANEL_WIDTH,
h: DEFAULT_PANEL_HEIGHT, h: DEFAULT_PANEL_HEIGHT,
i: newId,
},
explicitInput: {
...panelPackage.initialState,
id: newId,
}, },
}, },
}); });

View file

@ -11,6 +11,7 @@ import { GridLayoutData } from '@kbn/grid-layout';
import { MockedDashboardPanelMap, MockedDashboardRowMap } from './types'; import { MockedDashboardPanelMap, MockedDashboardRowMap } from './types';
export const gridLayoutToDashboardPanelMap = ( export const gridLayoutToDashboardPanelMap = (
panelState: MockedDashboardPanelMap,
layout: GridLayoutData layout: GridLayoutData
): { panels: MockedDashboardPanelMap; rows: MockedDashboardRowMap } => { ): { panels: MockedDashboardPanelMap; rows: MockedDashboardRowMap } => {
const panels: MockedDashboardPanelMap = {}; const panels: MockedDashboardPanelMap = {};
@ -19,7 +20,7 @@ export const gridLayoutToDashboardPanelMap = (
rows.push({ title: row.title, collapsed: row.isCollapsed }); rows.push({ title: row.title, collapsed: row.isCollapsed });
Object.values(row.panels).forEach((panelGridData) => { Object.values(row.panels).forEach((panelGridData) => {
panels[panelGridData.id] = { panels[panelGridData.id] = {
id: panelGridData.id, ...panelState[panelGridData.id],
gridData: { gridData: {
i: panelGridData.id, i: panelGridData.id,
y: panelGridData.row, y: panelGridData.row,
@ -49,7 +50,7 @@ export const dashboardInputToGridLayout = ({
Object.keys(panels).forEach((panelId) => { Object.keys(panels).forEach((panelId) => {
const gridData = panels[panelId].gridData; const gridData = panels[panelId].gridData;
layout[gridData.row].panels[panelId] = { layout[gridData.row ?? 0].panels[panelId] = {
id: panelId, id: panelId,
row: gridData.y, row: gridData.y,
column: gridData.x, column: gridData.x,

View file

@ -3,7 +3,13 @@
"compilerOptions": { "compilerOptions": {
"outDir": "target/types" "outDir": "target/types"
}, },
"include": ["index.ts", "public/**/*.ts", "public/**/*.tsx", "../../typings/**/*"], "include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"../../typings/**/*",
"public/**/*.json"
],
"exclude": ["target/**/*"], "exclude": ["target/**/*"],
"kbn_references": [ "kbn_references": [
"@kbn/grid-layout", "@kbn/grid-layout",
@ -11,7 +17,15 @@
"@kbn/core", "@kbn/core",
"@kbn/developer-examples-plugin", "@kbn/developer-examples-plugin",
"@kbn/core-lifecycle-browser", "@kbn/core-lifecycle-browser",
"@kbn/embeddable-plugin",
"@kbn/react-kibana-mount", "@kbn/react-kibana-mount",
"@kbn/i18n", "@kbn/i18n",
"@kbn/embeddable-examples-plugin",
"@kbn/react-kibana-context-render",
"@kbn/presentation-publishing",
"@kbn/es-query",
"@kbn/ui-actions-plugin",
"@kbn/embeddable-examples-plugin",
"@kbn/presentation-containers"
] ]
} }

View file

@ -10,7 +10,6 @@
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import React, { PropsWithChildren, useEffect, useRef } from 'react'; import React, { PropsWithChildren, useEffect, useRef } from 'react';
import { combineLatest } from 'rxjs'; import { combineLatest } from 'rxjs';
import { euiThemeVars } from '@kbn/ui-theme';
import { GridLayoutStateManager } from './types'; import { GridLayoutStateManager } from './types';
export const GridHeightSmoother = ({ export const GridHeightSmoother = ({
@ -30,6 +29,7 @@ export const GridHeightSmoother = ({
} }
if (!interactionEvent) { if (!interactionEvent) {
smoothHeightRef.current.style.height = `${dimensions.height}px`; smoothHeightRef.current.style.height = `${dimensions.height}px`;
smoothHeightRef.current.style.userSelect = 'auto';
return; return;
} }
@ -42,6 +42,7 @@ export const GridHeightSmoother = ({
dimensions.height ?? 0, dimensions.height ?? 0,
smoothHeightRef.current.getBoundingClientRect().height smoothHeightRef.current.getBoundingClientRect().height
)}px`; )}px`;
smoothHeightRef.current.style.userSelect = 'none';
}); });
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe( const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
@ -49,19 +50,9 @@ export const GridHeightSmoother = ({
if (!smoothHeightRef.current) return; if (!smoothHeightRef.current) return;
if (expandedPanelId) { if (expandedPanelId) {
const smoothHeightRefY = smoothHeightRef.current.style.height = `100%`;
smoothHeightRef.current.getBoundingClientRect().y + document.documentElement.scrollTop;
const gutterSize = parseFloat(euiThemeVars.euiSizeL);
// When panel is expanded, ensure the page occupies the full viewport height
// If the parent element is a flex container (preferred approach):
smoothHeightRef.current.style.flexBasis = `100%`;
// fallback in case parent is not a flex container (less reliable if shifts happen after the time we calculate smoothHeightRefY)
smoothHeightRef.current.style.height = `calc(100vh - ${smoothHeightRefY + gutterSize}px`;
smoothHeightRef.current.style.transition = 'none'; smoothHeightRef.current.style.transition = 'none';
} else { } else {
smoothHeightRef.current.style.flexBasis = '';
smoothHeightRef.current.style.height = ''; smoothHeightRef.current.style.height = '';
smoothHeightRef.current.style.transition = ''; smoothHeightRef.current.style.transition = '';
} }
@ -78,6 +69,8 @@ export const GridHeightSmoother = ({
<div <div
ref={smoothHeightRef} ref={smoothHeightRef}
css={css` css={css`
// the guttersize cannot currently change, so it's safe to set it just once
padding: ${gridLayoutStateManager.runtimeSettings$.getValue().gutterSize};
overflow-anchor: none; overflow-anchor: none;
transition: height 500ms linear; transition: height 500ms linear;
`} `}

View file

@ -24,7 +24,10 @@ import { resolveGridRow } from './utils/resolve_grid_row';
export interface GridLayoutProps { export interface GridLayoutProps {
layout: GridLayoutData; layout: GridLayoutData;
gridSettings: GridSettings; gridSettings: GridSettings;
renderPanelContents: (panelId: string) => React.ReactNode; renderPanelContents: (
panelId: string,
setDragHandles?: (refs: Array<HTMLElement | null>) => void
) => React.ReactNode;
onLayoutChange: (newLayout: GridLayoutData) => void; onLayoutChange: (newLayout: GridLayoutData) => void;
expandedPanelId?: string; expandedPanelId?: string;
accessMode?: GridAccessMode; accessMode?: GridAccessMode;

View file

@ -7,24 +7,88 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import React from 'react'; import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { EuiIcon, useEuiTheme } from '@elastic/eui'; import { EuiIcon, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme'; import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { PanelInteractionEvent } from '../types'; import { GridLayoutStateManager, PanelInteractionEvent } from '../types';
export const DragHandle = ({ export interface DragHandleApi {
interactionStart, setDragHandles: (refs: Array<HTMLElement | null>) => void;
}: { }
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop', export const DragHandle = React.forwardRef<
e: React.MouseEvent<HTMLButtonElement, MouseEvent> DragHandleApi,
) => void; {
}) => { gridLayoutStateManager: GridLayoutStateManager;
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}
>(({ gridLayoutStateManager, interactionStart }, ref) => {
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
return (
const removeEventListenersRef = useRef<(() => void) | null>(null);
const [dragHandleCount, setDragHandleCount] = useState<number>(0);
const dragHandleRefs = useRef<Array<HTMLElement | null>>([]);
/**
* We need to memoize the `onMouseDown` callback so that we don't assign a new `onMouseDown` event handler
* every time `setDragHandles` is called
*/
const onMouseDown = useCallback(
(e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (gridLayoutStateManager.accessMode$.getValue() !== 'EDIT' || e.button !== 0) {
// ignore anything but left clicks, and ignore clicks when not in edit mode
return;
}
e.stopPropagation();
interactionStart('drag', e);
},
[interactionStart, gridLayoutStateManager.accessMode$]
);
const setDragHandles = useCallback(
(dragHandles: Array<HTMLElement | null>) => {
setDragHandleCount(dragHandles.length);
dragHandleRefs.current = dragHandles;
for (const handle of dragHandles) {
if (handle === null) return;
handle.addEventListener('mousedown', onMouseDown, { passive: true });
}
removeEventListenersRef.current = () => {
for (const handle of dragHandles) {
if (handle === null) return;
handle.removeEventListener('mousedown', onMouseDown);
}
};
},
[onMouseDown]
);
useEffect(() => {
return () => {
// on unmount, remove all drag handle event listeners
if (removeEventListenersRef.current) {
removeEventListenersRef.current();
}
};
}, []);
useImperativeHandle(
ref,
() => {
return { setDragHandles };
},
[setDragHandles]
);
return Boolean(dragHandleCount) ? null : (
<button <button
aria-label={i18n.translate('kbnGridLayout.dragHandle.ariaLabel', { aria-label={i18n.translate('kbnGridLayout.dragHandle.ariaLabel', {
defaultMessage: 'Drag to move', defaultMessage: 'Drag to move',
@ -71,4 +135,4 @@ export const DragHandle = ({
<EuiIcon type="grabOmnidirectional" /> <EuiIcon type="grabOmnidirectional" />
</button> </button>
); );
}; });

View file

@ -7,24 +7,27 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import React, { forwardRef, useEffect, useMemo } from 'react'; import React, { forwardRef, useEffect, useMemo, useState } from 'react';
import { combineLatest, skip } from 'rxjs'; import { combineLatest, skip } from 'rxjs';
import { EuiPanel, euiFullHeight, useEuiOverflowScroll } from '@elastic/eui';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme'; import { euiThemeVars } from '@kbn/ui-theme';
import { GridLayoutStateManager, PanelInteractionEvent } from '../types'; import { GridLayoutStateManager, PanelInteractionEvent } from '../types';
import { getKeysInOrder } from '../utils/resolve_grid_row'; import { getKeysInOrder } from '../utils/resolve_grid_row';
import { DragHandle } from './drag_handle'; import { DragHandle, DragHandleApi } from './drag_handle';
import { ResizeHandle } from './resize_handle'; import { ResizeHandle } from './resize_handle';
export interface GridPanelProps { export interface GridPanelProps {
panelId: string; panelId: string;
rowIndex: number; rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode; renderPanelContents: (
panelId: string,
setDragHandles?: (refs: Array<HTMLElement | null>) => void
) => React.ReactNode;
interactionStart: ( interactionStart: (
type: PanelInteractionEvent['type'] | 'drop', type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLButtonElement, MouseEvent> e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void; ) => void;
gridLayoutStateManager: GridLayoutStateManager; gridLayoutStateManager: GridLayoutStateManager;
} }
@ -34,6 +37,37 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
{ panelId, rowIndex, renderPanelContents, interactionStart, gridLayoutStateManager }, { panelId, rowIndex, renderPanelContents, interactionStart, gridLayoutStateManager },
panelRef panelRef
) => { ) => {
const [dragHandleApi, setDragHandleApi] = useState<DragHandleApi | null>(null);
useEffect(() => {
const onDropEventHandler = (dropEvent: MouseEvent) => interactionStart('drop', dropEvent);
/**
* Subscription to add a singular "drop" event handler whenever an interaction starts -
* this is handled in a subscription so that it is not lost when the component gets remounted
* (which happens when a panel gets dragged from one grid row to another)
*/
const dropEventSubscription = gridLayoutStateManager.interactionEvent$.subscribe((event) => {
if (!event || event.id !== panelId) return;
/**
* By adding the "drop" event listener to the document rather than the drag/resize event handler,
* we prevent the element from getting "stuck" in an interaction; however, we only attach this event
* listener **when the drag/resize event starts**, and it only executes once (i.e. it removes itself
* once it executes, so we don't have to manually remove it outside of the unmount condition)
*/
document.addEventListener('mouseup', onDropEventHandler, {
once: true,
passive: true,
});
});
return () => {
dropEventSubscription.unsubscribe();
document.removeEventListener('mouseup', onDropEventHandler); // removes the event listener on row change
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** Set initial styles based on state at mount to prevent styles from "blipping" */ /** Set initial styles based on state at mount to prevent styles from "blipping" */
const initialStyles = useMemo(() => { const initialStyles = useMemo(() => {
const initialPanel = gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels[panelId]; const initialPanel = gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels[panelId];
@ -88,24 +122,22 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
// undo any "lock to grid" styles **except** for the top left corner, which stays locked // undo any "lock to grid" styles **except** for the top left corner, which stays locked
ref.style.gridColumnStart = `${panel.column + 1}`; ref.style.gridColumnStart = `${panel.column + 1}`;
ref.style.gridRowStart = `${panel.row + 1}`; ref.style.gridRowStart = `${panel.row + 1}`;
ref.style.gridColumnEnd = ``; ref.style.gridColumnEnd = `auto`;
ref.style.gridRowEnd = ``; ref.style.gridRowEnd = `auto`;
} else { } else {
// if the current panel is being dragged, render it with a fixed position + size // if the current panel is being dragged, render it with a fixed position + size
ref.style.position = `fixed`; ref.style.position = 'fixed';
ref.style.left = `${draggingPosition.left}px`; ref.style.left = `${draggingPosition.left}px`;
ref.style.top = `${draggingPosition.top}px`; ref.style.top = `${draggingPosition.top}px`;
ref.style.width = `${draggingPosition.right - draggingPosition.left}px`; ref.style.width = `${draggingPosition.right - draggingPosition.left}px`;
ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`; ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`;
// undo any "lock to grid" styles // undo any "lock to grid" styles
ref.style.gridColumnStart = ``; ref.style.gridArea = `auto`; // shortcut to set all grid styles to `auto`
ref.style.gridRowStart = ``;
ref.style.gridColumnEnd = ``;
ref.style.gridRowEnd = ``;
} }
} else { } else {
ref.style.zIndex = '0'; ref.style.zIndex = `auto`;
// if the panel is not being dragged and/or resized, undo any fixed position styles // if the panel is not being dragged and/or resized, undo any fixed position styles
ref.style.position = ''; ref.style.position = '';
@ -175,32 +207,27 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
* Memoize panel contents to prevent unnecessary re-renders * Memoize panel contents to prevent unnecessary re-renders
*/ */
const panelContents = useMemo(() => { const panelContents = useMemo(() => {
return renderPanelContents(panelId); if (!dragHandleApi) return <></>; // delays the rendering of the panel until after dragHandleApi is defined
}, [panelId, renderPanelContents]); return renderPanelContents(panelId, dragHandleApi.setDragHandles);
}, [panelId, renderPanelContents, dragHandleApi]);
return ( return (
<div ref={panelRef} css={initialStyles} className="kbnGridPanel"> <div ref={panelRef} css={initialStyles} className="kbnGridPanel">
<EuiPanel <div
hasShadow={false}
hasBorder={true}
css={css` css={css`
padding: 0; padding: 0;
position: relative;
height: 100%; height: 100%;
position: relative;
`} `}
> >
<DragHandle interactionStart={interactionStart} /> <DragHandle
<div ref={setDragHandleApi}
css={css` gridLayoutStateManager={gridLayoutStateManager}
${euiFullHeight()} interactionStart={interactionStart}
${useEuiOverflowScroll('y', false)} />
${useEuiOverflowScroll('x', false)} {panelContents}
`}
>
{panelContents}
</div>
<ResizeHandle interactionStart={interactionStart} /> <ResizeHandle interactionStart={interactionStart} />
</EuiPanel> </div>
</div> </div>
); );
} }

View file

@ -7,12 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import React from 'react';
import { transparentize } from '@elastic/eui'; import { transparentize } from '@elastic/eui';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import React from 'react';
import { PanelInteractionEvent } from '../types'; import { PanelInteractionEvent } from '../types';
export const ResizeHandle = ({ export const ResizeHandle = ({
@ -20,7 +19,7 @@ export const ResizeHandle = ({
}: { }: {
interactionStart: ( interactionStart: (
type: PanelInteractionEvent['type'] | 'drop', type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLButtonElement, MouseEvent> e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void; ) => void;
}) => { }) => {
return ( return (
@ -42,7 +41,9 @@ export const ResizeHandle = ({
margin: -2px; margin: -2px;
position: absolute; position: absolute;
width: ${euiThemeVars.euiSizeL}; width: ${euiThemeVars.euiSizeL};
max-width: 100%;
height: ${euiThemeVars.euiSizeL}; height: ${euiThemeVars.euiSizeL};
z-index: ${euiThemeVars.euiZLevel9};
transition: opacity 0.2s, border 0.2s; transition: opacity 0.2s, border 0.2s;
border-radius: 7px 0 7px 0; border-radius: 7px 0 7px 0;
border-bottom: 2px solid ${euiThemeVars.euiColorSuccess}; border-bottom: 2px solid ${euiThemeVars.euiColorSuccess};

View file

@ -23,7 +23,10 @@ import { GridRowHeader } from './grid_row_header';
export interface GridRowProps { export interface GridRowProps {
rowIndex: number; rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode; renderPanelContents: (
panelId: string,
setDragHandles?: (refs: Array<HTMLElement | null>) => void
) => React.ReactNode;
setInteractionEvent: (interactionData?: PanelInteractionEvent) => void; setInteractionEvent: (interactionData?: PanelInteractionEvent) => void;
gridLayoutStateManager: GridLayoutStateManager; gridLayoutStateManager: GridLayoutStateManager;
} }
@ -32,19 +35,13 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
({ rowIndex, renderPanelContents, setInteractionEvent, gridLayoutStateManager }, gridRef) => { ({ rowIndex, renderPanelContents, setInteractionEvent, gridLayoutStateManager }, gridRef) => {
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex]; const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
const [panelIds, setPanelIds] = useState<string[]>(() => getKeysInOrder(currentRow.panels)); const [panelIds, setPanelIds] = useState<string[]>(Object.keys(currentRow.panels));
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
getKeysInOrder(currentRow.panels)
);
const [rowTitle, setRowTitle] = useState<string>(currentRow.title); const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed); const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
/** Syncs panel IDs in order after a change in the grid layout, such as adding, removing, or reordering panels. */
const syncPanelIds = useCallback(() => {
const newPanelIds = getKeysInOrder(gridLayoutStateManager.gridLayout$.value[rowIndex].panels);
const hasOrderChanged = JSON.stringify(panelIds) !== JSON.stringify(newPanelIds);
if (hasOrderChanged) {
setPanelIds(newPanelIds);
}
}, [setPanelIds, gridLayoutStateManager.gridLayout$, rowIndex, panelIds]);
const getRowCount = useCallback( const getRowCount = useCallback(
(row: GridRowData) => { (row: GridRowData) => {
const maxRow = Object.values(row.panels).reduce((acc, panel) => { const maxRow = Object.values(row.panels).reduce((acc, panel) => {
@ -152,10 +149,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
* - Title * - Title
* - Collapsed state * - Collapsed state
* - Panel IDs (adding/removing/replacing, but not reordering) * - Panel IDs (adding/removing/replacing, but not reordering)
*
* Note: During dragging or resizing actions, the row should not re-render because panel positions are controlled via CSS styles for performance reasons.
* However, once the user finishes the interaction, the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility reasons (screen readers and focus management).
* This is handled in the syncPanelIds callback.
*/ */
const rowStateSubscription = gridLayoutStateManager.gridLayout$ const rowStateSubscription = gridLayoutStateManager.gridLayout$
.pipe( .pipe(
@ -163,7 +156,7 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
return { return {
title: gridLayout[rowIndex].title, title: gridLayout[rowIndex].title,
isCollapsed: gridLayout[rowIndex].isCollapsed, isCollapsed: gridLayout[rowIndex].isCollapsed,
panelIds: getKeysInOrder(gridLayout[rowIndex].panels), panelIds: Object.keys(gridLayout[rowIndex].panels),
}; };
}), }),
pairwise() pairwise()
@ -180,6 +173,9 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
) )
) { ) {
setPanelIds(newRowData.panelIds); setPanelIds(newRowData.panelIds);
setPanelIdsInOrder(
getKeysInOrder(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels)
);
} }
}); });
@ -194,64 +190,67 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
); );
/** /**
* Memoize panel children components to prevent unnecessary re-renders * Memoize panel children components (independent of their order) to prevent unnecessary re-renders
*/ */
const children = useMemo(() => { const children: { [panelId: string]: React.ReactNode } = useMemo(() => {
return panelIds.map((panelId) => ( return panelIds.reduce(
<GridPanel (prev, panelId) => ({
key={panelId} ...prev,
panelId={panelId} [panelId]: (
rowIndex={rowIndex} <GridPanel
gridLayoutStateManager={gridLayoutStateManager} key={panelId}
renderPanelContents={renderPanelContents} panelId={panelId}
interactionStart={(type, e) => { rowIndex={rowIndex}
e.preventDefault(); gridLayoutStateManager={gridLayoutStateManager}
e.stopPropagation(); renderPanelContents={renderPanelContents}
interactionStart={(type, e) => {
e.stopPropagation();
// Disable interactions when a panel is expanded // Disable interactions when a panel is expanded
const isInteractive = gridLayoutStateManager.expandedPanelId$.value === undefined; const isInteractive = gridLayoutStateManager.expandedPanelId$.value === undefined;
if (!isInteractive) return; if (!isInteractive) return;
const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
if (!panelRef) return; if (!panelRef) return;
const panelRect = panelRef.getBoundingClientRect(); const panelRect = panelRef.getBoundingClientRect();
if (type === 'drop') { if (type === 'drop') {
setInteractionEvent(undefined); setInteractionEvent(undefined);
// Ensure the row re-renders to reflect the new panel order after a drag-and-drop interaction. /**
// the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility reasons (screen readers and focus management). * Ensure the row re-renders to reflect the new panel order after a drag-and-drop interaction, since
syncPanelIds(); * the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility
} else { * reasons (screen readers and focus management).
setInteractionEvent({ */
type, setPanelIdsInOrder(
id: panelId, getKeysInOrder(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels)
panelDiv: panelRef, );
targetRowIndex: rowIndex, } else {
mouseOffsets: { setInteractionEvent({
top: e.clientY - panelRect.top, type,
left: e.clientX - panelRect.left, id: panelId,
right: e.clientX - panelRect.right, panelDiv: panelRef,
bottom: e.clientY - panelRect.bottom, targetRowIndex: rowIndex,
}, mouseOffsets: {
}); top: e.clientY - panelRect.top,
} left: e.clientX - panelRect.left,
}} right: e.clientX - panelRect.right,
ref={(element) => { bottom: e.clientY - panelRect.bottom,
if (!gridLayoutStateManager.panelRefs.current[rowIndex]) { },
gridLayoutStateManager.panelRefs.current[rowIndex] = {}; });
} }
gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element; }}
}} ref={(element) => {
/> if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {
)); gridLayoutStateManager.panelRefs.current[rowIndex] = {};
}, [ }
panelIds, gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element;
rowIndex, }}
gridLayoutStateManager, />
renderPanelContents, ),
setInteractionEvent, }),
syncPanelIds, {}
]); );
}, [panelIds, gridLayoutStateManager, renderPanelContents, rowIndex, setInteractionEvent]);
return ( return (
<div ref={rowContainer}> <div ref={rowContainer}>
@ -276,7 +275,8 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
${initialStyles}; ${initialStyles};
`} `}
> >
{children} {/* render the panels **in order** for accessibility, using the memoized panel components */}
{panelIdsInOrder.map((panelId) => children[panelId])}
<DragPreview rowIndex={rowIndex} gridLayoutStateManager={gridLayoutStateManager} /> <DragPreview rowIndex={rowIndex} gridLayoutStateManager={gridLayoutStateManager} />
</div> </div>
)} )}

View file

@ -12,6 +12,7 @@ import { BehaviorSubject } from 'rxjs';
import { ObservedSize } from 'use-resize-observer/polyfilled'; import { ObservedSize } from 'use-resize-observer/polyfilled';
import { import {
ActivePanel, ActivePanel,
GridAccessMode,
GridLayoutData, GridLayoutData,
GridLayoutStateManager, GridLayoutStateManager,
PanelInteractionEvent, PanelInteractionEvent,
@ -46,6 +47,7 @@ export const gridLayoutStateManagerMock: GridLayoutStateManager = {
runtimeSettings$, runtimeSettings$,
panelRefs: { current: [] }, panelRefs: { current: [] },
rowRefs: { current: [] }, rowRefs: { current: [] },
accessMode$: new BehaviorSubject<GridAccessMode>('EDIT'),
interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined), interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined),
activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined), activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined),
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }), gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),

View file

@ -60,6 +60,7 @@ export interface GridLayoutStateManager {
gridLayout$: BehaviorSubject<GridLayoutData>; gridLayout$: BehaviorSubject<GridLayoutData>;
expandedPanelId$: BehaviorSubject<string | undefined>; expandedPanelId$: BehaviorSubject<string | undefined>;
isMobileView$: BehaviorSubject<boolean>; isMobileView$: BehaviorSubject<boolean>;
accessMode$: BehaviorSubject<GridAccessMode>;
gridDimensions$: BehaviorSubject<ObservedSize>; gridDimensions$: BehaviorSubject<ObservedSize>;
runtimeSettings$: BehaviorSubject<RuntimeGridSettings>; runtimeSettings$: BehaviorSubject<RuntimeGridSettings>;

View file

@ -13,18 +13,42 @@ import { resolveGridRow } from './utils/resolve_grid_row';
import { GridPanelData, GridLayoutStateManager } from './types'; import { GridPanelData, GridLayoutStateManager } from './types';
import { isGridDataEqual } from './utils/equality_checks'; import { isGridDataEqual } from './utils/equality_checks';
const MIN_SPEED = 50;
const MAX_SPEED = 150;
const scrollOnInterval = (direction: 'up' | 'down') => { const scrollOnInterval = (direction: 'up' | 'down') => {
let count = 0; let count = 0;
let currentSpeed = MIN_SPEED;
let maxSpeed = MIN_SPEED;
let turnAroundPoint: number | undefined;
const interval = setInterval(() => { const interval = setInterval(() => {
// calculate the speed based on how long the interval has been going to create an ease effect /**
// via the parabola formula `y = a(x - h)^2 + k` * Since "smooth" scrolling on an interval is jittery on Chrome, we are manually creating
// - the starting speed is k = 50 * an "ease" effect via the parabola formula `y = a(x - h)^2 + k`
// - the maximum speed is 250 *
// - the rate at which the speed increases is controlled by a = 0.75 * Scrolling slowly speeds up as the user drags, and it slows down again as they approach the
const speed = Math.min(0.75 * count ** 2 + 50, 250); * top and/or bottom of the screen.
window.scrollBy({ top: direction === 'down' ? speed : -speed, behavior: 'smooth' }); */
count++; const nearTop = direction === 'up' && scrollY < window.innerHeight;
}, 100); const nearBottom =
direction === 'down' &&
window.innerHeight + window.scrollY > document.body.scrollHeight - window.innerHeight;
if (!turnAroundPoint && (nearTop || nearBottom)) {
// reverse the direction of the parabola
maxSpeed = currentSpeed;
turnAroundPoint = count;
}
currentSpeed = turnAroundPoint
? Math.max(-3 * (count - turnAroundPoint) ** 2 + maxSpeed, MIN_SPEED) // slow down fast
: Math.min(0.1 * count ** 2 + MIN_SPEED, MAX_SPEED); // speed up slowly
window.scrollBy({
top: direction === 'down' ? currentSpeed : -currentSpeed,
});
count++; // increase the counter to increase the time interval used in the parabola formula
}, 60);
return interval; return interval;
}; };
@ -56,7 +80,6 @@ export const useGridLayoutEvents = ({
stopAutoScrollIfNecessary(); stopAutoScrollIfNecessary();
return; return;
} }
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const gridRowElements = gridLayoutStateManager.rowRefs.current; const gridRowElements = gridLayoutStateManager.rowRefs.current;
@ -154,8 +177,11 @@ export const useGridLayoutEvents = ({
// auto scroll when an event is happening close to the top or bottom of the screen // auto scroll when an event is happening close to the top or bottom of the screen
const heightPercentage = const heightPercentage =
100 - ((window.innerHeight - mouseTargetPixel.y) / window.innerHeight) * 100; 100 - ((window.innerHeight - mouseTargetPixel.y) / window.innerHeight) * 100;
const startScrollingUp = !isResize && heightPercentage < 5; // don't scroll up when resizing const atTheTop = window.scrollY <= 0;
const startScrollingDown = heightPercentage > 95; const atTheBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight;
const startScrollingUp = !isResize && heightPercentage < 5 && !atTheTop; // don't scroll up when resizing
const startScrollingDown = heightPercentage > 95 && !atTheBottom;
if (startScrollingUp || startScrollingDown) { if (startScrollingUp || startScrollingDown) {
if (!scrollInterval.current) { if (!scrollInterval.current) {
// only start scrolling if it's not already happening // only start scrolling if it's not already happening
@ -202,8 +228,9 @@ export const useGridLayoutEvents = ({
calculateUserEvent(e); calculateUserEvent(e);
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove, { passive: true });
document.addEventListener('scroll', calculateUserEvent); document.addEventListener('scroll', calculateUserEvent, { passive: true });
return () => { return () => {
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('scroll', calculateUserEvent); document.removeEventListener('scroll', calculateUserEvent);

View file

@ -10,6 +10,7 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { BehaviorSubject, combineLatest, debounceTime } from 'rxjs'; import { BehaviorSubject, combineLatest, debounceTime } from 'rxjs';
import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled'; import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled';
import { cloneDeep } from 'lodash';
import { import {
ActivePanel, ActivePanel,
@ -21,6 +22,7 @@ import {
RuntimeGridSettings, RuntimeGridSettings,
} from './types'; } from './types';
import { shouldShowMobileView } from './utils/mobile_view'; import { shouldShowMobileView } from './utils/mobile_view';
import { resolveGridRow } from './utils/resolve_grid_row';
export const useGridLayoutState = ({ export const useGridLayoutState = ({
layout, layout,
@ -59,7 +61,12 @@ export const useGridLayoutState = ({
}, [accessMode, accessMode$]); }, [accessMode, accessMode$]);
const gridLayoutStateManager = useMemo(() => { const gridLayoutStateManager = useMemo(() => {
const gridLayout$ = new BehaviorSubject<GridLayoutData>(layout); const resolvedLayout = cloneDeep(layout);
resolvedLayout.forEach((row, rowIndex) => {
resolvedLayout[rowIndex] = resolveGridRow(row);
});
const gridLayout$ = new BehaviorSubject<GridLayoutData>(resolvedLayout);
const gridDimensions$ = new BehaviorSubject<ObservedSize>({ width: 0, height: 0 }); const gridDimensions$ = new BehaviorSubject<ObservedSize>({ width: 0, height: 0 });
const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined); const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined);
const activePanel$ = new BehaviorSubject<ActivePanel | undefined>(undefined); const activePanel$ = new BehaviorSubject<ActivePanel | undefined>(undefined);
@ -77,6 +84,7 @@ export const useGridLayoutState = ({
panelIds$, panelIds$,
gridLayout$, gridLayout$,
activePanel$, activePanel$,
accessMode$,
gridDimensions$, gridDimensions$,
runtimeSettings$, runtimeSettings$,
interactionEvent$, interactionEvent$,

View file

@ -1,9 +1,7 @@
{ {
"type": "plugin", "type": "plugin",
"id": "@kbn/embeddable-plugin", "id": "@kbn/embeddable-plugin",
"owner": [ "owner": ["@elastic/kibana-presentation"],
"@elastic/kibana-presentation"
],
"group": "platform", "group": "platform",
"visibility": "shared", "visibility": "shared",
"description": "Adds embeddables service to Kibana", "description": "Adds embeddables service to Kibana",
@ -19,17 +17,8 @@
"savedObjectsManagement", "savedObjectsManagement",
"contentManagement" "contentManagement"
], ],
"optionalPlugins": [ "optionalPlugins": ["savedObjectsTaggingOss", "usageCollection"],
"savedObjectsTaggingOss", "requiredBundles": ["savedObjects", "kibanaUtils", "presentationPanel"],
"usageCollection" "extraPublicDirs": ["common"]
],
"requiredBundles": [
"savedObjects",
"kibanaUtils",
"presentationPanel"
],
"extraPublicDirs": [
"common"
]
} }
} }

View file

@ -65,6 +65,7 @@ export const ReactEmbeddableRenderer = <
| 'hideLoader' | 'hideLoader'
| 'hideHeader' | 'hideHeader'
| 'hideInspector' | 'hideInspector'
| 'setDragHandles'
| 'getActions' | 'getActions'
>; >;
hidePanelChrome?: boolean; hidePanelChrome?: boolean;

View file

@ -10,7 +10,7 @@
import { EuiScreenReaderOnly } from '@elastic/eui'; import { EuiScreenReaderOnly } from '@elastic/eui';
import { ViewMode } from '@kbn/presentation-publishing'; import { ViewMode } from '@kbn/presentation-publishing';
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React, { useCallback } from 'react';
import { getAriaLabelForTitle } from '../presentation_panel_strings'; import { getAriaLabelForTitle } from '../presentation_panel_strings';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from '../types'; import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from '../types';
import { PresentationPanelTitle } from './presentation_panel_title'; import { PresentationPanelTitle } from './presentation_panel_title';
@ -23,6 +23,7 @@ export type PresentationPanelHeaderProps<ApiType extends DefaultPresentationPane
hideTitle?: boolean; hideTitle?: boolean;
panelTitle?: string; panelTitle?: string;
panelDescription?: string; panelDescription?: string;
setDragHandle: (id: string, ref: HTMLDivElement | null) => void;
} & Pick<PresentationPanelInternalProps, 'showBadges' | 'getActions' | 'showNotifications'>; } & Pick<PresentationPanelInternalProps, 'showBadges' | 'getActions' | 'showNotifications'>;
export const PresentationPanelHeader = < export const PresentationPanelHeader = <
@ -35,6 +36,7 @@ export const PresentationPanelHeader = <
hideTitle, hideTitle,
panelTitle, panelTitle,
panelDescription, panelDescription,
setDragHandle,
showBadges = true, showBadges = true,
showNotifications = true, showNotifications = true,
}: PresentationPanelHeaderProps<ApiType>) => { }: PresentationPanelHeaderProps<ApiType>) => {
@ -45,6 +47,14 @@ export const PresentationPanelHeader = <
getActions getActions
); );
const memoizedSetDragHandle = useCallback(
// memoize the ref callback so that we don't call `setDragHandle` on every render
(ref: HTMLHeadingElement | null) => {
setDragHandle('panelHeader', ref);
},
[setDragHandle]
);
const showPanelBar = const showPanelBar =
(!hideTitle && panelTitle) || badgeElements.length > 0 || notificationElements.length > 0; (!hideTitle && panelTitle) || badgeElements.length > 0 || notificationElements.length > 0;
@ -71,7 +81,7 @@ export const PresentationPanelHeader = <
className={headerClasses} className={headerClasses}
data-test-subj={`embeddablePanelHeading-${(panelTitle || '').replace(/\s/g, '')}`} data-test-subj={`embeddablePanelHeading-${(panelTitle || '').replace(/\s/g, '')}`}
> >
<h2 data-test-subj="dashboardPanelTitle" className={titleClasses}> <h2 ref={memoizedSetDragHandle} data-test-subj="dashboardPanelTitle" className={titleClasses}>
{ariaLabelElement} {ariaLabelElement}
<PresentationPanelTitle <PresentationPanelTitle
api={api} api={api}

View file

@ -101,6 +101,7 @@ export const PresentationPanelHoverActions = ({
api, api,
index, index,
getActions, getActions,
setDragHandle,
actionPredicate, actionPredicate,
children, children,
className, className,
@ -111,6 +112,7 @@ export const PresentationPanelHoverActions = ({
index?: number; index?: number;
api: DefaultPresentationPanelApi | null; api: DefaultPresentationPanelApi | null;
getActions: PresentationPanelInternalProps['getActions']; getActions: PresentationPanelInternalProps['getActions'];
setDragHandle: (id: string, ref: HTMLElement | null) => void;
actionPredicate?: (actionId: string) => boolean; actionPredicate?: (actionId: string) => boolean;
children: ReactElement; children: ReactElement;
className?: string; className?: string;
@ -124,9 +126,10 @@ export const PresentationPanelHoverActions = ({
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean>(false); const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean>(false);
const [notifications, setNotifications] = useState<AnyApiAction[]>([]); const [notifications, setNotifications] = useState<AnyApiAction[]>([]);
const hoverActionsRef = useRef<HTMLDivElement | null>(null); const hoverActionsRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
const anchorRef = useRef<HTMLDivElement | null>(null); const anchorRef = useRef<HTMLDivElement | null>(null);
const leftHoverActionsRef = useRef<HTMLDivElement | null>(null);
const rightHoverActionsRef = useRef<HTMLDivElement | null>(null); const rightHoverActionsRef = useRef<HTMLDivElement | null>(null);
const [combineHoverActions, setCombineHoverActions] = useState<boolean>(false); const [combineHoverActions, setCombineHoverActions] = useState<boolean>(false);
const [borderStyles, setBorderStyles] = useState<string>(TOP_ROUNDED_CORNERS); const [borderStyles, setBorderStyles] = useState<string>(TOP_ROUNDED_CORNERS);
@ -138,14 +141,14 @@ export const PresentationPanelHoverActions = ({
const anchorWidth = anchorRef.current.offsetWidth; const anchorWidth = anchorRef.current.offsetWidth;
const hoverActionsWidth = const hoverActionsWidth =
(rightHoverActionsRef.current?.offsetWidth ?? 0) + (rightHoverActionsRef.current?.offsetWidth ?? 0) +
(leftHoverActionsRef.current?.offsetWidth ?? 0) + (dragHandleRef.current?.offsetWidth ?? 0) +
parseInt(euiThemeVars.euiSize, 10) * 2; parseInt(euiThemeVars.euiSize, 10) * 2;
const hoverActionsHeight = rightHoverActionsRef.current?.offsetHeight ?? 0; const hoverActionsHeight = rightHoverActionsRef.current?.offsetHeight ?? 0;
// Left align hover actions when they would get cut off by the right edge of the window // Left align hover actions when they would get cut off by the right edge of the window
if (anchorLeft - (hoverActionsWidth - anchorWidth) <= parseInt(euiThemeVars.euiSize, 10)) { if (anchorLeft - (hoverActionsWidth - anchorWidth) <= parseInt(euiThemeVars.euiSize, 10)) {
hoverActionsRef.current.style.removeProperty('right'); dragHandleRef.current?.style.removeProperty('right');
hoverActionsRef.current.style.setProperty('left', '0'); dragHandleRef.current?.style.setProperty('left', '0');
} else { } else {
hoverActionsRef.current.style.removeProperty('left'); hoverActionsRef.current.style.removeProperty('left');
hoverActionsRef.current.style.setProperty('right', '0'); hoverActionsRef.current.style.setProperty('right', '0');
@ -442,19 +445,30 @@ export const PresentationPanelHoverActions = ({
/> />
); );
const dragHandle = ( const dragHandle = useMemo(
<EuiIcon // memoize the drag handle to avoid calling `setDragHandle` unnecessarily
type="move" () => (
color="text" <button
className={`${viewMode === 'edit' ? 'embPanel--dragHandle' : ''}`} ref={(ref) => {
aria-label={i18n.translate('presentationPanel.dragHandle', { dragHandleRef.current = ref;
defaultMessage: 'Move panel', setDragHandle('hoverActions', ref);
})} }}
data-test-subj="embeddablePanelDragHandle" >
css={css` <EuiIcon
margin: ${euiThemeVars.euiSizeXS}; type="move"
`} color="text"
/> className={`embPanel--dragHandle`}
aria-label={i18n.translate('presentationPanel.dragHandle', {
defaultMessage: 'Move panel',
})}
data-test-subj="embeddablePanelDragHandle"
css={css`
margin: ${euiThemeVars.euiSizeXS};
`}
/>
</button>
),
[setDragHandle]
); );
const hasHoverActions = quickActionElements.length || contextMenuPanels.lastIndexOf.length; const hasHoverActions = quickActionElements.length || contextMenuPanels.lastIndexOf.length;
@ -535,7 +549,6 @@ export const PresentationPanelHoverActions = ({
> >
{viewMode === 'edit' && !combineHoverActions ? ( {viewMode === 'edit' && !combineHoverActions ? (
<div <div
ref={leftHoverActionsRef}
data-test-subj="embPanel__hoverActions__left" data-test-subj="embPanel__hoverActions__left"
className={classNames( className={classNames(
'embPanel__hoverActions', 'embPanel__hoverActions',

View file

@ -15,9 +15,9 @@ import {
useBatchedOptionalPublishingSubjects, useBatchedOptionalPublishingSubjects,
} from '@kbn/presentation-publishing'; } from '@kbn/presentation-publishing';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useMemo, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { PresentationPanelHoverActions } from './panel_header/presentation_panel_hover_actions';
import { PresentationPanelHeader } from './panel_header/presentation_panel_header'; import { PresentationPanelHeader } from './panel_header/presentation_panel_header';
import { PresentationPanelHoverActions } from './panel_header/presentation_panel_hover_actions';
import { PresentationPanelError } from './presentation_panel_error'; import { PresentationPanelError } from './presentation_panel_error';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from './types'; import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from './types';
@ -37,10 +37,14 @@ export const PresentationPanelInternal = <
Component, Component,
componentProps, componentProps,
setDragHandles,
}: PresentationPanelInternalProps<ApiType, ComponentPropsType>) => { }: PresentationPanelInternalProps<ApiType, ComponentPropsType>) => {
const [api, setApi] = useState<ApiType | null>(null); const [api, setApi] = useState<ApiType | null>(null);
const headerId = useMemo(() => htmlIdGenerator()(), []); const headerId = useMemo(() => htmlIdGenerator()(), []);
const dragHandles = useRef<{ [dragHandleKey: string]: HTMLElement | null }>({});
const viewModeSubject = (() => { const viewModeSubject = (() => {
if (apiPublishesViewMode(api)) return api.viewMode; if (apiPublishesViewMode(api)) return api.viewMode;
if (apiHasParentApi(api) && apiPublishesViewMode(api.parentApi)) return api.parentApi.viewMode; if (apiHasParentApi(api) && apiPublishesViewMode(api.parentApi)) return api.parentApi.viewMode;
@ -90,9 +94,26 @@ export const PresentationPanelInternal = <
return attrs; return attrs;
}, [dataLoading, blockingError]); }, [dataLoading, blockingError]);
const setDragHandle = useCallback(
(id: string, ref: HTMLElement | null) => {
dragHandles.current[id] = ref;
setDragHandles?.(Object.values(dragHandles.current));
},
[setDragHandles]
);
return ( return (
<PresentationPanelHoverActions <PresentationPanelHoverActions
{...{ index, api, getActions, actionPredicate, viewMode, showNotifications, showBorder }} {...{
index,
api,
getActions,
actionPredicate,
viewMode,
showNotifications,
showBorder,
}}
setDragHandle={setDragHandle}
> >
<EuiPanel <EuiPanel
role="figure" role="figure"
@ -108,6 +129,7 @@ export const PresentationPanelInternal = <
{!hideHeader && api && ( {!hideHeader && api && (
<PresentationPanelHeader <PresentationPanelHeader
api={api} api={api}
setDragHandle={setDragHandle}
headerId={headerId} headerId={headerId}
viewMode={viewMode} viewMode={viewMode}
hideTitle={hideTitle} hideTitle={hideTitle}

View file

@ -58,6 +58,13 @@ export interface PresentationPanelInternalProps<
* "title" when the panel has no title, i.e. "Panel {index}". * "title" when the panel has no title, i.e. "Panel {index}".
*/ */
index?: number; index?: number;
/**
* Set the drag handlers to be used by kbn-grid-layout
* Note: If we make kbn-grid-layout responsible for **all** panel placement
* logic, then this could be removed.
*/
setDragHandles?: (refs: Array<HTMLElement | null>) => void;
} }
/** /**