From b91fa562bb7663a119fdd9c22054560960f625a0 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 29 Oct 2024 16:07:45 -0600 Subject: [PATCH] [Dashboard] [Collapsable Panels] Reduce re-renders (#197343) Closes https://github.com/elastic/kibana/issues/191131 ## Summary This PR greatly reduces the number of React re-renders that happen as panels get dragged around and/or resized. Now, the actions that trigger a panel to get rendered are as follows: 1. Obviously, when the grid first loads, every panel has to be rendered. 2. When a panel gets dragged from one row to the next, both the original row and the new row will re-render all of their panels because the panel IDs in both rows changed - however, because of the `key` prop on the `GridPanel` component, only the **dragged** panel will actually be fully re-mounted. 3. When a panel gets collapsed and expanded, all panels in that row will get re-mounted and rendered. 4. When a panel ID gets changed (this currently isn't possible, but in theory, this would also trigger the panel to get re-rendered due to the `key` prop on the `GridPanel` component) In order to accomplish this, we are now handling **all style changes** via a subscription rather than setting the CSS directly; so, as the `gridLayout$` behaviour subjects publishes changes, we update the row + panel styles via the panel reference. This allows us to change how the grid looks without triggering React to rerender the entire panel. **How to Test:** Add a `console.log` to the `renderPanelContents` in `examples/grid_example/public/app.tsx` - this will tell you when a panel is getting re-rendered. ### Checklist - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#_add_your_labels) - [ ] This will appear in the **Release Notes** and follow the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../kbn-grid-layout/grid/drag_preview.tsx | 71 +++++ packages/kbn-grid-layout/grid/grid_layout.tsx | 37 ++- packages/kbn-grid-layout/grid/grid_panel.tsx | 266 ++++++++++++------ packages/kbn-grid-layout/grid/grid_row.tsx | 208 +++++++++----- .../grid/use_grid_layout_events.ts | 7 +- .../grid/use_grid_layout_state.ts | 116 +------- packages/kbn-grid-layout/tsconfig.json | 1 - 7 files changed, 415 insertions(+), 291 deletions(-) create mode 100644 packages/kbn-grid-layout/grid/drag_preview.tsx diff --git a/packages/kbn-grid-layout/grid/drag_preview.tsx b/packages/kbn-grid-layout/grid/drag_preview.tsx new file mode 100644 index 0000000000000..24aa81ffea1df --- /dev/null +++ b/packages/kbn-grid-layout/grid/drag_preview.tsx @@ -0,0 +1,71 @@ +/* + * 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 React, { useEffect, useRef } from 'react'; +import { combineLatest, skip } from 'rxjs'; + +import { transparentize } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import { GridLayoutStateManager } from './types'; + +export const DragPreview = ({ + rowIndex, + gridLayoutStateManager, +}: { + rowIndex: number; + gridLayoutStateManager: GridLayoutStateManager; +}) => { + const dragPreviewRef = useRef(null); + + useEffect( + () => { + /** Update the styles of the drag preview via a subscription to prevent re-renders */ + const styleSubscription = combineLatest([ + gridLayoutStateManager.activePanel$, + gridLayoutStateManager.gridLayout$, + ]) + .pipe(skip(1)) // skip the first emit because the drag preview is only rendered after a user action + .subscribe(([activePanel, gridLayout]) => { + if (!dragPreviewRef.current) return; + + if (!activePanel || !gridLayout[rowIndex].panels[activePanel.id]) { + dragPreviewRef.current.style.display = 'none'; + } else { + const panel = gridLayout[rowIndex].panels[activePanel.id]; + dragPreviewRef.current.style.display = 'block'; + dragPreviewRef.current.style.gridColumnStart = `${panel.column + 1}`; + dragPreviewRef.current.style.gridColumnEnd = `${panel.column + 1 + panel.width}`; + dragPreviewRef.current.style.gridRowStart = `${panel.row + 1}`; + dragPreviewRef.current.style.gridRowEnd = `${panel.row + 1 + panel.height}`; + } + }); + + return () => { + styleSubscription.unsubscribe(); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( +
+ ); +}; diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index c6bbd94dabe56..7f77a476579e9 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -7,9 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; - -import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import React, { useEffect, useState } from 'react'; +import { distinctUntilChanged, map, skip } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; import { GridHeightSmoother } from './grid_height_smoother'; import { GridRow } from './grid_row'; @@ -29,12 +29,28 @@ export const GridLayout = ({ }); useGridLayoutEvents({ gridLayoutStateManager }); - const [gridLayout, runtimeSettings, interactionEvent] = useBatchedPublishingSubjects( - gridLayoutStateManager.gridLayout$, - gridLayoutStateManager.runtimeSettings$, - gridLayoutStateManager.interactionEvent$ + const [rowCount, setRowCount] = useState( + gridLayoutStateManager.gridLayout$.getValue().length ); + useEffect(() => { + /** + * The only thing that should cause the entire layout to re-render is adding a new row; + * this subscription ensures this by updating the `rowCount` state when it changes. + */ + const rowCountSubscription = gridLayoutStateManager.gridLayout$ + .pipe( + skip(1), // we initialized `rowCount` above, so skip the initial emit + map((newLayout) => newLayout.length), + distinctUntilChanged() + ) + .subscribe((newRowCount) => { + setRowCount(newRowCount); + }); + return () => rowCountSubscription.unsubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( <> @@ -43,15 +59,12 @@ export const GridLayout = ({ setDimensionsRef(divElement); }} > - {gridLayout.map((rowData, rowIndex) => { + {Array.from({ length: rowCount }, (_, rowIndex) => { return ( { const currentLayout = gridLayoutStateManager.gridLayout$.value; diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel.tsx index fbe34c4b68e15..64a4a2faff403 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel.tsx @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useEffect, useMemo } from 'react'; +import { combineLatest, skip } from 'rxjs'; import { EuiIcon, @@ -20,108 +21,191 @@ import { import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; -import { GridPanelData, PanelInteractionEvent } from './types'; +import { GridLayoutStateManager, PanelInteractionEvent } from './types'; export const GridPanel = forwardRef< HTMLDivElement, { - panelData: GridPanelData; - activePanelId: string | undefined; + panelId: string; + rowIndex: number; renderPanelContents: (panelId: string) => React.ReactNode; interactionStart: ( type: PanelInteractionEvent['type'], e: React.MouseEvent ) => void; + gridLayoutStateManager: GridLayoutStateManager; } ->(({ activePanelId, panelData, renderPanelContents, interactionStart }, panelRef) => { - const { euiTheme } = useEuiTheme(); - const thisPanelActive = activePanelId === panelData.id; +>( + ( + { panelId, rowIndex, renderPanelContents, interactionStart, gridLayoutStateManager }, + panelRef + ) => { + const { euiTheme } = useEuiTheme(); - return ( -
- - {/* drag handle */} -
interactionStart('drag', e)} - onMouseUp={(e) => interactionStart('drop', e)} - > - -
- {/* Resize handle */} -
interactionStart('resize', e)} - onMouseUp={(e) => interactionStart('drop', e)} - css={css` - right: 0; - bottom: 0; - opacity: 0; - margin: -2px; - position: absolute; - width: ${euiThemeVars.euiSizeL}; - height: ${euiThemeVars.euiSizeL}; - transition: opacity 0.2s, border 0.2s; - border-radius: 7px 0 7px 0; - border-bottom: 2px solid ${euiThemeVars.euiColorSuccess}; - border-right: 2px solid ${euiThemeVars.euiColorSuccess}; - :hover { - background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; - cursor: se-resize; + /** Set initial styles based on state at mount to prevent styles from "blipping" */ + const initialStyles = useMemo(() => { + const initialPanel = gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels[panelId]; + return css` + 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}; + `; + }, [gridLayoutStateManager, rowIndex, panelId]); + + useEffect( + () => { + /** Update the styles of the panel via a subscription to prevent re-renders */ + const styleSubscription = combineLatest([ + gridLayoutStateManager.activePanel$, + gridLayoutStateManager.gridLayout$, + gridLayoutStateManager.runtimeSettings$, + ]) + .pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it + .subscribe(([activePanel, gridLayout, runtimeSettings]) => { + const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; + const panel = gridLayout[rowIndex].panels[panelId]; + if (!ref || !panel) return; + + const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue(); + if (panelId === activePanel?.id) { + // if the current panel is active, give it fixed positioning depending on the interaction event + const { position: draggingPosition } = activePanel; + + ref.style.zIndex = `${euiThemeVars.euiZModal}`; + if (currentInteractionEvent?.type === 'resize') { + // if the current panel is being resized, ensure it is not shrunk past the size of a single cell + ref.style.width = `${Math.max( + draggingPosition.right - draggingPosition.left, + runtimeSettings.columnPixelWidth + )}px`; + ref.style.height = `${Math.max( + draggingPosition.bottom - draggingPosition.top, + runtimeSettings.rowHeight + )}px`; + + // undo any "lock to grid" styles **except** for the top left corner, which stays locked + ref.style.gridColumnStart = `${panel.column + 1}`; + ref.style.gridRowStart = `${panel.row + 1}`; + ref.style.gridColumnEnd = ``; + ref.style.gridRowEnd = ``; + } else { + // if the current panel is being dragged, render it with a fixed position + size + ref.style.position = 'fixed'; + ref.style.left = `${draggingPosition.left}px`; + ref.style.top = `${draggingPosition.top}px`; + ref.style.width = `${draggingPosition.right - draggingPosition.left}px`; + ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`; + + // undo any "lock to grid" styles + ref.style.gridColumnStart = ``; + ref.style.gridRowStart = ``; + ref.style.gridColumnEnd = ``; + ref.style.gridRowEnd = ``; + } + } else { + ref.style.zIndex = '0'; + + // if the panel is not being dragged and/or resized, undo any fixed position styles + ref.style.position = ''; + ref.style.left = ``; + ref.style.top = ``; + ref.style.width = ``; + ref.style.height = ``; + + // and render the panel locked to the grid + ref.style.gridColumnStart = `${panel.column + 1}`; + ref.style.gridColumnEnd = `${panel.column + 1 + panel.width}`; + ref.style.gridRowStart = `${panel.row + 1}`; + ref.style.gridRowEnd = `${panel.row + 1 + panel.height}`; } - `} - /> -
{ + styleSubscription.unsubscribe(); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( +
+ - {renderPanelContents(panelData.id)} -
- -
- ); -}); + {/* drag handle */} +
interactionStart('drag', e)} + onMouseUp={(e) => interactionStart('drop', e)} + > + +
+ {/* Resize handle */} +
interactionStart('resize', e)} + onMouseUp={(e) => interactionStart('drop', e)} + css={css` + right: 0; + bottom: 0; + opacity: 0; + margin: -2px; + position: absolute; + width: ${euiThemeVars.euiSizeL}; + height: ${euiThemeVars.euiSizeL}; + transition: opacity 0.2s, border 0.2s; + border-radius: 7px 0 7px 0; + border-bottom: 2px solid ${euiThemeVars.euiColorSuccess}; + border-right: 2px solid ${euiThemeVars.euiColorSuccess}; + :hover { + background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; + cursor: se-resize; + } + `} + /> +
+ {renderPanelContents(panelId)} +
+ +
+ ); + } +); diff --git a/packages/kbn-grid-layout/grid/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row.tsx index 917f661c91740..e797cd570550a 100644 --- a/packages/kbn-grid-layout/grid/grid_row.tsx +++ b/packages/kbn-grid-layout/grid/grid_row.tsx @@ -7,41 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { forwardRef, useMemo, useRef } from 'react'; +import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { combineLatest, map, pairwise, skip } from 'rxjs'; import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle, transparentize } from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import { euiThemeVars } from '@kbn/ui-theme'; -import { useStateFromPublishingSubject } from '@kbn/presentation-publishing'; +import { DragPreview } from './drag_preview'; import { GridPanel } from './grid_panel'; -import { - GridLayoutStateManager, - GridRowData, - PanelInteractionEvent, - RuntimeGridSettings, -} from './types'; - -const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2); -const getGridBackgroundCSS = (settings: RuntimeGridSettings) => { - const { gutterSize, columnPixelWidth, rowHeight } = settings; - return css` - background-position: top -${gutterSize / 2}px left -${gutterSize / 2}px; - background-size: ${columnPixelWidth + gutterSize}px ${rowHeight + gutterSize}px; - background-image: linear-gradient(to right, ${gridColor} 1px, transparent 1px), - linear-gradient(to bottom, ${gridColor} 1px, transparent 1px); - `; -}; +import { GridLayoutStateManager, GridRowData, PanelInteractionEvent } from './types'; export const GridRow = forwardRef< HTMLDivElement, { rowIndex: number; - rowData: GridRowData; toggleIsCollapsed: () => void; - targetRowIndex: number | undefined; - runtimeSettings: RuntimeGridSettings; renderPanelContents: (panelId: string) => React.ReactNode; setInteractionEvent: (interactionData?: PanelInteractionEvent) => void; gridLayoutStateManager: GridLayoutStateManager; @@ -49,10 +31,7 @@ export const GridRow = forwardRef< >( ( { - rowData, rowIndex, - targetRowIndex, - runtimeSettings, toggleIsCollapsed, renderPanelContents, setInteractionEvent, @@ -60,19 +39,122 @@ export const GridRow = forwardRef< }, gridRef ) => { - const dragPreviewRef = useRef(null); - const activePanel = useStateFromPublishingSubject(gridLayoutStateManager.activePanel$); + const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex]; + const [panelIds, setPanelIds] = useState(Object.keys(currentRow.panels)); + const [rowTitle, setRowTitle] = useState(currentRow.title); + const [isCollapsed, setIsCollapsed] = useState(currentRow.isCollapsed); - const { gutterSize, columnCount, rowHeight } = runtimeSettings; - const isGridTargeted = activePanel?.id && targetRowIndex === rowIndex; + 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] + ); + + /** 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; + + 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); + `; + }, [gridLayoutStateManager, getRowCount, rowIndex]); + + useEffect( + () => { + /** Update the styles of the grid row via a subscription to prevent re-renders */ + const styleSubscription = combineLatest([ + gridLayoutStateManager.interactionEvent$, + gridLayoutStateManager.gridLayout$, + gridLayoutStateManager.runtimeSettings$, + ]) + .pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it + .subscribe(([interactionEvent, gridLayout, runtimeSettings]) => { + const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex]; + if (!rowRef) return; + + const { gutterSize, rowHeight, columnPixelWidth } = runtimeSettings; - // calculate row count based on the number of rows needed to fit all panels - const rowCount = useMemo(() => { - const maxRow = Object.values(rowData.panels).reduce((acc, panel) => { - return Math.max(acc, panel.row + panel.height); - }, 0); - return maxRow || 1; - }, [rowData]); + rowRef.style.gridTemplateRows = `repeat(${getRowCount( + gridLayout[rowIndex] + )}, ${rowHeight}px)`; + + const targetRow = interactionEvent?.targetRowIndex; + if (rowIndex === targetRow && interactionEvent?.type !== 'drop') { + // apply "targetted row" styles + const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2); + rowRef.style.backgroundPosition = `top -${gutterSize / 2}px left -${ + gutterSize / 2 + }px`; + rowRef.style.backgroundSize = ` ${columnPixelWidth + gutterSize}px ${ + rowHeight + gutterSize + }px`; + rowRef.style.backgroundImage = `linear-gradient(to right, ${gridColor} 1px, transparent 1px), + linear-gradient(to bottom, ${gridColor} 1px, transparent 1px)`; + rowRef.style.backgroundColor = `${transparentize( + euiThemeVars.euiColorSuccess, + 0.05 + )}`; + } else { + // undo any "targetted row" styles + rowRef.style.backgroundPosition = ``; + rowRef.style.backgroundSize = ``; + rowRef.style.backgroundImage = ``; + rowRef.style.backgroundColor = `transparent`; + } + }); + + /** + * The things that should trigger a re-render are title, collapsed state, and panel ids - panel positions + * are being controlled via CSS styles, so they do not need to trigger a re-render. This subscription ensures + * that the row will re-render when one of those three things changes. + */ + const rowStateSubscription = gridLayoutStateManager.gridLayout$ + .pipe( + skip(1), // we are initializing all row state with a value, so skip the initial emit + map((gridLayout) => { + return { + title: gridLayout[rowIndex].title, + isCollapsed: gridLayout[rowIndex].isCollapsed, + panelIds: Object.keys(gridLayout[rowIndex].panels), + }; + }), + pairwise() + ) + .subscribe(([oldRowData, newRowData]) => { + if (oldRowData.title !== newRowData.title) setRowTitle(newRowData.title); + if (oldRowData.isCollapsed !== newRowData.isCollapsed) + setIsCollapsed(newRowData.isCollapsed); + if ( + oldRowData.panelIds.length !== newRowData.panelIds.length || + !( + oldRowData.panelIds.every((p) => newRowData.panelIds.includes(p)) && + newRowData.panelIds.every((p) => oldRowData.panelIds.includes(p)) + ) + ) { + setPanelIds(newRowData.panelIds); + } + }); + + return () => { + styleSubscription.unsubscribe(); + rowStateSubscription.unsubscribe(); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [rowIndex] + ); return ( <> @@ -85,51 +167,43 @@ export const GridRow = forwardRef< aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', { defaultMessage: 'Toggle collapse', })} - iconType={rowData.isCollapsed ? 'arrowRight' : 'arrowDown'} + iconType={isCollapsed ? 'arrowRight' : 'arrowDown'} onClick={toggleIsCollapsed} /> -

{rowData.title}

+

{rowTitle}

)} - {!rowData.isCollapsed && ( + {!isCollapsed && (
- {Object.values(rowData.panels).map((panelData) => ( + {panelIds.map((panelId) => ( { e.preventDefault(); e.stopPropagation(); - const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelData.id]; + const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; if (!panelRef) return; const panelRect = panelRef.getBoundingClientRect(); setInteractionEvent({ type, - id: panelData.id, + id: panelId, panelDiv: panelRef, targetRowIndex: rowIndex, mouseOffsets: { @@ -144,32 +218,12 @@ export const GridRow = forwardRef< if (!gridLayoutStateManager.panelRefs.current[rowIndex]) { gridLayoutStateManager.panelRefs.current[rowIndex] = {}; } - gridLayoutStateManager.panelRefs.current[rowIndex][panelData.id] = element; + gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element; }} /> ))} - {/* render the drag preview if this row is currently being targetted */} - {isGridTargeted && ( -
- )} +
)} 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 dfbd013d3642a..bd6343b9e5652 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -8,6 +8,7 @@ */ import { useEffect, useRef } from 'react'; +import deepEqual from 'fast-deep-equal'; import { resolveGridRow } from './resolve_grid_row'; import { GridLayoutStateManager, GridPanelData } from './types'; @@ -121,6 +122,7 @@ export const useGridLayoutEvents = ({ maxColumn ); const targetRow = Math.max(Math.round(localYCoordinate / (rowHeight + gutterSize)), 0); + const requestedGridData = { ...currentGridData }; if (isResize) { requestedGridData.width = Math.max(targetColumn - requestedGridData.column, 1); @@ -154,8 +156,9 @@ export const useGridLayoutEvents = ({ const resolvedOriginGrid = resolveGridRow(originGrid); nextLayout[lastRowIndex] = resolvedOriginGrid; } - - gridLayout$.next(nextLayout); + if (!deepEqual(currentLayout, nextLayout)) { + gridLayout$.next(nextLayout); + } } }; 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 cdb99a9ebbfd0..fe657ae253107 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -7,10 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { i18n } from '@kbn/i18n'; import { useEffect, useMemo, useRef } from 'react'; -import { BehaviorSubject, combineLatest, debounceTime, map, retry } from 'rxjs'; +import { BehaviorSubject, debounceTime } from 'rxjs'; + import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled'; + import { ActivePanel, GridLayoutData, @@ -43,10 +44,14 @@ export const useGridLayoutState = ({ ...gridSettings, columnPixelWidth: 0, }); + const panelIds$ = new BehaviorSubject( + initialLayout.map(({ panels }) => Object.keys(panels)) + ); return { rowRefs, panelRefs, + panelIds$, gridLayout$, activePanel$, gridDimensions$, @@ -67,117 +72,12 @@ export const useGridLayoutState = ({ const columnPixelWidth = (elementWidth - gridSettings.gutterSize * (gridSettings.columnCount - 1)) / gridSettings.columnCount; - gridLayoutStateManager.runtimeSettings$.next({ ...gridSettings, columnPixelWidth }); - }); - - /** - * on layout change, update the styles of every panel so that it renders as expected - */ - const onLayoutChangeSubscription = combineLatest([ - gridLayoutStateManager.gridLayout$, - gridLayoutStateManager.activePanel$, - ]) - .pipe( - map(([gridLayout, activePanel]) => { - // wait for all panel refs to be ready before continuing - for (let rowIndex = 0; rowIndex < gridLayout.length; rowIndex++) { - const currentRow = gridLayout[rowIndex]; - Object.keys(currentRow.panels).forEach((key) => { - const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][key]; - if (!panelRef && !currentRow.isCollapsed) { - throw new Error( - i18n.translate('kbnGridLayout.panelRefNotFoundError', { - defaultMessage: 'Panel reference does not exist', // the retry will catch this error - }) - ); - } - }); - } - return { gridLayout, activePanel }; - }), - retry({ delay: 10 }) // retry until panel references all exist - ) - .subscribe(({ gridLayout, activePanel }) => { - const runtimeSettings = gridLayoutStateManager.runtimeSettings$.getValue(); - const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue(); - for (let rowIndex = 0; rowIndex < gridLayout.length; rowIndex++) { - if (activePanel && rowIndex !== currentInteractionEvent?.targetRowIndex) { - /** - * If there is an interaction event happening but the current row is not being targetted, it - * does not need to be re-rendered; so, skip setting the panel styles of this row. - * - * If there is **no** interaction event, then this is the initial render so the styles of every - * panel should be initialized; so, don't skip setting the panel styles. - */ - continue; - } - - // re-render the targetted row - const currentRow = gridLayout[rowIndex]; - Object.keys(currentRow.panels).forEach((key) => { - const panel = currentRow.panels[key]; - const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][key]; - if (!panelRef) { - return; - } - - const isResize = currentInteractionEvent?.type === 'resize'; - if (panel.id === activePanel?.id) { - // if the current panel is active, give it fixed positioning depending on the interaction event - const { position: draggingPosition } = activePanel; - - if (isResize) { - // if the current panel is being resized, ensure it is not shrunk past the size of a single cell - panelRef.style.width = `${Math.max( - draggingPosition.right - draggingPosition.left, - runtimeSettings.columnPixelWidth - )}px`; - panelRef.style.height = `${Math.max( - draggingPosition.bottom - draggingPosition.top, - runtimeSettings.rowHeight - )}px`; - - // undo any "lock to grid" styles **except** for the top left corner, which stays locked - panelRef.style.gridColumnStart = `${panel.column + 1}`; - panelRef.style.gridRowStart = `${panel.row + 1}`; - panelRef.style.gridColumnEnd = ``; - panelRef.style.gridRowEnd = ``; - } else { - // if the current panel is being dragged, render it with a fixed position + size - panelRef.style.position = 'fixed'; - panelRef.style.left = `${draggingPosition.left}px`; - panelRef.style.top = `${draggingPosition.top}px`; - panelRef.style.width = `${draggingPosition.right - draggingPosition.left}px`; - panelRef.style.height = `${draggingPosition.bottom - draggingPosition.top}px`; - - // undo any "lock to grid" styles - panelRef.style.gridColumnStart = ``; - panelRef.style.gridRowStart = ``; - panelRef.style.gridColumnEnd = ``; - panelRef.style.gridRowEnd = ``; - } - } else { - // if the panel is not being dragged and/or resized, undo any fixed position styles - panelRef.style.position = ''; - panelRef.style.left = ``; - panelRef.style.top = ``; - panelRef.style.width = ``; - panelRef.style.height = ``; - - // and render the panel locked to the grid - panelRef.style.gridColumnStart = `${panel.column + 1}`; - panelRef.style.gridColumnEnd = `${panel.column + 1 + panel.width}`; - panelRef.style.gridRowStart = `${panel.row + 1}`; - panelRef.style.gridRowEnd = `${panel.row + 1 + panel.height}`; - } - }); - } + gridLayoutStateManager.runtimeSettings$.next({ ...gridSettings, columnPixelWidth }); }); return () => { resizeSubscription.unsubscribe(); - onLayoutChangeSubscription.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json index 5a1888db0dc64..f0dd3232a42d5 100644 --- a/packages/kbn-grid-layout/tsconfig.json +++ b/packages/kbn-grid-layout/tsconfig.json @@ -17,7 +17,6 @@ "target/**/*" ], "kbn_references": [ - "@kbn/presentation-publishing", "@kbn/ui-theme", "@kbn/i18n", ]