mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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` | |--------|--------| |  |  | - 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` | |--------|--------| |  |  | - More reliable panel positioning due to the use of CSS grid rather than absolute positioning via pixels | `react-grid-layout` | `kbn-grid-layout` | |--------|--------| |  |  | - 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:
parent
2c9e55dc44
commit
686571547f
26 changed files with 516 additions and 612 deletions
|
@ -123,7 +123,7 @@ export const GridExample = ({
|
|||
<EuiPageTemplate.Section
|
||||
color="subdued"
|
||||
contentProps={{
|
||||
css: { flexGrow: 1 },
|
||||
css: { flexGrow: 1, display: 'flex', flexDirection: 'column' },
|
||||
}}
|
||||
>
|
||||
<EuiCallOut
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -146,7 +146,8 @@ export const DragHandle = React.forwardRef<
|
|||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.kbnGrid--static & {
|
||||
.kbnGrid--static &,
|
||||
.kbnGridPanel--expanded & {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: $euiSizeL + 2px; // + 2px to account for border
|
||||
position: relative;
|
||||
|
||||
&-isLoading {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
.dashboardContainer, .dashboardViewport {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboardViewport--loading {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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%;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 };
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue