diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 4f0a6c4a1dfa7..635d116f2249a 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -123,7 +123,7 @@ export const GridExample = ({ ) => { // set the parent div size directly to smooth out height changes. const smoothHeightRef = useRef(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 (
{children} diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index b11fd8cc7dd9f..4aa93710f4a2f 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -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(null); const [rowCount, setRowCount] = useState( 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 (
{ + 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 = ({ ); }; + +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; + } +`; diff --git a/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx index 6d08678b50281..63e909d5cfb8e 100644 --- a/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx @@ -146,7 +146,8 @@ export const DragHandle = React.forwardRef< &:active { cursor: grabbing; } - .kbnGrid--static & { + .kbnGrid--static &, + .kbnGridPanel--expanded & { display: none; } `} diff --git a/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx index e27785bf702ea..a89b2230d0f13 100644 --- a/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx @@ -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( /** 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( 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( 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( } }); - 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( return (
-
- - {panelContents} - -
+ + {panelContents} +
); } diff --git a/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx index 3f64c32aba59a..8ccfd4d44d96b 100644 --- a/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx @@ -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; } diff --git a/packages/kbn-grid-layout/grid/grid_row/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row/grid_row.tsx index 976f71fb1683a..849fbdecbb211 100644 --- a/packages/kbn-grid-layout/grid/grid_row/grid_row.tsx +++ b/packages/kbn-grid-layout/grid/grid_row/grid_row.tsx @@ -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( 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(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( 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( } }); - 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( return () => { interactionStyleSubscription.unsubscribe(); rowStateSubscription.unsubscribe(); - expandedPanelStyleSubscription.unsubscribe(); }; }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -257,7 +203,13 @@ export const GridRow = forwardRef( }, [panelIds, gridLayoutStateManager, renderPanelContents, rowIndex, setInteractionEvent]); return ( -
+
{rowIndex !== 0 && ( ( )} {!isCollapsed && (
{ return ( - <> +
- +
); }; diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts index c8026c5a7e4a5..884aa170d8271 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -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'); } diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts index 6808798ead6a3..f9ef34258fe0e 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -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({ + ...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({ width: 0, height: 0 }); const interactionEvent$ = new BehaviorSubject(undefined); const activePanel$ = new BehaviorSubject(undefined); - const runtimeSettings$ = new BehaviorSubject({ - ...gridSettings, - columnPixelWidth: 0, - }); const panelIds$ = new BehaviorSubject( 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 () => { diff --git a/src/platform/plugins/private/presentation_panel/public/panel_component/_presentation_panel.scss b/src/platform/plugins/private/presentation_panel/public/panel_component/_presentation_panel.scss index a205501c0d08f..52e7615308fdd 100644 --- a/src/platform/plugins/private/presentation_panel/public/panel_component/_presentation_panel.scss +++ b/src/platform/plugins/private/presentation_panel/public/panel_component/_presentation_panel.scss @@ -4,7 +4,6 @@ display: flex; flex-direction: column; height: 100%; - min-height: $euiSizeL + 2px; // + 2px to account for border position: relative; &-isLoading { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts index cf1dd0e949d4c..8344f699ec3fa 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts @@ -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(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 diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts index 07fe1134d4dcb..81e206d006495 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/track_panel.ts @@ -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: () => { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts index 238bd5976bfa1..0318456ac0a0d 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts @@ -140,6 +140,7 @@ export type DashboardApi = CanExpandPanels & controlGroupApi$: PublishingSubject; fullScreenMode$: PublishingSubject; focusedPanelId$: PublishingSubject; + setFocusedPanelId: (id: string | undefined) => void; forceRefresh: () => void; getSettings: () => DashboardSettings; getSerializedState: () => { @@ -174,7 +175,6 @@ export type DashboardApi = CanExpandPanels & }; export interface DashboardInternalApi { - animatePanelTransforms$: PublishingSubject; controlGroupReload$: Subject; panelsReload$: Subject; getRuntimeStateForControlGroup: () => object | undefined; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/_dashboard_container.scss b/src/platform/plugins/shared/dashboard/public/dashboard_container/_dashboard_container.scss index e834a65230e81..02d6495b696dc 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/_dashboard_container.scss +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/_dashboard_container.scss @@ -4,6 +4,7 @@ .dashboardContainer, .dashboardViewport { flex: auto; display: flex; + width: 100%; } .dashboardViewport--loading { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss index ce010aa4cf9a5..8b33ce8278e27 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss @@ -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; - } -} +} \ No newline at end of file diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss index 2b7ec068f827d..5c34785a5eff7 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/_dashboard_panel.scss @@ -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%; +} \ No newline at end of file diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx index 543d6b7456270..4a0c8ec92612c 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.test.tsx @@ -9,35 +9,44 @@ import React from 'react'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; - -import { DashboardGrid } from './dashboard_grid'; +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 { DashboardContext } from '../../../dashboard_api/use_dashboard_api'; -import { DashboardInternalContext } from '../../../dashboard_api/use_dashboard_internal_api'; -import { DashboardPanelMap } from '../../../../common'; +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 ( -
+
mockDashboardGridItem
); @@ -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( - + ); + + // 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(); + 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.find('GridItem').length).toBe(2); + expect(component.getAllByTestId('dashboardGridItem').length).toBe(2); - expect(component.find('#mockDashboardGridItem_1').hasClass('expandedPanel')).toBe(true); - expect(component.find('#mockDashboardGridItem_2').hasClass('hiddenPanel')).toBe(true); + 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'); + + await act(async () => { + dashboardApi.clearOverlays(); + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + expect(component.getAllByTestId('dashboardGridItem').length).toBe(2); - expect(component.find('#mockDashboardGridItem_1').hasClass('blurredPanel')).toBe(false); - expect(component.find('#mockDashboardGridItem_2').hasClass('focusedPanel')).toBe(false); + verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel'); + verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel'); }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx index e521a1bbd276c..f4a109fb14c38 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -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 { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { useAppFixedViewport } from '@kbn/core-rendering-browser'; +import { GridLayout, type GridLayoutData } from '@kbn/grid-layout'; + +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; 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'; - -export const DashboardGrid = ({ - dashboardContainer, - viewportWidth, -}: { - dashboardContainer?: HTMLElement; - viewportWidth: number; -}) => { +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 }: { dashboardContainer?: HTMLElement }) => { const dashboardApi = useDashboardApi(); - const dashboardInternalApi = useDashboardInternalApi(); - - const [animatePanelTransforms, expandedPanelId, focusedPanelId, panels, useMargins, viewMode] = - useBatchedPublishingSubjects( - dashboardInternalApi.animatePanelTransforms$, - dashboardApi.expandedPanelId, - dashboardApi.focusedPanelId$, - dashboardApi.panels$, - dashboardApi.settings.useMargins$, - dashboardApi.viewMode - ); + const panelRefs = useRef<{ [panelId: string]: React.Ref }>({}); - /** - * 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]; - - // 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; - } + 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: {}, + }; + + 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; + const onLayoutChange = useCallback( + (newLayout: GridLayoutData) => { + if (viewMode !== 'edit') return; + + 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, viewMode] + ); + + const renderPanelContents = useCallback( + (id: string, setDragHandles?: (refs: Array) => 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 ( ); - }); - }, [ - appFixedViewport, - dashboardContainer, - expandedPanelId, - panels, - panelsInOrder, - focusedPanelId, - ]); - - const onLayoutChange = useCallback( - (newLayout: Array) => { - 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)) { - dashboardApi.setPanels(updatedPanels); - } }, - [dashboardApi, panels, viewMode] + [appFixedViewport, dashboardApi, dashboardContainer] ); + const memoizedgridLayout = useMemo(() => { + // memoizing this component reduces the number of times it gets re-rendered to a minimum + return ( + + ); + }, [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 ( - - {panelComponents} - - ); + return
{memoizedgridLayout}
; }; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx index 268c352e91ad7..1306b8836d94c 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.test.tsx @@ -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( @@ -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); }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx index ded3cc7095407..dd111bc9f6fc2 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx @@ -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) => void; } export const Item = React.forwardRef( @@ -40,14 +39,11 @@ export const Item = React.forwardRef( { 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( ) => { 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( showBorder: useMargins, showNotifications: true, showShadow: false, + setDragHandles, }; return ( @@ -133,7 +139,7 @@ export const Item = React.forwardRef( onApiAvailable={(api) => dashboardInternalApi.registerChildApi(api)} /> ); - }, [id, dashboardApi, dashboardInternalApi, type, useMargins]); + }, [id, dashboardApi, dashboardInternalApi, type, useMargins, setDragHandles]); return (
((props, pane return ; }); -// 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((props, ref) => { const dashboardApi = useDashboardApi(); const [focusedPanelId, viewMode] = useBatchedPublishingSubjects( diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/use_dashboard_grid_settings.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/use_dashboard_grid_settings.tsx deleted file mode 100644 index 50481d1a82f72..0000000000000 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/grid/use_dashboard_grid_settings.tsx +++ /dev/null @@ -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 }; -}; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_dashboard_viewport.scss b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_dashboard_viewport.scss index 79e7c16bfe4a7..9a57af03d3c11 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_dashboard_viewport.scss +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_dashboard_viewport.scss @@ -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; - } } \ No newline at end of file diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_print_viewport.scss b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_print_viewport.scss index cd9c41f392a0b..68002b179b88c 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_print_viewport.scss +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/_print_viewport.scss @@ -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 { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx index 0252341b5f4b9..9636a9a09bdf6 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_container/component/viewport/dashboard_viewport.tsx @@ -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(0); - const onWidthChange = useMemo(() => debounce(setWidth, wait), [wait]); - const { ref } = useResizeObserver({ - 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 && }
- {/* 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 && ( - - )} +
); diff --git a/src/platform/plugins/shared/dashboard/tsconfig.json b/src/platform/plugins/shared/dashboard/tsconfig.json index cbf2b4c469875..ed1555fb069c3 100644 --- a/src/platform/plugins/shared/dashboard/tsconfig.json +++ b/src/platform/plugins/shared/dashboard/tsconfig.json @@ -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/**/*"] }