[Dashboard][Collapsable Panels] Swap react-grid-layout for kbn-grid-layout (#205341)

Closes https://github.com/elastic/kibana/issues/190446

## Summary

This PR swaps out `react-grid-layout` for the new internal
`kbn-grid-layout` in the Dashboard plugin. This is the first major step
in making collapsible sections possible in Dashboard.

- **`react-grid-layout` (before)**:


https://github.com/user-attachments/assets/ca6ec059-7f4a-43fb-890e-7b72b781e50b

- **`kbn-grid-layout` (after)**:


https://github.com/user-attachments/assets/3d3de1f3-1afc-4e6b-93d6-9cc31a46e2cf

### Notable Improvements

- Better handling of resizing panels near the bottom of the screen
   
  | `react-grid-layout` | `kbn-grid-layout` |
  |--------|--------|
| ![Jan-09-2025
09-59-00](https://github.com/user-attachments/assets/75854b76-3ad7-4f06-9745-b03bde15f87a)
| ![Jan-09-2025
09-26-24](https://github.com/user-attachments/assets/f0fbc0bf-9208-4866-b7eb-988c7abc3e50)
|


- Auto-scroll when dragging / resizing panels near the top and bottom of
the screen, making it much easier to move panels around by larger
distances

  | `react-grid-layout` | `kbn-grid-layout` |
  |--------|--------|
| ![Jan-09-2025
10-01-30](https://github.com/user-attachments/assets/e3457e5e-3647-4024-b6e6-c594d6d3e1d7)
| ![Jan-09-2025
09-25-35](https://github.com/user-attachments/assets/3252bdec-2bbc-4793-b089-346866d4589b)
|

- More reliable panel positioning due to the use of CSS grid rather than
absolute positioning via pixels

  | `react-grid-layout` | `kbn-grid-layout` |
  |--------|--------|
| ![Screenshot 2025-01-09 at 9 32
52 AM](https://github.com/user-attachments/assets/06bd31a4-0a9f-4561-84c3-4cd96ba297b0)
| ![Screenshot 2025-01-09 at 9 35
14 AM](https://github.com/user-attachments/assets/573dab98-3fb9-4ef6-9f37-c4cf4d03ce52)
|

- Better performance when dragging and resizing (see
https://github.com/elastic/kibana/pull/204134 for a more thorough
explanation) and a smaller bundle size than `react-grid-layout`

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [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

This PR contains a significant change to the Dashboard layout engine,
which means that it carries a decent amount of risk for introducing new,
uncaught bugs with dragging / resizing panels and collision resolution.
That being said, `kbn-grid-layout` has been built **iteratively** with
plenty of testing along the way to reduce this risk.

## Release note
Improves Dashboard layout engine by switching to the internally
developed `kbn-grid-layout`.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2025-01-14 14:51:14 -07:00 committed by GitHub
parent 2c9e55dc44
commit 686571547f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 516 additions and 612 deletions

View file

@ -123,7 +123,7 @@ export const GridExample = ({
<EuiPageTemplate.Section
color="subdued"
contentProps={{
css: { flexGrow: 1 },
css: { flexGrow: 1, display: 'flex', flexDirection: 'column' },
}}
>
<EuiCallOut

View file

@ -9,7 +9,7 @@
import { css } from '@emotion/react';
import React, { PropsWithChildren, useEffect, useRef } from 'react';
import { combineLatest } from 'rxjs';
import { combineLatest, distinctUntilChanged, map } from 'rxjs';
import { GridLayoutStateManager } from './types';
export const GridHeightSmoother = ({
@ -18,26 +18,25 @@ export const GridHeightSmoother = ({
}: PropsWithChildren<{ gridLayoutStateManager: GridLayoutStateManager }>) => {
// set the parent div size directly to smooth out height changes.
const smoothHeightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
/**
* When the user is interacting with an element, the page can grow, but it cannot
* shrink. This is to stop a behaviour where the page would scroll up automatically
* making the panel shrink or grow unpredictably.
*/
const interactionStyleSubscription = combineLatest([
gridLayoutStateManager.gridDimensions$,
gridLayoutStateManager.interactionEvent$,
]).subscribe(([dimensions, interactionEvent]) => {
if (!smoothHeightRef.current) return;
if (gridLayoutStateManager.expandedPanelId$.getValue()) {
return;
}
if (!smoothHeightRef.current || gridLayoutStateManager.expandedPanelId$.getValue()) return;
if (!interactionEvent) {
smoothHeightRef.current.style.height = `${dimensions.height}px`;
smoothHeightRef.current.style.userSelect = 'auto';
return;
}
/**
* When the user is interacting with an element, the page can grow, but it cannot
* shrink. This is to stop a behaviour where the page would scroll up automatically
* making the panel shrink or grow unpredictably.
*/
smoothHeightRef.current.style.height = `${Math.max(
dimensions.height ?? 0,
smoothHeightRef.current.getBoundingClientRect().height
@ -45,22 +44,23 @@ export const GridHeightSmoother = ({
smoothHeightRef.current.style.userSelect = 'none';
});
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
(expandedPanelId) => {
if (!smoothHeightRef.current) return;
/**
* This subscription sets global CSS variables that can be used by all components contained within
* this wrapper; note that this is **currently** only used for the gutter size, but things like column
* count could be added here once we add the ability to change these values
*/
const globalCssVariableSubscription = gridLayoutStateManager.runtimeSettings$
.pipe(
map(({ gutterSize }) => gutterSize),
distinctUntilChanged()
)
.subscribe((gutterSize) => {
smoothHeightRef.current?.style.setProperty('--kbnGridGutterSize', `${gutterSize}`);
});
if (expandedPanelId) {
smoothHeightRef.current.style.height = `100%`;
smoothHeightRef.current.style.transition = 'none';
} else {
smoothHeightRef.current.style.height = '';
smoothHeightRef.current.style.transition = '';
}
}
);
return () => {
interactionStyleSubscription.unsubscribe();
expandedPanelSubscription.unsubscribe();
globalCssVariableSubscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -68,11 +68,20 @@ export const GridHeightSmoother = ({
return (
<div
ref={smoothHeightRef}
className={'kbnGridWrapper'}
css={css`
// the guttersize cannot currently change, so it's safe to set it just once
padding: ${gridLayoutStateManager.runtimeSettings$.getValue().gutterSize};
margin: calc(var(--kbnGridGutterSize) * 1px);
overflow-anchor: none;
transition: height 500ms linear;
&:has(.kbnGridPanel--expanded) {
height: 100% !important;
position: relative;
transition: none;
// switch to padding so that the panel does not extend the height of the parent
margin: 0px;
padding: calc(var(--kbnGridGutterSize) * 1px);
}
`}
>
{children}

View file

@ -8,11 +8,11 @@
*/
import { cloneDeep } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip } from 'rxjs';
import { css } from '@emotion/react';
import { GridHeightSmoother } from './grid_height_smoother';
import { GridRow } from './grid_row';
import { GridAccessMode, GridLayoutData, GridSettings } from './types';
@ -48,6 +48,7 @@ export const GridLayout = ({
accessMode,
});
useGridLayoutEvents({ gridLayoutStateManager });
const layoutRef = useRef<HTMLDivElement | null>(null);
const [rowCount, setRowCount] = useState<number>(
gridLayoutStateManager.gridLayout$.getValue().length
@ -89,6 +90,9 @@ export const GridLayout = ({
setRowCount(newRowCount);
});
/**
* This subscription calls the passed `onLayoutChange` callback when the layout changes
*/
const onLayoutChangeSubscription = combineLatest([
gridLayoutStateManager.gridLayout$,
gridLayoutStateManager.interactionEvent$,
@ -106,9 +110,33 @@ export const GridLayout = ({
}
});
/**
* This subscription adds and/or removes the necessary class names related to styling for
* mobile view and a static (non-interactable) grid layout
*/
const gridLayoutClassSubscription = combineLatest([
gridLayoutStateManager.accessMode$,
gridLayoutStateManager.isMobileView$,
]).subscribe(([currentAccessMode, isMobileView]) => {
if (!layoutRef) return;
if (isMobileView) {
layoutRef.current?.classList.add('kbnGrid--mobileView');
} else {
layoutRef.current?.classList.remove('kbnGrid--mobileView');
}
if (currentAccessMode === 'VIEW') {
layoutRef.current?.classList.add('kbnGrid--static');
} else {
layoutRef.current?.classList.remove('kbnGrid--static');
}
});
return () => {
rowCountSubscription.unsubscribe();
onLayoutChangeSubscription.unsubscribe();
gridLayoutClassSubscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -138,21 +166,20 @@ export const GridLayout = ({
});
}, [rowCount, gridLayoutStateManager, renderPanelContents]);
const gridClassNames = classNames('kbnGrid', {
'kbnGrid--static': expandedPanelId || accessMode === 'VIEW',
'kbnGrid--hasExpandedPanel': Boolean(expandedPanelId),
});
return (
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
<div
ref={(divElement) => {
layoutRef.current = divElement;
setDimensionsRef(divElement);
}}
className={gridClassNames}
className="kbnGrid"
css={css`
&.kbnGrid--hasExpandedPanel {
height: 100%;
&:has(.kbnGridPanel--expanded) {
${expandedPanelStyles}
}
&.kbnGrid--mobileView {
${singleColumnStyles}
}
`}
>
@ -161,3 +188,50 @@ export const GridLayout = ({
</GridHeightSmoother>
);
};
const singleColumnStyles = css`
.kbnGridRow {
grid-template-columns: 100%;
grid-template-rows: auto;
grid-auto-flow: row;
grid-auto-rows: auto;
}
.kbnGridPanel {
grid-area: unset !important;
}
`;
const expandedPanelStyles = css`
height: 100%;
& .kbnGridRowContainer:has(.kbnGridPanel--expanded) {
// targets the grid row container that contains the expanded panel
.kbnGridRowHeader {
height: 0px; // used instead of 'display: none' due to a11y concerns
}
.kbnGridRow {
display: block !important; // overwrite grid display
height: 100%;
.kbnGridPanel {
&.kbnGridPanel--expanded {
height: 100% !important;
}
&:not(.kbnGridPanel--expanded) {
// hide the non-expanded panels
position: absolute;
top: -9999px;
left: -9999px;
visibility: hidden; // remove hidden panels and their contents from tab order for a11y
}
}
}
}
& .kbnGridRowContainer:not(:has(.kbnGridPanel--expanded)) {
// targets the grid row containers that **do not** contain the expanded panel
position: absolute;
top: -9999px;
left: -9999px;
}
`;

View file

@ -146,7 +146,8 @@ export const DragHandle = React.forwardRef<
&:active {
cursor: grabbing;
}
.kbnGrid--static & {
.kbnGrid--static &,
.kbnGridPanel--expanded & {
display: none;
}
`}

View file

@ -13,8 +13,7 @@ import { combineLatest, skip } from 'rxjs';
import { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { GridLayoutStateManager, UserInteractionEvent, PanelInteractionEvent } from '../types';
import { getKeysInOrder } from '../utils/resolve_grid_row';
import { GridLayoutStateManager, PanelInteractionEvent, UserInteractionEvent } from '../types';
import { DragHandle, DragHandleApi } from './drag_handle';
import { ResizeHandle } from './resize_handle';
@ -69,19 +68,20 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
/** Set initial styles based on state at mount to prevent styles from "blipping" */
const initialStyles = useMemo(() => {
const initialPanel = gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels[panelId];
const { rowHeight } = gridLayoutStateManager.runtimeSettings$.getValue();
return css`
position: relative;
height: calc(
1px *
(
${initialPanel.height} * (${rowHeight} + var(--kbnGridGutterSize)) -
var(--kbnGridGutterSize)
)
);
grid-column-start: ${initialPanel.column + 1};
grid-column-end: ${initialPanel.column + 1 + initialPanel.width};
grid-row-start: ${initialPanel.row + 1};
grid-row-end: ${initialPanel.row + 1 + initialPanel.height};
&.kbnGridPanel--isExpanded {
transform: translate(9999px, 9999px);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
`;
}, [gridLayoutStateManager, rowIndex, panelId]);
@ -135,6 +135,8 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
ref.style.gridArea = `auto`; // shortcut to set all grid styles to `auto`
}
} else {
const { rowHeight } = gridLayoutStateManager.runtimeSettings$.getValue();
ref.style.zIndex = `auto`;
// if the panel is not being dragged and/or resized, undo any fixed position styles
@ -142,7 +144,8 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
ref.style.left = ``;
ref.style.top = ``;
ref.style.width = ``;
ref.style.height = ``;
// setting the height is necessary for mobile mode
ref.style.height = `calc(1px * (${panel.height} * (${rowHeight} + var(--kbnGridGutterSize)) - var(--kbnGridGutterSize)))`;
// and render the panel locked to the grid
ref.style.gridColumnStart = `${panel.column + 1}`;
@ -152,55 +155,33 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
}
});
const expandedPanelStyleSubscription = gridLayoutStateManager.expandedPanelId$
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
.subscribe((expandedPanelId) => {
/**
* This subscription adds and/or removes the necessary class name for expanded panel styling
*/
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
(expandedPanelId) => {
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
const panel = gridLayout[rowIndex].panels[panelId];
if (!ref || !panel) return;
if (expandedPanelId && expandedPanelId === panelId) {
ref.classList.add('kbnGridPanel--isExpanded');
ref.classList.add('kbnGridPanel--expanded');
} else {
ref.classList.remove('kbnGridPanel--isExpanded');
ref.classList.remove('kbnGridPanel--expanded');
}
});
const mobileViewStyleSubscription = gridLayoutStateManager.isMobileView$
.pipe(skip(1))
.subscribe((isMobileView) => {
if (!isMobileView) {
return;
}
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
const allPanels = gridLayout[rowIndex].panels;
const panel = allPanels[panelId];
if (!ref || !panel) return;
const sortedKeys = getKeysInOrder(gridLayout[rowIndex].panels);
const currentPanelPosition = sortedKeys.indexOf(panelId);
const sortedKeysBefore = sortedKeys.slice(0, currentPanelPosition);
const responsiveGridRowStart = sortedKeysBefore.reduce(
(acc, key) => acc + allPanels[key].height,
1
);
ref.style.gridColumnStart = `1`;
ref.style.gridColumnEnd = `-1`;
ref.style.gridRowStart = `${responsiveGridRowStart}`;
ref.style.gridRowEnd = `${responsiveGridRowStart + panel.height}`;
});
}
);
return () => {
expandedPanelStyleSubscription.unsubscribe();
mobileViewStyleSubscription.unsubscribe();
expandedPanelSubscription.unsubscribe();
activePanelStyleSubscription.unsubscribe();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
/**
* Memoize panel contents to prevent unnecessary re-renders
*/
@ -211,21 +192,13 @@ export const GridPanel = forwardRef<HTMLDivElement, GridPanelProps>(
return (
<div ref={panelRef} css={initialStyles} className="kbnGridPanel">
<div
css={css`
padding: 0;
height: 100%;
position: relative;
`}
>
<DragHandle
ref={setDragHandleApi}
gridLayoutStateManager={gridLayoutStateManager}
interactionStart={interactionStart}
/>
{panelContents}
<ResizeHandle interactionStart={interactionStart} />
</div>
<DragHandle
ref={setDragHandleApi}
gridLayoutStateManager={gridLayoutStateManager}
interactionStart={interactionStart}
/>
{panelContents}
<ResizeHandle interactionStart={interactionStart} />
</div>
);
}

View file

@ -46,6 +46,7 @@ export const ResizeHandle = ({
position: absolute;
width: ${euiTheme.size.l};
max-width: 100%;
max-height: 100%;
height: ${euiTheme.size.l};
z-index: ${euiTheme.levels.toast};
transition: opacity 0.2s, border 0.2s;
@ -59,7 +60,8 @@ export const ResizeHandle = ({
background-color: ${transparentize(euiTheme.colors.accentSecondary, 0.05)};
cursor: se-resize;
}
.kbnGrid--static & {
.kbnGrid--static &,
.kbnGridPanel--expanded & {
opacity: 0 !important;
display: none;
}

View file

@ -7,24 +7,19 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cloneDeep } from 'lodash';
import React, { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import { combineLatest, map, pairwise, skip } from 'rxjs';
import { transparentize, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { cloneDeep } from 'lodash';
import { DragPreview } from '../drag_preview';
import { GridPanel } from '../grid_panel';
import {
GridLayoutStateManager,
GridRowData,
UserInteractionEvent,
PanelInteractionEvent,
} from '../types';
import { GridLayoutStateManager, PanelInteractionEvent, UserInteractionEvent } from '../types';
import { getKeysInOrder } from '../utils/resolve_grid_row';
import { isMouseEvent, isTouchEvent } from '../utils/sensors';
import { GridRowHeader } from './grid_row_header';
import { isTouchEvent, isMouseEvent } from '../utils/sensors';
export interface GridRowProps {
rowIndex: number;
@ -49,33 +44,19 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
const { euiTheme } = useEuiTheme();
const getRowCount = useCallback(
(row: GridRowData) => {
const maxRow = Object.values(row.panels).reduce((acc, panel) => {
return Math.max(acc, panel.row + panel.height);
}, 0);
return maxRow || 1;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[rowIndex]
);
const rowContainer = useRef<HTMLDivElement | null>(null);
/** Set initial styles based on state at mount to prevent styles from "blipping" */
const initialStyles = useMemo(() => {
const initialRow = gridLayoutStateManager.gridLayout$.getValue()[rowIndex];
const runtimeSettings = gridLayoutStateManager.runtimeSettings$.getValue();
const { gutterSize, columnCount, rowHeight } = runtimeSettings;
const { columnCount, rowHeight } = runtimeSettings;
return css`
gap: ${gutterSize}px;
grid-template-columns: repeat(
${columnCount},
calc((100% - ${gutterSize * (columnCount - 1)}px) / ${columnCount})
);
grid-template-rows: repeat(${getRowCount(initialRow)}, ${rowHeight}px);
grid-auto-rows: ${rowHeight}px;
grid-template-columns: repeat(${columnCount}, minmax(0, 1fr));
gap: calc(var(--kbnGridGutterSize) * 1px);
`;
}, [gridLayoutStateManager, getRowCount, rowIndex]);
}, [gridLayoutStateManager]);
useEffect(
() => {
@ -92,10 +73,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
const { gutterSize, rowHeight, columnPixelWidth } = runtimeSettings;
rowRef.style.gridTemplateRows = `repeat(${getRowCount(
gridLayout[rowIndex]
)}, ${rowHeight}px)`;
const targetRow = interactionEvent?.targetRowIndex;
if (rowIndex === targetRow && interactionEvent) {
// apply "targetted row" styles
@ -121,36 +98,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
}
});
const expandedPanelStyleSubscription = gridLayoutStateManager.expandedPanelId$
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
.subscribe((expandedPanelId) => {
const rowContainerRef = rowContainer.current;
if (!rowContainerRef) return;
if (expandedPanelId) {
// If any panel is expanded, move all rows with their panels out of the viewport.
// The expanded panel is repositioned to its original location in the GridPanel component
// and stretched to fill the viewport.
rowContainerRef.style.transform = 'translate(-9999px, -9999px)';
const panelsIds = Object.keys(
gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels
);
const includesExpandedPanel = panelsIds.includes(expandedPanelId);
if (includesExpandedPanel) {
// Stretch the row with the expanded panel to occupy the entire remaining viewport
rowContainerRef.style.height = '100%';
} else {
// Hide the row if it does not contain the expanded panel
rowContainerRef.style.height = '0';
}
} else {
rowContainerRef.style.transform = ``;
rowContainerRef.style.height = ``;
}
});
/**
* This subscription ensures that the row will re-render when one of the following changes:
* - Title
@ -189,7 +136,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
return () => {
interactionStyleSubscription.unsubscribe();
rowStateSubscription.unsubscribe();
expandedPanelStyleSubscription.unsubscribe();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -257,7 +203,13 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
}, [panelIds, gridLayoutStateManager, renderPanelContents, rowIndex, setInteractionEvent]);
return (
<div ref={rowContainer}>
<div
ref={rowContainer}
css={css`
height: 100%;
`}
className="kbnGridRowContainer"
>
{rowIndex !== 0 && (
<GridRowHeader
isCollapsed={isCollapsed}
@ -271,8 +223,10 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
)}
{!isCollapsed && (
<div
className={'kbnGridRow'}
ref={gridRef}
css={css`
height: 100%;
display: grid;
justify-items: stretch;
transition: background-color 300ms linear;

View file

@ -20,7 +20,7 @@ export const GridRowHeader = ({
rowTitle?: string;
}) => {
return (
<>
<div className="kbnGridRowHeader">
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s">
<EuiButtonIcon
@ -36,6 +36,6 @@ export const GridRowHeader = ({
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
</div>
);
};

View file

@ -119,7 +119,10 @@ export const useGridLayoutEvents = ({
: pointerClientPixel.x - interactionEvent.pointerOffsets.left,
top: isResize ? panelRect.top : pointerClientPixel.y - interactionEvent.pointerOffsets.top,
bottom: pointerClientPixel.y - interactionEvent.pointerOffsets.bottom,
right: Math.min(pointerClientPixel.x - interactionEvent.pointerOffsets.right, gridWidth),
right:
isResize && isTouchEvent(e)
? Math.min(pointerClientPixel.x - interactionEvent.pointerOffsets.right, gridWidth)
: pointerClientPixel.x - interactionEvent.pointerOffsets.right,
};
gridLayoutStateManager.activePanel$.next({ id: interactionEvent.id, position: previewRect });
@ -195,7 +198,7 @@ export const useGridLayoutEvents = ({
const atTheBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight;
if (!isTouchEvent(e)) {
const startScrollingUp = !isResize && heightPercentage < 5 && !atTheTop; // don't scroll up when resizing
const startScrollingUp = heightPercentage < 5 && !atTheTop; // don't scroll up when resizing
const startScrollingDown = heightPercentage > 95 && !atTheBottom;
if (startScrollingUp || startScrollingDown) {
if (!scrollInterval.current) {
@ -258,11 +261,11 @@ export const useGridLayoutEvents = ({
};
function getPointerClientPosition(e: Event) {
if (isTouchEvent(e)) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
if (isMouseEvent(e)) {
return { x: e.clientX, y: e.clientY };
}
if (isTouchEvent(e)) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
throw new Error('Unknown event type');
}

View file

@ -7,11 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { cloneDeep, pick } from 'lodash';
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 { useEuiTheme } from '@elastic/eui';
import {
ActivePanel,
GridAccessMode,
@ -48,7 +51,7 @@ export const useGridLayoutState = ({
[]
);
useEffect(() => {
expandedPanelId$.next(expandedPanelId);
if (expandedPanelId !== expandedPanelId$.getValue()) expandedPanelId$.next(expandedPanelId);
}, [expandedPanelId, expandedPanelId$]);
const accessMode$ = useMemo(
@ -58,9 +61,28 @@ export const useGridLayoutState = ({
);
useEffect(() => {
accessMode$.next(accessMode);
if (accessMode !== accessMode$.getValue()) accessMode$.next(accessMode);
}, [accessMode, accessMode$]);
const runtimeSettings$ = useMemo(
() =>
new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
const runtimeSettings = runtimeSettings$.getValue();
if (!deepEqual(gridSettings, pick(runtimeSettings, ['gutterSize', 'rowHeight', 'columnCount'])))
runtimeSettings$.next({
...gridSettings,
columnPixelWidth: runtimeSettings.columnPixelWidth,
});
}, [gridSettings, runtimeSettings$]);
const gridLayoutStateManager = useMemo(() => {
const resolvedLayout = cloneDeep(layout);
resolvedLayout.forEach((row, rowIndex) => {
@ -71,10 +93,6 @@ export const useGridLayoutState = ({
const gridDimensions$ = new BehaviorSubject<ObservedSize>({ width: 0, height: 0 });
const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined);
const activePanel$ = new BehaviorSubject<ActivePanel | undefined>(undefined);
const runtimeSettings$ = new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
});
const panelIds$ = new BehaviorSubject<string[][]>(
layout.map(({ panels }) => Object.keys(panels))
);
@ -104,15 +122,22 @@ export const useGridLayoutState = ({
const resizeSubscription = combineLatest([gridLayoutStateManager.gridDimensions$, accessMode$])
.pipe(debounceTime(250))
.subscribe(([dimensions, currentAccessMode]) => {
const currentRuntimeSettings = gridLayoutStateManager.runtimeSettings$.getValue();
const elementWidth = dimensions.width ?? 0;
const columnPixelWidth =
(elementWidth - gridSettings.gutterSize * (gridSettings.columnCount - 1)) /
gridSettings.columnCount;
(elementWidth -
currentRuntimeSettings.gutterSize * (currentRuntimeSettings.columnCount - 1)) /
currentRuntimeSettings.columnCount;
gridLayoutStateManager.runtimeSettings$.next({ ...gridSettings, columnPixelWidth });
gridLayoutStateManager.isMobileView$.next(
shouldShowMobileView(currentAccessMode, euiTheme.breakpoint.m)
);
if (columnPixelWidth !== currentRuntimeSettings.columnPixelWidth)
gridLayoutStateManager.runtimeSettings$.next({
...currentRuntimeSettings,
columnPixelWidth,
});
const isMobileView = shouldShowMobileView(currentAccessMode, euiTheme.breakpoint.m);
if (isMobileView !== gridLayoutStateManager.isMobileView$.getValue()) {
gridLayoutStateManager.isMobileView$.next(isMobileView);
}
});
return () => {

View file

@ -4,7 +4,6 @@
display: flex;
flex-direction: column;
height: 100%;
min-height: $euiSizeL + 2px; // + 2px to account for border
position: relative;
&-isLoading {

View file

@ -58,7 +58,6 @@ export function getDashboardApi({
savedObjectResult?: LoadDashboardReturn;
savedObjectId?: string;
}) {
const animatePanelTransforms$ = new BehaviorSubject(false); // set panel transforms to false initially to avoid panels animating on initial render.
const controlGroupApi$ = new BehaviorSubject<ControlGroupApi | undefined>(undefined);
const fullScreenMode$ = new BehaviorSubject(creationOptions?.fullScreenMode ?? false);
const isManaged = savedObjectResult?.managed ?? false;
@ -139,9 +138,6 @@ export function getDashboardApi({
const trackOverlayApi = initializeTrackOverlay(trackPanel.setFocusedPanelId);
// Start animating panel transforms 500 ms after dashboard is created.
setTimeout(() => animatePanelTransforms$.next(true), 500);
const dashboardApi = {
...viewModeManager.api,
...dataLoadingManager.api,
@ -239,7 +235,6 @@ export function getDashboardApi({
internalApi: {
...panelsManager.internalApi,
...unifiedSearchManager.internalApi,
animatePanelTransforms$,
getSerializedStateForControlGroup: () => {
return {
rawState: savedObjectResult?.dashboardInput?.controlGroupInput

View file

@ -36,9 +36,7 @@ export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Prom
}
setExpandedPanelId(panelId);
if (window.scrollY > 0) {
scrollPosition = window.scrollY;
}
scrollPosition = window.scrollY;
},
focusedPanelId$,
highlightPanelId$,
@ -63,17 +61,12 @@ export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Prom
untilEmbeddableLoaded(id).then(() => {
setScrollToPanelId(undefined);
if (scrollPosition) {
panelRef.ontransitionend = () => {
// Scroll to the last scroll position after the transition ends to ensure the panel is back in the right position before scrolling
// This is necessary because when an expanded panel collapses, it takes some time for the panel to return to its original position
window.scrollTo({ top: scrollPosition });
scrollPosition = undefined;
panelRef.ontransitionend = null;
};
return;
if (scrollPosition !== undefined) {
window.scrollTo({ top: scrollPosition });
scrollPosition = undefined;
} else {
panelRef.scrollIntoView({ block: 'start' });
}
panelRef.scrollIntoView({ block: 'start' });
});
},
scrollToTop: () => {

View file

@ -140,6 +140,7 @@ export type DashboardApi = CanExpandPanels &
controlGroupApi$: PublishingSubject<ControlGroupApi | undefined>;
fullScreenMode$: PublishingSubject<boolean>;
focusedPanelId$: PublishingSubject<string | undefined>;
setFocusedPanelId: (id: string | undefined) => void;
forceRefresh: () => void;
getSettings: () => DashboardSettings;
getSerializedState: () => {
@ -174,7 +175,6 @@ export type DashboardApi = CanExpandPanels &
};
export interface DashboardInternalApi {
animatePanelTransforms$: PublishingSubject<boolean>;
controlGroupReload$: Subject<void>;
panelsReload$: Subject<void>;
getRuntimeStateForControlGroup: () => object | undefined;

View file

@ -4,6 +4,7 @@
.dashboardContainer, .dashboardViewport {
flex: auto;
display: flex;
width: 100%;
}
.dashboardViewport--loading {

View file

@ -1,30 +1,5 @@
// SASSTODO: Can't find this selector, but could break something if removed
.react-grid-layout .gs-w {
z-index: auto;
}
/**
* 1. Due to https://github.com/STRML/react-grid-layout/issues/240 we have to manually hide the resizable
* element.
*/
.dshLayout--viewing {
.react-resizable-handle {
display: none; /* 1 */
}
}
/**
* 1. If we don't give the resizable handler a larger z index value the layout will hide it.
*/
.dshLayout--editing {
.react-resizable-handle {
@include size($euiSizeL);
z-index: $euiZLevel2; /* 1 */
right: 0;
bottom: 0;
padding-right: $euiSizeS;
padding-bottom: $euiSizeS;
}
.dshLayout {
position: relative;
}
/**
@ -32,41 +7,16 @@
* otherwise the height is set inline.
*/
.dshLayout-isMaximizedPanel {
height: 100% !important; /* 1. */
height: 100%;
.embPanel__hoverActionsLeft {
visibility: hidden;
}
}
/**
* When a single panel is expanded, all the other panels moved offscreen.
* Shifting the rendered panels offscreen prevents a quick flash when redrawing the panels on minimize
*/
.dshDashboardGrid__item--hidden {
transform: translate(-9999px, -9999px);
}
/**
* turn off panel transforms initially so that the dashboard panels don't swoop in on first load.
*/
.dshLayout--noAnimation .react-grid-item.cssTransforms {
transition-property: none !important;
}
/**
* 1. We need to mark this as important because react grid layout sets the width and height of the panels inline.
*/
.dshDashboardGrid__item--expanded {
position: absolute;
height: 100% !important; /* 1 */
width: 100% !important; /* 1 */
top: 0 !important; /* 1 */
left: 0 !important; /* 1 */
transform: none !important;
padding: $euiSizeS;
// Altered panel styles can be found in ../panel
.dshDashboardGrid__item--expanded {
position: absolute;
width: 100%;
}
}
// Remove padding in fullscreen mode
@ -74,62 +24,4 @@
.dshDashboardGrid__item--expanded {
padding: 0;
}
}
// REACT-GRID
.react-grid-item {
/**
* Copy over and overwrite the fill color with EUI color mixin (for theming)
*/
> .react-resizable-handle {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' viewBox='0 0 6 6'%3E%3Cpolygon fill='#{hexToRGB($euiColorDarkShade)}' points='6 6 0 6 0 4.2 4 4.2 4.2 4.2 4.2 0 6 0' /%3E%3C/svg%3E%0A");
&::after {
border: none !important; /** overrides library default visual indicator **/
}
&:hover,
&:focus {
background-color: transparentize($euiColorWarning, lightOrDarkTheme(.9, .7));
}
}
/**
* Dragged/Resized panels in dashboard should always appear above other panels
* and above the placeholder
*/
&.resizing,
&.react-draggable-dragging {
z-index: $euiZLevel3 !important;
}
&.react-draggable-dragging {
transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance;
@include euiBottomShadowLarge;
border-radius: $euiBorderRadius; // keeps shadow within bounds
.embPanel__hoverActionsWrapper {
z-index: $euiZLevel9;
top: -$euiSizeXL;
// Show hover actions with drag handle
.embPanel__hoverActions:has(.embPanel--dragHandle) {
opacity: 1;
}
// Hide hover actions without drag handle
.embPanel__hoverActions:not(:has(.embPanel--dragHandle)) {
opacity: 0;
}
}
}
/**
* Overwrites red coloring that comes from this library by default.
*/
&.react-grid-placeholder {
border-radius: $euiBorderRadius;
background: $euiColorWarning;
}
}
}

View file

@ -1,7 +1,7 @@
// LAYOUT MODES
// Adjust borders/etc... for non-spaced out and expanded panels
.dshLayout-withoutMargins {
margin-top: $euiSizeS;
padding-top: $euiSizeS;
.embPanel__content,
.embPanel,
@ -59,3 +59,7 @@
z-index: $euiZLevel2;
}
}
.dshDashboardGrid__item {
height: 100%;
}

View file

@ -9,35 +9,44 @@
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DashboardGrid } from './dashboard_grid';
import { buildMockDashboardApi } from '../../../mocks';
import type { Props as DashboardGridItemProps } from './dashboard_grid_item';
import { DashboardContext } from '../../../dashboard_api/use_dashboard_api';
import { DashboardInternalContext } from '../../../dashboard_api/use_dashboard_internal_api';
import { useBatchedPublishingSubjects as mockUseBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { DashboardPanelMap } from '../../../../common';
import {
DashboardContext,
useDashboardApi as mockUseDashboardApi,
} from '../../../dashboard_api/use_dashboard_api';
import { DashboardInternalContext } from '../../../dashboard_api/use_dashboard_internal_api';
import { buildMockDashboardApi } from '../../../mocks';
import { DashboardGrid } from './dashboard_grid';
import type { Props as DashboardGridItemProps } from './dashboard_grid_item';
import { RenderResult, act, render, waitFor } from '@testing-library/react';
jest.mock('./dashboard_grid_item', () => {
return {
// eslint-disable-next-line @typescript-eslint/no-var-requires
DashboardGridItem: require('react').forwardRef(
(props: DashboardGridItemProps, ref: HTMLDivElement) => {
const dashboardApi = mockUseDashboardApi();
const [expandedPanelId, focusedPanelId] = mockUseBatchedPublishingSubjects(
dashboardApi.expandedPanelId,
dashboardApi.focusedPanelId$
);
const className = `${
props.expandedPanelId === undefined
expandedPanelId === undefined
? 'regularPanel'
: props.expandedPanelId === props.id
: expandedPanelId === props.id
? 'expandedPanel'
: 'hiddenPanel'
} ${
props.focusedPanelId
? props.focusedPanelId === props.id
? 'focusedPanel'
: 'blurredPanel'
: ''
}`;
} ${focusedPanelId ? (focusedPanelId === props.id ? 'focusedPanel' : 'blurredPanel') : ''}`;
return (
<div className={className} id={`mockDashboardGridItem_${props.id}`}>
<div
data-test-subj="dashboardGridItem"
id={`mockDashboardGridItem_${props.id}`}
className={className}
>
mockDashboardGridItem
</div>
);
@ -59,66 +68,83 @@ const PANELS = {
},
};
const verifyElementHasClass = (
component: RenderResult,
elementSelector: string,
className: string
) => {
const itemToCheck = component.container.querySelector(elementSelector);
expect(itemToCheck).toBeDefined();
expect(itemToCheck!.classList.contains(className)).toBe(true);
};
const createAndMountDashboardGrid = async (panels: DashboardPanelMap = PANELS) => {
const { api, internalApi } = buildMockDashboardApi({
overrides: {
panels,
},
});
const component = mountWithIntl(
const component = render(
<DashboardContext.Provider value={api}>
<DashboardInternalContext.Provider value={internalApi}>
<DashboardGrid viewportWidth={1000} />
<DashboardGrid />
</DashboardInternalContext.Provider>
</DashboardContext.Provider>
);
// wait for first render
await waitFor(() => {
expect(component.queryAllByTestId('dashboardGridItem').length).toBe(Object.keys(panels).length);
});
return { dashboardApi: api, component };
};
test('renders DashboardGrid', async () => {
const { component } = await createAndMountDashboardGrid(PANELS);
const panelElements = component.find('GridItem');
expect(panelElements.length).toBe(2);
await createAndMountDashboardGrid(PANELS);
});
test('renders DashboardGrid with no visualizations', async () => {
const { component } = await createAndMountDashboardGrid({});
expect(component.find('GridItem').length).toBe(0);
await createAndMountDashboardGrid({});
});
test('DashboardGrid removes panel when removed from container', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid(PANELS);
expect(component.find('GridItem').length).toBe(2);
dashboardApi.setPanels({
'2': PANELS['2'],
// remove panel
await act(async () => {
dashboardApi.setPanels({
'2': PANELS['2'],
});
await new Promise((resolve) => setTimeout(resolve, 1));
});
await new Promise((resolve) => setTimeout(resolve, 1));
component.update();
expect(component.find('GridItem').length).toBe(1);
expect(component.getAllByTestId('dashboardGridItem').length).toBe(1);
});
test('DashboardGrid renders expanded panel', async () => {
const { dashboardApi, component } = await createAndMountDashboardGrid();
// maximize panel
dashboardApi.expandPanel('1');
await new Promise((resolve) => setTimeout(resolve, 1));
component.update();
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
expect(component.find('GridItem').length).toBe(2);
expect(component.find('#mockDashboardGridItem_1').hasClass('expandedPanel')).toBe(true);
expect(component.find('#mockDashboardGridItem_2').hasClass('hiddenPanel')).toBe(true);
// maximize panel
await act(async () => {
dashboardApi.expandPanel('1');
await new Promise((resolve) => setTimeout(resolve, 1));
});
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'expandedPanel');
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'hiddenPanel');
// minimize panel
dashboardApi.expandPanel('1');
await new Promise((resolve) => setTimeout(resolve, 1));
component.update();
expect(component.find('GridItem').length).toBe(2);
await act(async () => {
dashboardApi.expandPanel('1');
await new Promise((resolve) => setTimeout(resolve, 1));
});
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
expect(component.find('#mockDashboardGridItem_1').hasClass('regularPanel')).toBe(true);
expect(component.find('#mockDashboardGridItem_2').hasClass('regularPanel')).toBe(true);
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
});
test('DashboardGrid renders focused panel', async () => {
@ -129,20 +155,23 @@ test('DashboardGrid renders focused panel', async () => {
}),
close: async () => {},
};
dashboardApi.openOverlay(overlayMock, { focusedPanelId: '2' });
await new Promise((resolve) => setTimeout(resolve, 1));
component.update();
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
expect(component.find('GridItem').length).toBe(2);
expect(component.find('#mockDashboardGridItem_1').hasClass('blurredPanel')).toBe(true);
expect(component.find('#mockDashboardGridItem_2').hasClass('focusedPanel')).toBe(true);
await act(async () => {
dashboardApi.openOverlay(overlayMock, { focusedPanelId: '2' });
await new Promise((resolve) => setTimeout(resolve, 1));
});
// Both panels should still exist in the dom, so nothing needs to be re-fetched once focused/blurred.
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
dashboardApi.clearOverlays();
await new Promise((resolve) => setTimeout(resolve, 1));
component.update();
expect(component.find('GridItem').length).toBe(2);
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'blurredPanel');
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'focusedPanel');
expect(component.find('#mockDashboardGridItem_1').hasClass('blurredPanel')).toBe(false);
expect(component.find('#mockDashboardGridItem_2').hasClass('focusedPanel')).toBe(false);
await act(async () => {
dashboardApi.clearOverlays();
await new Promise((resolve) => setTimeout(resolve, 1));
});
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
});

View file

@ -7,153 +7,134 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import 'react-resizable/css/styles.css';
import 'react-grid-layout/css/styles.css';
import { pick } from 'lodash';
import classNames from 'classnames';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { Layout, Responsive as ResponsiveReactGridLayout } from 'react-grid-layout';
import React, { useCallback, useMemo, useRef } from 'react';
import { useAppFixedViewport } from '@kbn/core-rendering-browser';
import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { useAppFixedViewport } from '@kbn/core-rendering-browser';
import { DashboardPanelState } from '../../../../common';
import { DashboardGridItem } from './dashboard_grid_item';
import { useDashboardGridSettings } from './use_dashboard_grid_settings';
import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api';
import { arePanelLayoutsEqual } from '../../../dashboard_api/are_panel_layouts_equal';
import { useDashboardInternalApi } from '../../../dashboard_api/use_dashboard_internal_api';
import { DASHBOARD_GRID_HEIGHT, DASHBOARD_MARGIN_SIZE } from '../../../dashboard_constants';
import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api';
import {
DASHBOARD_GRID_COLUMN_COUNT,
DASHBOARD_GRID_HEIGHT,
DASHBOARD_MARGIN_SIZE,
} from '../../../dashboard_constants';
import { DashboardGridItem } from './dashboard_grid_item';
export const DashboardGrid = ({
dashboardContainer,
viewportWidth,
}: {
dashboardContainer?: HTMLElement;
viewportWidth: number;
}) => {
export const DashboardGrid = ({ dashboardContainer }: { dashboardContainer?: HTMLElement }) => {
const dashboardApi = useDashboardApi();
const dashboardInternalApi = useDashboardInternalApi();
const panelRefs = useRef<{ [panelId: string]: React.Ref<HTMLDivElement> }>({});
const [animatePanelTransforms, expandedPanelId, focusedPanelId, panels, useMargins, viewMode] =
useBatchedPublishingSubjects(
dashboardInternalApi.animatePanelTransforms$,
dashboardApi.expandedPanelId,
dashboardApi.focusedPanelId$,
dashboardApi.panels$,
dashboardApi.settings.useMargins$,
dashboardApi.viewMode
);
/**
* Track panel maximized state delayed by one tick and use it to prevent
* panel sliding animations on maximize and minimize.
*/
const [delayedIsPanelExpanded, setDelayedIsPanelMaximized] = useState(false);
useEffect(() => {
if (expandedPanelId) {
setDelayedIsPanelMaximized(true);
} else {
setTimeout(() => setDelayedIsPanelMaximized(false), 0);
}
}, [expandedPanelId]);
const [expandedPanelId, panels, useMargins, viewMode] = useBatchedPublishingSubjects(
dashboardApi.expandedPanelId,
dashboardApi.panels$,
dashboardApi.settings.useMargins$,
dashboardApi.viewMode
);
const appFixedViewport = useAppFixedViewport();
const panelsInOrder: string[] = useMemo(() => {
return Object.keys(panels).sort((embeddableIdA, embeddableIdB) => {
const panelA = panels[embeddableIdA];
const panelB = panels[embeddableIdB];
const currentLayout: GridLayoutData = useMemo(() => {
const singleRow: GridLayoutData[number] = {
title: '', // we only support a single section currently, and it does not have a title
isCollapsed: false,
panels: {},
};
// need to manually sort the panels by position because we want the panels to be collapsed from the left to the
// right when switching to the single column layout, but RGL sorts by ID which can cause unexpected behaviour between
// by-reference and by-value panels + we want the HTML order to align with this in the multi-panel view
if (panelA.gridData.y === panelB.gridData.y) {
return panelA.gridData.x - panelB.gridData.x;
} else {
return panelA.gridData.y - panelB.gridData.y;
}
Object.keys(panels).forEach((panelId) => {
const gridData = panels[panelId].gridData;
singleRow.panels[panelId] = {
id: panelId,
row: gridData.y,
column: gridData.x,
width: gridData.w,
height: gridData.h,
};
});
return [singleRow];
}, [panels]);
const panelComponents = useMemo(() => {
return panelsInOrder.map((embeddableId, index) => {
const type = panels[embeddableId].type;
return (
<DashboardGridItem
appFixedViewport={appFixedViewport}
dashboardContainer={dashboardContainer}
data-grid={panels[embeddableId].gridData}
key={embeddableId}
id={embeddableId}
index={index + 1}
type={type}
expandedPanelId={expandedPanelId}
focusedPanelId={focusedPanelId}
/>
);
});
}, [
appFixedViewport,
dashboardContainer,
expandedPanelId,
panels,
panelsInOrder,
focusedPanelId,
]);
const onLayoutChange = useCallback(
(newLayout: Array<Layout & { i: string }>) => {
(newLayout: GridLayoutData) => {
if (viewMode !== 'edit') return;
const updatedPanels: { [key: string]: DashboardPanelState } = newLayout.reduce(
(updatedPanelsAcc, panelLayout) => {
updatedPanelsAcc[panelLayout.i] = {
...panels[panelLayout.i],
gridData: pick(panelLayout, ['x', 'y', 'w', 'h', 'i']),
};
return updatedPanelsAcc;
},
{} as { [key: string]: DashboardPanelState }
);
if (!arePanelLayoutsEqual(panels, updatedPanels)) {
const currentPanels = dashboardApi.panels$.getValue();
const updatedPanels: { [key: string]: DashboardPanelState } = Object.values(
newLayout[0].panels
).reduce((updatedPanelsAcc, panelLayout) => {
updatedPanelsAcc[panelLayout.id] = {
...currentPanels[panelLayout.id],
gridData: {
i: panelLayout.id,
y: panelLayout.row,
x: panelLayout.column,
w: panelLayout.width,
h: panelLayout.height,
},
};
return updatedPanelsAcc;
}, {} as { [key: string]: DashboardPanelState });
if (!arePanelLayoutsEqual(currentPanels, updatedPanels)) {
dashboardApi.setPanels(updatedPanels);
}
},
[dashboardApi, panels, viewMode]
[dashboardApi, viewMode]
);
const renderPanelContents = useCallback(
(id: string, setDragHandles?: (refs: Array<HTMLElement | null>) => void) => {
const currentPanels = dashboardApi.panels$.getValue();
if (!currentPanels[id]) return;
if (!panelRefs.current[id]) {
panelRefs.current[id] = React.createRef();
}
const type = currentPanels[id].type;
return (
<DashboardGridItem
ref={panelRefs.current[id]}
data-grid={currentPanels[id].gridData}
key={id}
id={id}
type={type}
setDragHandles={setDragHandles}
appFixedViewport={appFixedViewport}
dashboardContainer={dashboardContainer}
/>
);
},
[appFixedViewport, dashboardApi, dashboardContainer]
);
const memoizedgridLayout = useMemo(() => {
// memoizing this component reduces the number of times it gets re-rendered to a minimum
return (
<GridLayout
layout={currentLayout}
gridSettings={{
gutterSize: useMargins ? DASHBOARD_MARGIN_SIZE : 0,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
}}
renderPanelContents={renderPanelContents}
onLayoutChange={onLayoutChange}
expandedPanelId={expandedPanelId}
accessMode={viewMode === 'edit' ? 'EDIT' : 'VIEW'}
/>
);
}, [currentLayout, useMargins, renderPanelContents, onLayoutChange, expandedPanelId, viewMode]);
const classes = classNames({
'dshLayout-withoutMargins': !useMargins,
'dshLayout--viewing': viewMode === 'view',
'dshLayout--editing': viewMode !== 'view',
'dshLayout--noAnimation': !animatePanelTransforms || delayedIsPanelExpanded,
'dshLayout-isMaximizedPanel': expandedPanelId !== undefined,
});
const { layouts, breakpoints, columns } = useDashboardGridSettings(panelsInOrder, panels);
// in print mode, dashboard layout is not controlled by React Grid Layout
if (viewMode === 'print') {
return <>{panelComponents}</>;
}
return (
<ResponsiveReactGridLayout
cols={columns}
layouts={layouts}
className={classes}
width={viewportWidth}
breakpoints={breakpoints}
onLayoutChange={onLayoutChange}
isResizable={!expandedPanelId && !focusedPanelId}
isDraggable={!expandedPanelId && !focusedPanelId}
rowHeight={DASHBOARD_GRID_HEIGHT}
margin={useMargins ? [DASHBOARD_MARGIN_SIZE, DASHBOARD_MARGIN_SIZE] : [0, 0]}
draggableHandle={'.embPanel--dragHandle'}
useCSSTransforms={false}
>
{panelComponents}
</ResponsiveReactGridLayout>
);
return <div className={classes}>{memoizedgridLayout}</div>;
};

View file

@ -9,12 +9,11 @@
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { buildMockDashboardApi } from '../../../mocks';
import { Item, Props as DashboardGridItemProps } from './dashboard_grid_item';
import { DashboardContext } from '../../../dashboard_api/use_dashboard_api';
import { DashboardInternalContext } from '../../../dashboard_api/use_dashboard_internal_api';
import { act, render } from '@testing-library/react';
jest.mock('@kbn/embeddable-plugin/public', () => {
const original = jest.requireActual('@kbn/embeddable-plugin/public');
@ -50,7 +49,7 @@ const createAndMountDashboardGridItem = (props: DashboardGridItemProps) => {
};
const { api, internalApi } = buildMockDashboardApi({ overrides: { panels } });
const component = mountWithIntl(
const component = render(
<DashboardContext.Provider value={api}>
<DashboardInternalContext.Provider value={internalApi}>
<Item {...props} />
@ -66,58 +65,89 @@ test('renders Item', async () => {
key: '1',
type: TEST_EMBEDDABLE,
});
const panelElements = component.find('.embedPanel');
const panelElements = component.getAllByTestId('dashboardPanel');
expect(panelElements.length).toBe(1);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--expanded')).toBe(false);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--hidden')).toBe(false);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--focused')).toBe(false);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--blurred')).toBe(false);
const panelElement = component.container.querySelector('#panel-1');
expect(panelElement).not.toBeNull();
expect(panelElement!.classList.contains('dshDashboardGrid__item--expanded')).toBe(false);
expect(panelElement!.classList.contains('dshDashboardGrid__item--hidden')).toBe(false);
expect(panelElement!.classList.contains('dshDashboardGrid__item--focused')).toBe(false);
expect(panelElement!.classList.contains('dshDashboardGrid__item--blurred')).toBe(false);
});
test('renders expanded panel', async () => {
const { component } = createAndMountDashboardGridItem({
const { component, dashboardApi } = createAndMountDashboardGridItem({
id: '1',
key: '1',
type: TEST_EMBEDDABLE,
expandedPanelId: '1',
});
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--expanded')).toBe(true);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--hidden')).toBe(false);
// maximize rendered panel
await act(async () => {
dashboardApi.expandPanel('1');
await new Promise((resolve) => setTimeout(resolve, 1));
});
const panelElement = component.container.querySelector('#panel-1');
expect(panelElement).not.toBeNull();
expect(panelElement!.classList.contains('dshDashboardGrid__item--expanded')).toBe(true);
expect(panelElement!.classList.contains('dshDashboardGrid__item--hidden')).toBe(false);
});
test('renders hidden panel', async () => {
const { component } = createAndMountDashboardGridItem({
const { component, dashboardApi } = createAndMountDashboardGridItem({
id: '1',
key: '1',
type: TEST_EMBEDDABLE,
expandedPanelId: '2',
});
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--expanded')).toBe(false);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--hidden')).toBe(true);
// maximize non-rendered panel
await act(async () => {
dashboardApi.expandPanel('2');
await new Promise((resolve) => setTimeout(resolve, 1));
});
const panelElement = component.container.querySelector('#panel-1');
expect(panelElement).not.toBeNull();
expect(panelElement!.classList.contains('dshDashboardGrid__item--expanded')).toBe(false);
expect(panelElement!.classList.contains('dshDashboardGrid__item--hidden')).toBe(true);
});
test('renders focused panel', async () => {
const { component } = createAndMountDashboardGridItem({
const { component, dashboardApi } = createAndMountDashboardGridItem({
id: '1',
key: '1',
type: TEST_EMBEDDABLE,
focusedPanelId: '1',
});
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--focused')).toBe(true);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--blurred')).toBe(false);
// focus rendered panel
await act(async () => {
dashboardApi.setFocusedPanelId('1');
await new Promise((resolve) => setTimeout(resolve, 1));
});
const panelElement = component.container.querySelector('#panel-1');
expect(panelElement).not.toBeNull();
expect(panelElement!.classList.contains('dshDashboardGrid__item--focused')).toBe(true);
expect(panelElement!.classList.contains('dshDashboardGrid__item--blurred')).toBe(false);
});
test('renders blurred panel', async () => {
const { component } = createAndMountDashboardGridItem({
const { component, dashboardApi } = createAndMountDashboardGridItem({
id: '1',
key: '1',
type: TEST_EMBEDDABLE,
focusedPanelId: '2',
});
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--focused')).toBe(false);
expect(component.find('#panel-1').hasClass('dshDashboardGrid__item--blurred')).toBe(true);
// focus non-rendered panel
await act(async () => {
dashboardApi.setFocusedPanelId('2');
await new Promise((resolve) => setTimeout(resolve, 1));
});
const panelElement = component.container.querySelector('#panel-1');
expect(panelElement).not.toBeNull();
expect(panelElement!.classList.contains('dshDashboardGrid__item--focused')).toBe(false);
expect(panelElement!.classList.contains('dshDashboardGrid__item--blurred')).toBe(true);
});

View file

@ -29,10 +29,9 @@ export interface Props extends DivProps {
id: DashboardPanelState['explicitInput']['id'];
index?: number;
type: DashboardPanelState['type'];
expandedPanelId?: string;
focusedPanelId?: string;
key: string;
isRenderable?: boolean;
setDragHandles?: (refs: Array<HTMLElement | null>) => void;
}
export const Item = React.forwardRef<HTMLDivElement, Props>(
@ -40,14 +39,11 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
{
appFixedViewport,
dashboardContainer,
expandedPanelId,
focusedPanelId,
id,
index,
type,
isRenderable = true,
// The props below are passed from ReactGridLayoutn and need to be merged with their counterparts.
// https://github.com/react-grid-layout/react-grid-layout/issues/1241#issuecomment-658306889
setDragHandles,
children,
className,
...rest
@ -56,9 +52,18 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
) => {
const dashboardApi = useDashboardApi();
const dashboardInternalApi = useDashboardInternalApi();
const [highlightPanelId, scrollToPanelId, useMargins, viewMode] = useBatchedPublishingSubjects(
const [
highlightPanelId,
scrollToPanelId,
expandedPanelId,
focusedPanelId,
useMargins,
viewMode,
] = useBatchedPublishingSubjects(
dashboardApi.highlightPanelId$,
dashboardApi.scrollToPanelId$,
dashboardApi.expandedPanelId,
dashboardApi.focusedPanelId$,
dashboardApi.settings.useMargins$,
dashboardApi.viewMode
);
@ -118,6 +123,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
showBorder: useMargins,
showNotifications: true,
showShadow: false,
setDragHandles,
};
return (
@ -133,7 +139,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
onApiAvailable={(api) => dashboardInternalApi.registerChildApi(api)}
/>
);
}, [id, dashboardApi, dashboardInternalApi, type, useMargins]);
}, [id, dashboardApi, dashboardInternalApi, type, useMargins, setDragHandles]);
return (
<div
@ -190,8 +196,6 @@ export const ObservedItem = React.forwardRef<HTMLDivElement, Props>((props, pane
return <Item ref={panelRef} isRenderable={isRenderable} {...props} />;
});
// ReactGridLayout passes ref to children. Functional component children require forwardRef to avoid react warning
// https://github.com/react-grid-layout/react-grid-layout#custom-child-components-and-draggable-handles
export const DashboardGridItem = React.forwardRef<HTMLDivElement, Props>((props, ref) => {
const dashboardApi = useDashboardApi();
const [focusedPanelId, viewMode] = useBatchedPublishingSubjects(

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMemo } from 'react';
import { useEuiTheme } from '@elastic/eui';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { DashboardPanelMap } from '../../../../common';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../dashboard_constants';
import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api';
export const useDashboardGridSettings = (panelsInOrder: string[], panels: DashboardPanelMap) => {
const dashboardApi = useDashboardApi();
const { euiTheme } = useEuiTheme();
const viewMode = useStateFromPublishingSubject(dashboardApi.viewMode);
const layouts = useMemo(() => {
return {
lg: panelsInOrder.map((embeddableId) => panels[embeddableId].gridData),
};
}, [panels, panelsInOrder]);
const breakpoints = useMemo(
() => ({ lg: euiTheme.breakpoint.m, ...(viewMode === 'view' ? { sm: 0 } : {}) }),
[viewMode, euiTheme.breakpoint.m]
);
const columns = useMemo(
() => ({
lg: DASHBOARD_GRID_COLUMN_COUNT,
...(viewMode === 'view' ? { sm: 1 } : {}),
}),
[viewMode]
);
return { layouts, breakpoints, columns };
};

View file

@ -2,6 +2,8 @@
flex: auto;
display: flex;
flex-direction: column;
width: 100%;
&--defaultBg {
background: $euiColorEmptyShade;
}
@ -9,10 +11,10 @@
.dshDashboardViewport {
width: 100%;
}
.dshDashboardViewport--panelExpanded {
flex: 1;
&--panelExpanded {
flex: 1;
}
}
.dshDashboardViewport-controls {
@ -22,10 +24,4 @@
.dashboardViewport--screenshotMode .controlsWrapper--empty {
display:none
}
.dshDashboardViewportWrapper--isFullscreen {
.dshDashboardGrid__item--expanded {
padding: $euiSizeS;
}
}

View file

@ -25,6 +25,15 @@ is being formed. This can result in parts of the vis being cut out.
}
}
.dshDashboardViewport--print {
.kbnGridRow {
display: block !important;
}
.kbnGridPanel {
height: 100% !important;
}
}
@media screen, projection {
.printViewport {
&__vis {

View file

@ -7,9 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { debounce } from 'lodash';
import classNames from 'classnames';
import useResizeObserver from 'use-resize-observer/polyfilled';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiPortal } from '@elastic/eui';
@ -28,20 +26,6 @@ import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api';
import { useDashboardInternalApi } from '../../../dashboard_api/use_dashboard_internal_api';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
export const useDebouncedWidthObserver = (skipDebounce = false, wait = 100) => {
const [width, setWidth] = useState<number>(0);
const onWidthChange = useMemo(() => debounce(setWidth, wait), [wait]);
const { ref } = useResizeObserver<HTMLDivElement>({
onResize: (dimensions) => {
if (dimensions.width) {
if (width === 0 || skipDebounce) setWidth(dimensions.width);
if (dimensions.width !== width) onWidthChange(dimensions.width);
}
},
});
return { ref, width };
};
export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?: HTMLElement }) => {
const dashboardApi = useDashboardApi();
const dashboardInternalApi = useDashboardInternalApi();
@ -51,7 +35,6 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?:
dashboardTitle,
description,
expandedPanelId,
focusedPanelId,
panels,
viewMode,
useMargins,
@ -61,7 +44,6 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?:
dashboardApi.panelTitle,
dashboardApi.panelDescription,
dashboardApi.expandedPanelId,
dashboardApi.focusedPanelId$,
dashboardApi.panels$,
dashboardApi.viewMode,
dashboardApi.settings.useMargins$,
@ -75,10 +57,9 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?:
return Object.keys(panels).length;
}, [panels]);
const { ref: resizeRef, width: viewportWidth } = useDebouncedWidthObserver(!!focusedPanelId);
const classes = classNames({
dshDashboardViewport: true,
'dshDashboardViewport--print': viewMode === 'print',
'dshDashboardViewport--panelExpanded': Boolean(expandedPanelId),
});
@ -150,20 +131,13 @@ export const DashboardViewport = ({ dashboardContainer }: { dashboardContainer?:
)}
{panelCount === 0 && <DashboardEmptyScreen />}
<div
ref={resizeRef}
className={classes}
data-shared-items-container
data-title={dashboardTitle}
data-description={description}
data-shared-items-count={panelCount}
>
{/* Wait for `viewportWidth` to actually be set before rendering the dashboard grid -
otherwise, there is a race condition where the panels can end up being squashed
TODO only render when dashboardInitialized
*/}
{viewportWidth !== 0 && (
<DashboardGrid dashboardContainer={dashboardContainer} viewportWidth={viewportWidth} />
)}
<DashboardGrid dashboardContainer={dashboardContainer} />
</div>
</div>
);

View file

@ -82,7 +82,8 @@
"@kbn/core-mount-utils-browser",
"@kbn/visualization-utils",
"@kbn/std",
"@kbn/core-rendering-browser"
"@kbn/core-rendering-browser",
"@kbn/grid-layout"
],
"exclude": ["target/**/*"]
}