[8.x] [Dashboard] [Collapsable Panels] Add embeddable support (#198413) (#203652)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Dashboard] [Collapsable Panels] Add embeddable support
(#198413)](https://github.com/elastic/kibana/pull/198413)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Hannah
Mudge","email":"Heenawter@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-12-10T17:14:31Z","message":"[Dashboard]
[Collapsable Panels] Add embeddable support (#198413)\n\nCloses
https://github.com/elastic/kibana/issues/190379\r\n\r\n##
Summary\r\n\r\nThis PR switches the example grid layout app to render
embeddables as\r\npanels rather than the simplified mock panel we were
using previously.\r\nIn doing so, I had to add the ability for custom
panels to add a custom\r\ndrag handle via the `renderPanelContents`
callback - this required\r\nadding a `setDragHandles` callback to the
`ReactEmbeddableRenderer` that\r\ncould be passed down to the
`PresentationPanel`
component.\r\n\r\n\r\n\r\n\r\nhttps://github.com/user-attachments/assets/9e2c68f9-34af-4360-a978-9113701a5ea2\r\n\r\n\r\n\r\n####
New scroll behaviour\r\n\r\nIn
https://github.com/elastic/kibana/pull/201867, I introduced a
small\r\n\"ease\" to the auto-scroll effect that happens when you drag a
panel to\r\nthe top or bottom of the window. However, in that PR, I was
using the\r\n`smooth` scrolling behaviour, which unfortunately became
**very**\r\njittery once I switched to embeddables rather than simple
panels\r\n(specifically in Chrome - it worked fine in
Firefox).\r\n\r\nThe only way to prevent this jittery scroll was to
switch to the default\r\nscroll behaviour, but this lead to a very
**abrupt** stop when the\r\nscrollbar reached the top and/or bottom of
the page - so, to give the\r\nsame \"gentle\" stop that the `smooth`
scroll had, I decided to recreate\r\nthis effect by adding a slow down
\"ease\" when close to the top or bottom\r\nof the
page:\r\n\r\n\r\nhttps://github.com/user-attachments/assets/cb7bf03f-4a9e-4446-be4f-8f54c0bc88ac\r\n\r\nThis
effect is accomplished via the parabola formula `y = a(x-h)2 + k`\r\nand
can be roughly visualized with the following, which shows that
the\r\n\"speed up\" ease happens at a much slower pace than the \"slow
down\"
ease:\r\n\r\n\r\n![image](https://github.com/user-attachments/assets/02b4389c-fe78-448d-9c02-c4ec5e722d5e)\r\n\r\n\r\n\r\n\r\n####
Notes about parent changes\r\nAs I investigated improving the efficiency
of the grid layout with\r\nembeddables, one of the main things I noticed
was that the grid panel\r\nwas **always** remounted when moving a panel
from one collapsible\r\nsection to another. This lead me (and
@ThomThomson) down a rabbit hole\r\nof React-reparenting, and we
explored a few different options to see if\r\nwe could change the parent
of a component **without** having it remount.\r\n\r\nIn summary, after
various experiments and a whole bunch of research, we\r\ndetermined
that, due to the reconciliation of the React tree, this
is\r\nunfortunately impossible. So our priorities will instead have to
move to\r\nmaking the remount of `ReactEmbeddableRenderer` **as
efficient as\r\npossible** via caching, since the remount is
inevitable.\r\n\r\n### Checklist\r\n\r\n- [x] The PR description
includes the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n###
Identify risks\r\n\r\nThere are no risks to this PR, since the most
significant work is\r\ncontained in the `examples` plugin. Some changes
were made to the\r\npresentation panel to allow for custom drag handles,
but this isn't\r\nactually used in Dashboard - so for now, this code is
only called in the\r\nexample plugin, as
well.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"2a76fe3ee432d0b6746eae660cfe31fc71d15547","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Dashboard","Feature:Embedding","Team:Presentation","loe:medium","release_note:skip","impact:high","v9.0.0","backport:prev-minor","Project:Collapsable
Panels"],"title":"[Dashboard] [Collapsable Panels] Add embeddable
support","number":198413,"url":"https://github.com/elastic/kibana/pull/198413","mergeCommit":{"message":"[Dashboard]
[Collapsable Panels] Add embeddable support (#198413)\n\nCloses
https://github.com/elastic/kibana/issues/190379\r\n\r\n##
Summary\r\n\r\nThis PR switches the example grid layout app to render
embeddables as\r\npanels rather than the simplified mock panel we were
using previously.\r\nIn doing so, I had to add the ability for custom
panels to add a custom\r\ndrag handle via the `renderPanelContents`
callback - this required\r\nadding a `setDragHandles` callback to the
`ReactEmbeddableRenderer` that\r\ncould be passed down to the
`PresentationPanel`
component.\r\n\r\n\r\n\r\n\r\nhttps://github.com/user-attachments/assets/9e2c68f9-34af-4360-a978-9113701a5ea2\r\n\r\n\r\n\r\n####
New scroll behaviour\r\n\r\nIn
https://github.com/elastic/kibana/pull/201867, I introduced a
small\r\n\"ease\" to the auto-scroll effect that happens when you drag a
panel to\r\nthe top or bottom of the window. However, in that PR, I was
using the\r\n`smooth` scrolling behaviour, which unfortunately became
**very**\r\njittery once I switched to embeddables rather than simple
panels\r\n(specifically in Chrome - it worked fine in
Firefox).\r\n\r\nThe only way to prevent this jittery scroll was to
switch to the default\r\nscroll behaviour, but this lead to a very
**abrupt** stop when the\r\nscrollbar reached the top and/or bottom of
the page - so, to give the\r\nsame \"gentle\" stop that the `smooth`
scroll had, I decided to recreate\r\nthis effect by adding a slow down
\"ease\" when close to the top or bottom\r\nof the
page:\r\n\r\n\r\nhttps://github.com/user-attachments/assets/cb7bf03f-4a9e-4446-be4f-8f54c0bc88ac\r\n\r\nThis
effect is accomplished via the parabola formula `y = a(x-h)2 + k`\r\nand
can be roughly visualized with the following, which shows that
the\r\n\"speed up\" ease happens at a much slower pace than the \"slow
down\"
ease:\r\n\r\n\r\n![image](https://github.com/user-attachments/assets/02b4389c-fe78-448d-9c02-c4ec5e722d5e)\r\n\r\n\r\n\r\n\r\n####
Notes about parent changes\r\nAs I investigated improving the efficiency
of the grid layout with\r\nembeddables, one of the main things I noticed
was that the grid panel\r\nwas **always** remounted when moving a panel
from one collapsible\r\nsection to another. This lead me (and
@ThomThomson) down a rabbit hole\r\nof React-reparenting, and we
explored a few different options to see if\r\nwe could change the parent
of a component **without** having it remount.\r\n\r\nIn summary, after
various experiments and a whole bunch of research, we\r\ndetermined
that, due to the reconciliation of the React tree, this
is\r\nunfortunately impossible. So our priorities will instead have to
move to\r\nmaking the remount of `ReactEmbeddableRenderer` **as
efficient as\r\npossible** via caching, since the remount is
inevitable.\r\n\r\n### Checklist\r\n\r\n- [x] The PR description
includes the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n###
Identify risks\r\n\r\nThere are no risks to this PR, since the most
significant work is\r\ncontained in the `examples` plugin. Some changes
were made to the\r\npresentation panel to allow for custom drag handles,
but this isn't\r\nactually used in Dashboard - so for now, this code is
only called in the\r\nexample plugin, as
well.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"2a76fe3ee432d0b6746eae660cfe31fc71d15547"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/198413","number":198413,"mergeCommit":{"message":"[Dashboard]
[Collapsable Panels] Add embeddable support (#198413)\n\nCloses
https://github.com/elastic/kibana/issues/190379\r\n\r\n##
Summary\r\n\r\nThis PR switches the example grid layout app to render
embeddables as\r\npanels rather than the simplified mock panel we were
using previously.\r\nIn doing so, I had to add the ability for custom
panels to add a custom\r\ndrag handle via the `renderPanelContents`
callback - this required\r\nadding a `setDragHandles` callback to the
`ReactEmbeddableRenderer` that\r\ncould be passed down to the
`PresentationPanel`
component.\r\n\r\n\r\n\r\n\r\nhttps://github.com/user-attachments/assets/9e2c68f9-34af-4360-a978-9113701a5ea2\r\n\r\n\r\n\r\n####
New scroll behaviour\r\n\r\nIn
https://github.com/elastic/kibana/pull/201867, I introduced a
small\r\n\"ease\" to the auto-scroll effect that happens when you drag a
panel to\r\nthe top or bottom of the window. However, in that PR, I was
using the\r\n`smooth` scrolling behaviour, which unfortunately became
**very**\r\njittery once I switched to embeddables rather than simple
panels\r\n(specifically in Chrome - it worked fine in
Firefox).\r\n\r\nThe only way to prevent this jittery scroll was to
switch to the default\r\nscroll behaviour, but this lead to a very
**abrupt** stop when the\r\nscrollbar reached the top and/or bottom of
the page - so, to give the\r\nsame \"gentle\" stop that the `smooth`
scroll had, I decided to recreate\r\nthis effect by adding a slow down
\"ease\" when close to the top or bottom\r\nof the
page:\r\n\r\n\r\nhttps://github.com/user-attachments/assets/cb7bf03f-4a9e-4446-be4f-8f54c0bc88ac\r\n\r\nThis
effect is accomplished via the parabola formula `y = a(x-h)2 + k`\r\nand
can be roughly visualized with the following, which shows that
the\r\n\"speed up\" ease happens at a much slower pace than the \"slow
down\"
ease:\r\n\r\n\r\n![image](https://github.com/user-attachments/assets/02b4389c-fe78-448d-9c02-c4ec5e722d5e)\r\n\r\n\r\n\r\n\r\n####
Notes about parent changes\r\nAs I investigated improving the efficiency
of the grid layout with\r\nembeddables, one of the main things I noticed
was that the grid panel\r\nwas **always** remounted when moving a panel
from one collapsible\r\nsection to another. This lead me (and
@ThomThomson) down a rabbit hole\r\nof React-reparenting, and we
explored a few different options to see if\r\nwe could change the parent
of a component **without** having it remount.\r\n\r\nIn summary, after
various experiments and a whole bunch of research, we\r\ndetermined
that, due to the reconciliation of the React tree, this
is\r\nunfortunately impossible. So our priorities will instead have to
move to\r\nmaking the remount of `ReactEmbeddableRenderer` **as
efficient as\r\npossible** via caching, since the remount is
inevitable.\r\n\r\n### Checklist\r\n\r\n- [x] The PR description
includes the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n###
Identify risks\r\n\r\nThere are no risks to this PR, since the most
significant work is\r\ncontained in the `examples` plugin. Some changes
were made to the\r\npresentation panel to allow for custom drag handles,
but this isn't\r\nactually used in Dashboard - so for now, this code is
only called in the\r\nexample plugin, as
well.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"2a76fe3ee432d0b6746eae660cfe31fc71d15547"}}]}]
BACKPORT-->

Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-12-11 06:12:29 +11:00 committed by GitHub
parent bcfa88ec96
commit d223cd7df5
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 { 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[]>([]);
@ -73,7 +72,7 @@ export function AddButton({ pageApi, uiActions }: { pageApi: PageApi; uiActions:
setIsPopoverOpen(!isPopoverOpen);
}}
>
Add
Add panel
</EuiButton>
}
isOpen={isPopoverOpen}

View file

@ -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();

View file

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

View file

@ -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,
@ -45,23 +46,37 @@ 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 }));
@ -69,58 +84,31 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
// 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'}
@ -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
@ -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">
@ -180,7 +155,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
})}
options={[
{
id: 'VIEW',
id: 'view',
label: i18n.translate('examples.gridExample.viewOption', {
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.',
},
{
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>
@ -245,7 +220,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
</EuiFlexGroup>
<EuiSpacer size="m" />
<GridLayout
accessMode={accessMode}
accessMode={viewMode === 'view' ? 'VIEW' : 'EDIT'}
expandedPanelId={expandedPanelId}
layout={currentLayout}
gridSettings={{
@ -253,24 +228,27 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
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);
};

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

View file

@ -9,6 +9,8 @@
import { MockSerializedDashboardState } from './types';
import logsPanels from './logs_dashboard_panels.json';
const STATE_SESSION_STORAGE_KEY = 'kibana.examples.gridExample.state';
export function clearSerializedDashboardState() {
@ -25,21 +27,11 @@ export function setSerializedGridLayout(state: MockSerializedDashboardState) {
}
const initialState: MockSerializedDashboardState = {
panels: {
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 } },
},
panels: logsPanels,
rows: [
{ title: 'Large section', collapsed: false },
{ title: 'Small section', collapsed: false },
{ title: 'Another small section', collapsed: false },
{ title: 'Request Sizes', collapsed: false },
{ title: 'Visitors', collapsed: false },
{ title: 'Response Codes', collapsed: false },
{ title: 'Entire Flights Dashboard', collapsed: true },
],
};

View file

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

View file

@ -10,6 +10,10 @@
import { cloneDeep } from 'lodash';
import { useMemo } from 'react';
import { BehaviorSubject } from 'rxjs';
import { v4 } from 'uuid';
import { TimeRange } from '@kbn/es-query';
import { PanelPackage } from '@kbn/presentation-containers';
import {
MockSerializedDashboardState,
@ -27,24 +31,50 @@ export const useMockDashboardApi = ({
savedState: MockSerializedDashboardState;
}) => {
const mockDashboardApi = useMemo(() => {
const panels$ = new BehaviorSubject<MockedDashboardPanelMap>(savedState.panels);
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
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'),
panels$: new BehaviorSubject<MockedDashboardPanelMap>(savedState.panels),
panels$,
rows$: new BehaviorSubject<MockedDashboardRowMap>(savedState.rows),
expandedPanelId: expandedPanelId$,
expandPanel: (id: string) => {
if (expandedPanelId$.getValue()) {
expandedPanelId$.next(undefined);
} else {
expandedPanelId$.next(id);
}
},
removePanel: (id: string) => {
const panels = { ...mockDashboardApi.panels$.getValue() };
delete panels[id]; // the grid layout component will handle compacting, if necessary
mockDashboardApi.panels$.next(panels);
},
replacePanel: (oldId: string, newId: string) => {
replacePanel: (id: string, newPanel: PanelPackage) => {
const currentPanels = mockDashboardApi.panels$.getValue();
const otherPanels = { ...currentPanels };
const oldPanel = currentPanels[oldId];
delete otherPanels[oldId];
otherPanels[newId] = { id: newId, gridData: { ...oldPanel.gridData, i: newId } };
const oldPanel = currentPanels[id];
delete otherPanels[id];
const newId = v4();
otherPanels[newId] = {
...oldPanel,
explicitInput: { ...newPanel.initialState, id: newId },
};
mockDashboardApi.panels$.next(otherPanels);
},
addNewPanel: ({ id: newId }: { id: string }) => {
addNewPanel: async (panelPackage: PanelPackage) => {
// we are only implementing "place at top" here, for demo purposes
const currentPanels = mockDashboardApi.panels$.getValue();
const otherPanels = { ...currentPanels };
@ -53,17 +83,22 @@ export const useMockDashboardApi = ({
currentPanel.gridData.y = currentPanel.gridData.y + DEFAULT_PANEL_HEIGHT;
otherPanels[id] = currentPanel;
}
const newId = v4();
mockDashboardApi.panels$.next({
...otherPanels,
[newId]: {
id: newId,
type: panelPackage.panelType,
gridData: {
i: newId,
row: 0,
x: 0,
y: 0,
w: DEFAULT_PANEL_WIDTH,
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';
export const gridLayoutToDashboardPanelMap = (
panelState: MockedDashboardPanelMap,
layout: GridLayoutData
): { panels: MockedDashboardPanelMap; rows: MockedDashboardRowMap } => {
const panels: MockedDashboardPanelMap = {};
@ -19,7 +20,7 @@ export const gridLayoutToDashboardPanelMap = (
rows.push({ title: row.title, collapsed: row.isCollapsed });
Object.values(row.panels).forEach((panelGridData) => {
panels[panelGridData.id] = {
id: panelGridData.id,
...panelState[panelGridData.id],
gridData: {
i: panelGridData.id,
y: panelGridData.row,
@ -49,7 +50,7 @@ export const dashboardInputToGridLayout = ({
Object.keys(panels).forEach((panelId) => {
const gridData = panels[panelId].gridData;
layout[gridData.row].panels[panelId] = {
layout[gridData.row ?? 0].panels[panelId] = {
id: panelId,
row: gridData.y,
column: gridData.x,

View file

@ -3,7 +3,13 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["index.ts", "public/**/*.ts", "public/**/*.tsx", "../../typings/**/*"],
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"../../typings/**/*",
"public/**/*.json"
],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/grid-layout",
@ -11,7 +17,15 @@
"@kbn/core",
"@kbn/developer-examples-plugin",
"@kbn/core-lifecycle-browser",
"@kbn/embeddable-plugin",
"@kbn/react-kibana-mount",
"@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 React, { PropsWithChildren, useEffect, useRef } from 'react';
import { combineLatest } from 'rxjs';
import { euiThemeVars } from '@kbn/ui-theme';
import { GridLayoutStateManager } from './types';
export const GridHeightSmoother = ({
@ -30,6 +29,7 @@ export const GridHeightSmoother = ({
}
if (!interactionEvent) {
smoothHeightRef.current.style.height = `${dimensions.height}px`;
smoothHeightRef.current.style.userSelect = 'auto';
return;
}
@ -42,6 +42,7 @@ export const GridHeightSmoother = ({
dimensions.height ?? 0,
smoothHeightRef.current.getBoundingClientRect().height
)}px`;
smoothHeightRef.current.style.userSelect = 'none';
});
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
@ -49,19 +50,9 @@ export const GridHeightSmoother = ({
if (!smoothHeightRef.current) return;
if (expandedPanelId) {
const smoothHeightRefY =
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.height = `100%`;
smoothHeightRef.current.style.transition = 'none';
} else {
smoothHeightRef.current.style.flexBasis = '';
smoothHeightRef.current.style.height = '';
smoothHeightRef.current.style.transition = '';
}
@ -78,6 +69,8 @@ export const GridHeightSmoother = ({
<div
ref={smoothHeightRef}
css={css`
// the guttersize cannot currently change, so it's safe to set it just once
padding: ${gridLayoutStateManager.runtimeSettings$.getValue().gutterSize};
overflow-anchor: none;
transition: height 500ms linear;
`}

View file

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

View file

@ -7,24 +7,88 @@
* 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 { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { PanelInteractionEvent } from '../types';
import { GridLayoutStateManager, PanelInteractionEvent } from '../types';
export const DragHandle = ({
interactionStart,
}: {
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}) => {
export interface DragHandleApi {
setDragHandles: (refs: Array<HTMLElement | null>) => void;
}
export const DragHandle = React.forwardRef<
DragHandleApi,
{
gridLayoutStateManager: GridLayoutStateManager;
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}
>(({ gridLayoutStateManager, interactionStart }, ref) => {
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
aria-label={i18n.translate('kbnGridLayout.dragHandle.ariaLabel', {
defaultMessage: 'Drag to move',
@ -71,4 +135,4 @@ export const DragHandle = ({
<EuiIcon type="grabOmnidirectional" />
</button>
);
};
});

View file

@ -7,24 +7,27 @@
* 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 { EuiPanel, euiFullHeight, useEuiOverflowScroll } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { GridLayoutStateManager, PanelInteractionEvent } from '../types';
import { getKeysInOrder } from '../utils/resolve_grid_row';
import { DragHandle } from './drag_handle';
import { DragHandle, DragHandleApi } from './drag_handle';
import { ResizeHandle } from './resize_handle';
export interface GridPanelProps {
panelId: string;
rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode;
renderPanelContents: (
panelId: string,
setDragHandles?: (refs: Array<HTMLElement | null>) => void
) => React.ReactNode;
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
gridLayoutStateManager: GridLayoutStateManager;
}
@ -34,6 +37,37 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
{ panelId, rowIndex, renderPanelContents, interactionStart, gridLayoutStateManager },
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" */
const initialStyles = useMemo(() => {
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
ref.style.gridColumnStart = `${panel.column + 1}`;
ref.style.gridRowStart = `${panel.row + 1}`;
ref.style.gridColumnEnd = ``;
ref.style.gridRowEnd = ``;
ref.style.gridColumnEnd = `auto`;
ref.style.gridRowEnd = `auto`;
} else {
// 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.top = `${draggingPosition.top}px`;
ref.style.width = `${draggingPosition.right - draggingPosition.left}px`;
ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`;
// undo any "lock to grid" styles
ref.style.gridColumnStart = ``;
ref.style.gridRowStart = ``;
ref.style.gridColumnEnd = ``;
ref.style.gridRowEnd = ``;
ref.style.gridArea = `auto`; // shortcut to set all grid styles to `auto`
}
} else {
ref.style.zIndex = '0';
ref.style.zIndex = `auto`;
// if the panel is not being dragged and/or resized, undo any fixed position styles
ref.style.position = '';
@ -175,32 +207,27 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
* Memoize panel contents to prevent unnecessary re-renders
*/
const panelContents = useMemo(() => {
return renderPanelContents(panelId);
}, [panelId, renderPanelContents]);
if (!dragHandleApi) return <></>; // delays the rendering of the panel until after dragHandleApi is defined
return renderPanelContents(panelId, dragHandleApi.setDragHandles);
}, [panelId, renderPanelContents, dragHandleApi]);
return (
<div ref={panelRef} css={initialStyles} className="kbnGridPanel">
<EuiPanel
hasShadow={false}
hasBorder={true}
<div
css={css`
padding: 0;
position: relative;
height: 100%;
position: relative;
`}
>
<DragHandle interactionStart={interactionStart} />
<div
css={css`
${euiFullHeight()}
${useEuiOverflowScroll('y', false)}
${useEuiOverflowScroll('x', false)}
`}
>
{panelContents}
</div>
<DragHandle
ref={setDragHandleApi}
gridLayoutStateManager={gridLayoutStateManager}
interactionStart={interactionStart}
/>
{panelContents}
<ResizeHandle interactionStart={interactionStart} />
</EuiPanel>
</div>
</div>
);
}

View file

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

View file

@ -23,7 +23,10 @@ import { GridRowHeader } from './grid_row_header';
export interface GridRowProps {
rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode;
renderPanelContents: (
panelId: string,
setDragHandles?: (refs: Array<HTMLElement | null>) => void
) => React.ReactNode;
setInteractionEvent: (interactionData?: PanelInteractionEvent) => void;
gridLayoutStateManager: GridLayoutStateManager;
}
@ -32,19 +35,13 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
({ rowIndex, renderPanelContents, setInteractionEvent, gridLayoutStateManager }, gridRef) => {
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 [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(
(row: GridRowData) => {
const maxRow = Object.values(row.panels).reduce((acc, panel) => {
@ -152,10 +149,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
* - Title
* - Collapsed state
* - 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$
.pipe(
@ -163,7 +156,7 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
return {
title: gridLayout[rowIndex].title,
isCollapsed: gridLayout[rowIndex].isCollapsed,
panelIds: getKeysInOrder(gridLayout[rowIndex].panels),
panelIds: Object.keys(gridLayout[rowIndex].panels),
};
}),
pairwise()
@ -180,6 +173,9 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
)
) {
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(() => {
return panelIds.map((panelId) => (
<GridPanel
key={panelId}
panelId={panelId}
rowIndex={rowIndex}
gridLayoutStateManager={gridLayoutStateManager}
renderPanelContents={renderPanelContents}
interactionStart={(type, e) => {
e.preventDefault();
e.stopPropagation();
const children: { [panelId: string]: React.ReactNode } = useMemo(() => {
return panelIds.reduce(
(prev, panelId) => ({
...prev,
[panelId]: (
<GridPanel
key={panelId}
panelId={panelId}
rowIndex={rowIndex}
gridLayoutStateManager={gridLayoutStateManager}
renderPanelContents={renderPanelContents}
interactionStart={(type, e) => {
e.stopPropagation();
// Disable interactions when a panel is expanded
const isInteractive = gridLayoutStateManager.expandedPanelId$.value === undefined;
if (!isInteractive) return;
// Disable interactions when a panel is expanded
const isInteractive = gridLayoutStateManager.expandedPanelId$.value === undefined;
if (!isInteractive) return;
const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
if (!panelRef) return;
const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
if (!panelRef) return;
const panelRect = panelRef.getBoundingClientRect();
if (type === 'drop') {
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).
syncPanelIds();
} else {
setInteractionEvent({
type,
id: panelId,
panelDiv: panelRef,
targetRowIndex: rowIndex,
mouseOffsets: {
top: e.clientY - panelRect.top,
left: e.clientX - panelRect.left,
right: e.clientX - panelRect.right,
bottom: e.clientY - panelRect.bottom,
},
});
}
}}
ref={(element) => {
if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {
gridLayoutStateManager.panelRefs.current[rowIndex] = {};
}
gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element;
}}
/>
));
}, [
panelIds,
rowIndex,
gridLayoutStateManager,
renderPanelContents,
setInteractionEvent,
syncPanelIds,
]);
const panelRect = panelRef.getBoundingClientRect();
if (type === 'drop') {
setInteractionEvent(undefined);
/**
* Ensure the row re-renders to reflect the new panel order after a drag-and-drop interaction, since
* 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).
*/
setPanelIdsInOrder(
getKeysInOrder(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels)
);
} else {
setInteractionEvent({
type,
id: panelId,
panelDiv: panelRef,
targetRowIndex: rowIndex,
mouseOffsets: {
top: e.clientY - panelRect.top,
left: e.clientX - panelRect.left,
right: e.clientX - panelRect.right,
bottom: e.clientY - panelRect.bottom,
},
});
}
}}
ref={(element) => {
if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {
gridLayoutStateManager.panelRefs.current[rowIndex] = {};
}
gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element;
}}
/>
),
}),
{}
);
}, [panelIds, gridLayoutStateManager, renderPanelContents, rowIndex, setInteractionEvent]);
return (
<div ref={rowContainer}>
@ -276,7 +275,8 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
${initialStyles};
`}
>
{children}
{/* render the panels **in order** for accessibility, using the memoized panel components */}
{panelIdsInOrder.map((panelId) => children[panelId])}
<DragPreview rowIndex={rowIndex} gridLayoutStateManager={gridLayoutStateManager} />
</div>
)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@
import { EuiScreenReaderOnly } from '@elastic/eui';
import { ViewMode } from '@kbn/presentation-publishing';
import classNames from 'classnames';
import React from 'react';
import React, { useCallback } from 'react';
import { getAriaLabelForTitle } from '../presentation_panel_strings';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from '../types';
import { PresentationPanelTitle } from './presentation_panel_title';
@ -23,6 +23,7 @@ export type PresentationPanelHeaderProps<ApiType extends DefaultPresentationPane
hideTitle?: boolean;
panelTitle?: string;
panelDescription?: string;
setDragHandle: (id: string, ref: HTMLDivElement | null) => void;
} & Pick<PresentationPanelInternalProps, 'showBadges' | 'getActions' | 'showNotifications'>;
export const PresentationPanelHeader = <
@ -35,6 +36,7 @@ export const PresentationPanelHeader = <
hideTitle,
panelTitle,
panelDescription,
setDragHandle,
showBadges = true,
showNotifications = true,
}: PresentationPanelHeaderProps<ApiType>) => {
@ -45,6 +47,14 @@ export const PresentationPanelHeader = <
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 =
(!hideTitle && panelTitle) || badgeElements.length > 0 || notificationElements.length > 0;
@ -71,7 +81,7 @@ export const PresentationPanelHeader = <
className={headerClasses}
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}
<PresentationPanelTitle
api={api}

View file

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

View file

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

View file

@ -58,6 +58,13 @@ export interface PresentationPanelInternalProps<
* "title" when the panel has no title, i.e. "Panel {index}".
*/
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;
}
/**