From bc11868368a4981d74f26e74f6b94e6b4ea16a6e Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 21 Nov 2024 10:27:56 -0700 Subject: [PATCH] [Dashboard] [Collapsable Panels] Switch to using props (#200793) Closes https://github.com/elastic/kibana/issues/200090 ## Summary This PR migrates the `GridLayout` component a more traditional React design using **props** rather than providing an API. This change serves two purposes: 1. It makes the eventual Dashboard migration easier, since it is more similar to `react-grid-layout`'s implementation 3. It makes the `GridLayout` component less opinionated by moving the logic for panel management (i.e. panel placement, etc) to the parent component. I tried to keep efficiency in mind for this comparison, and ensured that we are still keeping the number of rerenders **o a minimum**. This PR should not introduce **any** extra renders in comparison to the API version. ### Checklist - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks There are no risks to this PR, since all work is contained in the `examples` plugin. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- examples/grid_example/public/app.tsx | 153 +++++++------ .../public/serialized_grid_layout.ts | 55 +++-- examples/grid_example/public/types.ts | 27 +++ .../public/use_mock_dashboard_api.tsx | 78 +++++++ examples/grid_example/public/utils.ts | 62 ++++++ packages/kbn-grid-layout/grid/grid_layout.tsx | 202 ++++++++++-------- packages/kbn-grid-layout/grid/grid_panel.tsx | 155 +++++++------- packages/kbn-grid-layout/grid/grid_row.tsx | 86 ++++---- packages/kbn-grid-layout/grid/types.ts | 14 -- .../grid/use_grid_layout_api.ts | 109 ---------- .../grid/use_grid_layout_state.ts | 13 +- packages/kbn-grid-layout/index.ts | 8 +- packages/kbn-grid-layout/tsconfig.json | 1 - 13 files changed, 525 insertions(+), 438 deletions(-) create mode 100644 examples/grid_example/public/types.ts create mode 100644 examples/grid_example/public/use_mock_dashboard_api.tsx create mode 100644 examples/grid_example/public/utils.ts delete mode 100644 packages/kbn-grid-layout/grid/use_grid_layout_api.ts diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 0e73a76d790fd..f144daa29f1ab 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -7,9 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { cloneDeep } from 'lodash'; -import React, { useRef, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; +import { combineLatest, debounceTime } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { @@ -25,29 +26,77 @@ import { } from '@elastic/eui'; import { AppMountParameters } from '@kbn/core-application-browser'; import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { GridLayout, GridLayoutData, isLayoutEqual, type GridLayoutApi } from '@kbn/grid-layout'; +import { GridLayout, GridLayoutData } from '@kbn/grid-layout'; import { i18n } from '@kbn/i18n'; import { getPanelId } from './get_panel_id'; import { - clearSerializedGridLayout, - getSerializedGridLayout, + clearSerializedDashboardState, + getSerializedDashboardState, setSerializedGridLayout, } from './serialized_grid_layout'; +import { MockSerializedDashboardState } from './types'; +import { useMockDashboardApi } from './use_mock_dashboard_api'; +import { dashboardInputToGridLayout, gridLayoutToDashboardPanelMap } from './utils'; const DASHBOARD_MARGIN_SIZE = 8; const DASHBOARD_GRID_HEIGHT = 20; const DASHBOARD_GRID_COLUMN_COUNT = 48; -const DEFAULT_PANEL_HEIGHT = 15; -const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { + const savedState = useRef(getSerializedDashboardState()); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [currentLayout, setCurrentLayout] = useState( + dashboardInputToGridLayout(savedState.current) + ); + + const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current }); - const [layoutKey, setLayoutKey] = useState(uuidv4()); - const [gridLayoutApi, setGridLayoutApi] = useState(); - const savedLayout = useRef(getSerializedGridLayout()); - const currentLayout = useRef(savedLayout.current); + useEffect(() => { + combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$]) + .pipe(debounceTime(0)) // debounce to avoid subscribe being called twice when both panels$ and rows$ publish + .subscribe(([panels, rows]) => { + const hasChanges = !( + deepEqual(panels, savedState.current.panels) && deepEqual(rows, savedState.current.rows) + ); + setHasUnsavedChanges(hasChanges); + setCurrentLayout(dashboardInputToGridLayout({ panels, rows })); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const renderBasicPanel = useCallback( + (id: string) => { + return ( + <> +
{id}
+ { + mockDashboardApi.removePanel(id); + }} + > + {i18n.translate('examples.gridExample.deletePanelButton', { + defaultMessage: 'Delete panel', + })} + + { + const newPanelId = await getPanelId({ + coreStart, + suggestion: id, + }); + if (newPanelId) mockDashboardApi.replacePanel(id, newPanelId); + }} + > + {i18n.translate('examples.gridExample.replacePanelButton', { + defaultMessage: 'Replace panel', + })} + + + ); + }, + [coreStart, mockDashboardApi] + ); return ( @@ -69,7 +118,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { color="accent" size="s" onClick={() => { - clearSerializedGridLayout(); + clearSerializedDashboardState(); window.location.reload(); }} > @@ -85,13 +134,9 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { onClick={async () => { const panelId = await getPanelId({ coreStart, - suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`, + suggestion: uuidv4(), }); - if (panelId) - gridLayoutApi?.addPanel(panelId, { - width: DEFAULT_PANEL_WIDTH, - height: DEFAULT_PANEL_HEIGHT, - }); + if (panelId) mockDashboardApi.addNewPanel({ id: panelId }); }} > {i18n.translate('examples.gridExample.addPanelButton', { @@ -113,9 +158,9 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { { - currentLayout.current = cloneDeep(savedLayout.current); - setHasUnsavedChanges(false); - setLayoutKey(uuidv4()); // force remount of grid + const { panels, rows } = savedState.current; + mockDashboardApi.panels$.next(panels); + mockDashboardApi.rows$.next(rows); }} > {i18n.translate('examples.gridExample.resetLayoutButton', { @@ -126,12 +171,13 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { { - if (gridLayoutApi) { - const layoutToSave = gridLayoutApi.serializeState(); - setSerializedGridLayout(layoutToSave); - savedLayout.current = layoutToSave; - setHasUnsavedChanges(false); - } + const newSavedState = { + panels: mockDashboardApi.panels$.getValue(), + rows: mockDashboardApi.rows$.getValue(), + }; + savedState.current = newSavedState; + setHasUnsavedChanges(false); + setSerializedGridLayout(newSavedState); }} > {i18n.translate('examples.gridExample.saveLayoutButton', { @@ -144,50 +190,17 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { { - currentLayout.current = cloneDeep(newLayout); - setHasUnsavedChanges(!isLayoutEqual(savedLayout.current, newLayout)); + layout={currentLayout} + gridSettings={{ + gutterSize: DASHBOARD_MARGIN_SIZE, + rowHeight: DASHBOARD_GRID_HEIGHT, + columnCount: DASHBOARD_GRID_COLUMN_COUNT, }} - ref={setGridLayoutApi} - renderPanelContents={(id) => { - return ( - <> -
{id}
- { - gridLayoutApi?.removePanel(id); - }} - > - {i18n.translate('examples.gridExample.deletePanelButton', { - defaultMessage: 'Delete panel', - })} - - { - const newPanelId = await getPanelId({ - coreStart, - suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`, - }); - if (newPanelId) gridLayoutApi?.replacePanel(id, newPanelId); - }} - > - {i18n.translate('examples.gridExample.replacePanelButton', { - defaultMessage: 'Replace panel', - })} - - - ); - }} - getCreationOptions={() => { - return { - gridSettings: { - gutterSize: DASHBOARD_MARGIN_SIZE, - rowHeight: DASHBOARD_GRID_HEIGHT, - columnCount: DASHBOARD_GRID_COLUMN_COUNT, - }, - initialLayout: cloneDeep(currentLayout.current), - }; + renderPanelContents={renderBasicPanel} + onLayoutChange={(newLayout) => { + const { panels, rows } = gridLayoutToDashboardPanelMap(newLayout); + mockDashboardApi.panels$.next(panels); + mockDashboardApi.rows$.next(rows); }} /> diff --git a/examples/grid_example/public/serialized_grid_layout.ts b/examples/grid_example/public/serialized_grid_layout.ts index 2bb20052398f8..3e40380d91ac3 100644 --- a/examples/grid_example/public/serialized_grid_layout.ts +++ b/examples/grid_example/public/serialized_grid_layout.ts @@ -7,46 +7,39 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { type GridLayoutData } from '@kbn/grid-layout'; +import { MockSerializedDashboardState } from './types'; const STATE_SESSION_STORAGE_KEY = 'kibana.examples.gridExample.state'; -export function clearSerializedGridLayout() { +export function clearSerializedDashboardState() { sessionStorage.removeItem(STATE_SESSION_STORAGE_KEY); } -export function getSerializedGridLayout(): GridLayoutData { +export function getSerializedDashboardState(): MockSerializedDashboardState { const serializedStateJSON = sessionStorage.getItem(STATE_SESSION_STORAGE_KEY); - return serializedStateJSON ? JSON.parse(serializedStateJSON) : initialGridLayout; + return serializedStateJSON ? JSON.parse(serializedStateJSON) : initialState; } -export function setSerializedGridLayout(layout: GridLayoutData) { - sessionStorage.setItem(STATE_SESSION_STORAGE_KEY, JSON.stringify(layout)); +export function setSerializedGridLayout(state: MockSerializedDashboardState) { + sessionStorage.setItem(STATE_SESSION_STORAGE_KEY, JSON.stringify(state)); } -const initialGridLayout: GridLayoutData = [ - { - title: 'Large section', - isCollapsed: false, - panels: { - panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' }, - panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' }, - panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' }, - panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' }, - panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' }, - panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' }, - panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' }, - panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' }, - }, +const initialState: MockSerializedDashboardState = { + panels: { + panel1: { id: 'panel1', gridData: { i: 'panel1', x: 0, y: 0, w: 12, h: 6, row: 0 } }, + panel2: { id: 'panel2', gridData: { i: 'panel2', x: 0, y: 6, w: 8, h: 4, row: 0 } }, + panel3: { id: 'panel3', gridData: { i: 'panel3', x: 8, y: 6, w: 12, h: 4, row: 0 } }, + panel4: { id: 'panel4', gridData: { i: 'panel4', x: 0, y: 10, w: 48, h: 4, row: 0 } }, + panel5: { id: 'panel5', gridData: { i: 'panel5', x: 12, y: 0, w: 36, h: 6, row: 0 } }, + panel6: { id: 'panel6', gridData: { i: 'panel6', x: 24, y: 6, w: 24, h: 4, row: 0 } }, + panel7: { id: 'panel7', gridData: { i: 'panel7', x: 20, y: 6, w: 4, h: 2, row: 0 } }, + panel8: { id: 'panel8', gridData: { i: 'panel8', x: 20, y: 8, w: 4, h: 2, row: 0 } }, + panel9: { id: 'panel9', gridData: { i: 'panel9', x: 0, y: 0, w: 12, h: 16, row: 1 } }, + panel10: { id: 'panel10', gridData: { i: 'panel10', x: 24, y: 0, w: 12, h: 6, row: 2 } }, }, - { - title: 'Small section', - isCollapsed: false, - panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } }, - }, - { - title: 'Another small section', - isCollapsed: false, - panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } }, - }, -]; + rows: [ + { title: 'Large section', collapsed: false }, + { title: 'Small section', collapsed: false }, + { title: 'Another small section', collapsed: false }, + ], +}; diff --git a/examples/grid_example/public/types.ts b/examples/grid_example/public/types.ts new file mode 100644 index 0000000000000..39885e25e7153 --- /dev/null +++ b/examples/grid_example/public/types.ts @@ -0,0 +1,27 @@ +/* + * 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". + */ + +export interface DashboardGridData { + w: number; + h: number; + x: number; + y: number; + i: string; +} + +export interface MockedDashboardPanelMap { + [key: string]: { id: string; gridData: DashboardGridData & { row: number } }; +} + +export type MockedDashboardRowMap = Array<{ title: string; collapsed: boolean }>; + +export interface MockSerializedDashboardState { + panels: MockedDashboardPanelMap; + rows: MockedDashboardRowMap; +} diff --git a/examples/grid_example/public/use_mock_dashboard_api.tsx b/examples/grid_example/public/use_mock_dashboard_api.tsx new file mode 100644 index 0000000000000..8388bd83f2645 --- /dev/null +++ b/examples/grid_example/public/use_mock_dashboard_api.tsx @@ -0,0 +1,78 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { useMemo } from 'react'; +import { BehaviorSubject } from 'rxjs'; + +import { + MockSerializedDashboardState, + MockedDashboardPanelMap, + MockedDashboardRowMap, +} from './types'; + +const DASHBOARD_GRID_COLUMN_COUNT = 48; +const DEFAULT_PANEL_HEIGHT = 15; +const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2; + +export const useMockDashboardApi = ({ + savedState, +}: { + savedState: MockSerializedDashboardState; +}) => { + const mockDashboardApi = useMemo(() => { + return { + viewMode: new BehaviorSubject('edit'), + panels$: new BehaviorSubject(savedState.panels), + rows$: new BehaviorSubject(savedState.rows), + removePanel: (id: string) => { + const panels = { ...mockDashboardApi.panels$.getValue() }; + delete panels[id]; // the grid layout component will handle compacting, if necessary + mockDashboardApi.panels$.next(panels); + }, + replacePanel: (oldId: string, newId: string) => { + const currentPanels = mockDashboardApi.panels$.getValue(); + const otherPanels = { ...currentPanels }; + const oldPanel = currentPanels[oldId]; + delete otherPanels[oldId]; + otherPanels[newId] = { id: newId, gridData: { ...oldPanel.gridData, i: newId } }; + mockDashboardApi.panels$.next(otherPanels); + }, + addNewPanel: ({ id: newId }: { id: string }) => { + // we are only implementing "place at top" here, for demo purposes + const currentPanels = mockDashboardApi.panels$.getValue(); + const otherPanels = { ...currentPanels }; + for (const [id, panel] of Object.entries(currentPanels)) { + const currentPanel = cloneDeep(panel); + currentPanel.gridData.y = currentPanel.gridData.y + DEFAULT_PANEL_HEIGHT; + otherPanels[id] = currentPanel; + } + mockDashboardApi.panels$.next({ + ...otherPanels, + [newId]: { + id: newId, + gridData: { + i: newId, + row: 0, + x: 0, + y: 0, + w: DEFAULT_PANEL_WIDTH, + h: DEFAULT_PANEL_HEIGHT, + }, + }, + }); + }, + canRemovePanels: () => true, + }; + // only run onMount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return mockDashboardApi; +}; diff --git a/examples/grid_example/public/utils.ts b/examples/grid_example/public/utils.ts new file mode 100644 index 0000000000000..5d2dfd0fa3002 --- /dev/null +++ b/examples/grid_example/public/utils.ts @@ -0,0 +1,62 @@ +/* + * 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 { GridLayoutData } from '@kbn/grid-layout'; +import { MockedDashboardPanelMap, MockedDashboardRowMap } from './types'; + +export const gridLayoutToDashboardPanelMap = ( + layout: GridLayoutData +): { panels: MockedDashboardPanelMap; rows: MockedDashboardRowMap } => { + const panels: MockedDashboardPanelMap = {}; + const rows: MockedDashboardRowMap = []; + layout.forEach((row, rowIndex) => { + rows.push({ title: row.title, collapsed: row.isCollapsed }); + Object.values(row.panels).forEach((panelGridData) => { + panels[panelGridData.id] = { + id: panelGridData.id, + gridData: { + i: panelGridData.id, + y: panelGridData.row, + x: panelGridData.column, + w: panelGridData.width, + h: panelGridData.height, + row: rowIndex, + }, + }; + }); + }); + return { panels, rows }; +}; + +export const dashboardInputToGridLayout = ({ + panels, + rows, +}: { + panels: MockedDashboardPanelMap; + rows: MockedDashboardRowMap; +}): GridLayoutData => { + const layout: GridLayoutData = []; + + rows.forEach((row) => { + layout.push({ title: row.title, isCollapsed: row.collapsed, panels: {} }); + }); + + Object.keys(panels).forEach((panelId) => { + const gridData = panels[panelId].gridData; + layout[gridData.row].panels[panelId] = { + id: panelId, + row: gridData.y, + column: gridData.x, + width: gridData.w, + height: gridData.h, + }; + }); + + return layout; +}; diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index c3f9521503107..fc67c5b134606 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -8,109 +8,139 @@ */ import { cloneDeep } from 'lodash'; -import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip } from 'rxjs'; import { GridHeightSmoother } from './grid_height_smoother'; import { GridRow } from './grid_row'; -import { GridLayoutApi, GridLayoutData, GridSettings } from './types'; -import { useGridLayoutApi } from './use_grid_layout_api'; +import { GridLayoutData, GridSettings } from './types'; import { useGridLayoutEvents } from './use_grid_layout_events'; import { useGridLayoutState } from './use_grid_layout_state'; import { isLayoutEqual } from './utils/equality_checks'; +import { compactGridRow } from './utils/resolve_grid_row'; interface GridLayoutProps { - getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings }; + layout: GridLayoutData; + gridSettings: GridSettings; renderPanelContents: (panelId: string) => React.ReactNode; onLayoutChange: (newLayout: GridLayoutData) => void; } -export const GridLayout = forwardRef( - ({ getCreationOptions, renderPanelContents, onLayoutChange }, ref) => { - const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({ - getCreationOptions, - }); - useGridLayoutEvents({ gridLayoutStateManager }); - - const gridLayoutApi = useGridLayoutApi({ gridLayoutStateManager }); - useImperativeHandle(ref, () => gridLayoutApi, [gridLayoutApi]); +export const GridLayout = ({ + layout, + gridSettings, + renderPanelContents, + onLayoutChange, +}: GridLayoutProps) => { + const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({ + layout, + gridSettings, + }); + useGridLayoutEvents({ gridLayoutStateManager }); - const [rowCount, setRowCount] = useState( - gridLayoutStateManager.gridLayout$.getValue().length - ); + const [rowCount, setRowCount] = useState( + gridLayoutStateManager.gridLayout$.getValue().length + ); - useEffect(() => { + /** + * Update the `gridLayout$` behaviour subject in response to the `layout` prop changing + */ + useEffect(() => { + if (!isLayoutEqual(layout, gridLayoutStateManager.gridLayout$.getValue())) { + const newLayout = cloneDeep(layout); /** - * 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. + * the layout sent in as a prop is not guaranteed to be valid (i.e it may have floating panels) - + * so, we need to loop through each row and ensure it is compacted */ - 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); - }); + newLayout.forEach((row, rowIndex) => { + newLayout[rowIndex] = compactGridRow(row); + }); + gridLayoutStateManager.gridLayout$.next(newLayout); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout]); - const onLayoutChangeSubscription = combineLatest([ - gridLayoutStateManager.gridLayout$, - gridLayoutStateManager.interactionEvent$, - ]) - .pipe( - // if an interaction event is happening, then ignore any "draft" layout changes - filter(([_, event]) => !Boolean(event)), - // once no interaction event, create pairs of "old" and "new" layouts for comparison - map(([layout]) => layout), - pairwise() - ) - .subscribe(([layoutBefore, layoutAfter]) => { - if (!isLayoutEqual(layoutBefore, layoutAfter)) { - onLayoutChange(layoutAfter); - } - }); + /** + * Set up subscriptions + */ + 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); + }); + + const onLayoutChangeSubscription = combineLatest([ + gridLayoutStateManager.gridLayout$, + gridLayoutStateManager.interactionEvent$, + ]) + .pipe( + // if an interaction event is happening, then ignore any "draft" layout changes + filter(([_, event]) => !Boolean(event)), + // once no interaction event, create pairs of "old" and "new" layouts for comparison + map(([newLayout]) => newLayout), + pairwise() + ) + .subscribe(([layoutBefore, layoutAfter]) => { + if (!isLayoutEqual(layoutBefore, layoutAfter)) { + onLayoutChange(layoutAfter); + } + }); - return () => { - rowCountSubscription.unsubscribe(); - onLayoutChangeSubscription.unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + return () => { + rowCountSubscription.unsubscribe(); + onLayoutChangeSubscription.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** + * Memoize row children components to prevent unnecessary re-renders + */ + const children = useMemo(() => { + return Array.from({ length: rowCount }, (_, rowIndex) => { + return ( + { + const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); + newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; + gridLayoutStateManager.gridLayout$.next(newLayout); + }} + setInteractionEvent={(nextInteractionEvent) => { + if (!nextInteractionEvent) { + gridLayoutStateManager.activePanel$.next(undefined); + } + gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent); + }} + ref={(element: HTMLDivElement | null) => + (gridLayoutStateManager.rowRefs.current[rowIndex] = element) + } + /> + ); + }); + }, [rowCount, gridLayoutStateManager, renderPanelContents]); - return ( - <> - -
{ - setDimensionsRef(divElement); - }} - > - {Array.from({ length: rowCount }, (_, rowIndex) => { - return ( - { - const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); - newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; - gridLayoutStateManager.gridLayout$.next(newLayout); - }} - setInteractionEvent={(nextInteractionEvent) => { - if (!nextInteractionEvent) { - gridLayoutStateManager.activePanel$.next(undefined); - } - gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent); - }} - ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)} - /> - ); - })} -
-
- - ); - } -); + return ( + +
{ + setDimensionsRef(divElement); + }} + > + {children} +
+
+ ); +}; diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel.tsx index 822cb2328c4a5..a44a321a7b18d 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel.tsx @@ -129,84 +129,93 @@ export const GridPanel = forwardRef< [] ); + /** + * Memoize panel contents to prevent unnecessary re-renders + */ + const panelContents = useMemo(() => { + return renderPanelContents(panelId); + }, [panelId, renderPanelContents]); + 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 { - opacity: 1; - background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; - cursor: se-resize; - } - `} - /> -
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 { + opacity: 1; + background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; + cursor: se-resize; + } + `} + /> +
- {renderPanelContents(panelId)} -
- -
+ `} + > + {panelContents} +
+ +
+ ); } ); diff --git a/packages/kbn-grid-layout/grid/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row.tsx index ff97b32efcdbc..01466a440b4cd 100644 --- a/packages/kbn-grid-layout/grid/grid_row.tsx +++ b/packages/kbn-grid-layout/grid/grid_row.tsx @@ -155,6 +155,51 @@ export const GridRow = forwardRef< [rowIndex] ); + /** + * Memoize panel children components to prevent unnecessary re-renders + */ + const children = useMemo(() => { + return panelIds.map((panelId) => ( + { + e.preventDefault(); + e.stopPropagation(); + const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; + if (!panelRef) return; + + const panelRect = panelRef.getBoundingClientRect(); + if (type === 'drop') { + setInteractionEvent(undefined); + } else { + setInteractionEvent({ + type, + id: panelId, + panelDiv: panelRef, + targetRowIndex: rowIndex, + mouseOffsets: { + top: e.clientY - panelRect.top, + left: e.clientX - panelRect.left, + right: e.clientX - panelRect.right, + bottom: e.clientY - panelRect.bottom, + }, + }); + } + }} + ref={(element) => { + if (!gridLayoutStateManager.panelRefs.current[rowIndex]) { + gridLayoutStateManager.panelRefs.current[rowIndex] = {}; + } + gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element; + }} + /> + )); + }, [panelIds, rowIndex, gridLayoutStateManager, renderPanelContents, setInteractionEvent]); + return ( <> {rowIndex !== 0 && ( @@ -186,46 +231,7 @@ export const GridRow = forwardRef< ${initialStyles}; `} > - {panelIds.map((panelId) => ( - { - e.preventDefault(); - e.stopPropagation(); - const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; - if (!panelRef) return; - - const panelRect = panelRef.getBoundingClientRect(); - if (type === 'drop') { - setInteractionEvent(undefined); - } else { - setInteractionEvent({ - type, - id: panelId, - panelDiv: panelRef, - targetRowIndex: rowIndex, - mouseOffsets: { - top: e.clientY - panelRect.top, - left: e.clientX - panelRect.left, - right: e.clientX - panelRect.right, - bottom: e.clientY - panelRect.bottom, - }, - }); - } - }} - ref={(element) => { - if (!gridLayoutStateManager.panelRefs.current[rowIndex]) { - gridLayoutStateManager.panelRefs.current[rowIndex] = {}; - } - gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element; - }} - /> - ))} - + {children}
)} diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts index 004669e69b186..3979b86f05a09 100644 --- a/packages/kbn-grid-layout/grid/types.ts +++ b/packages/kbn-grid-layout/grid/types.ts @@ -10,8 +10,6 @@ import { BehaviorSubject } from 'rxjs'; import type { ObservedSize } from 'use-resize-observer/polyfilled'; -import { SerializableRecord } from '@kbn/utility-types'; - export interface GridCoordinate { column: number; row: number; @@ -106,18 +104,6 @@ export interface PanelInteractionEvent { }; } -/** - * The external API provided through the GridLayout component - */ -export interface GridLayoutApi { - addPanel: (panelId: string, placementSettings: PanelPlacementSettings) => void; - removePanel: (panelId: string) => void; - replacePanel: (oldPanelId: string, newPanelId: string) => void; - - getPanelCount: () => number; - serializeState: () => GridLayoutData & SerializableRecord; -} - // TODO: Remove from Dashboard plugin as part of https://github.com/elastic/kibana/issues/190446 export enum PanelPlacementStrategy { /** Place on the very top of the grid layout, add the height of this panel to all other panels. */ diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_api.ts b/packages/kbn-grid-layout/grid/use_grid_layout_api.ts deleted file mode 100644 index 1a950ee934174..0000000000000 --- a/packages/kbn-grid-layout/grid/use_grid_layout_api.ts +++ /dev/null @@ -1,109 +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 { cloneDeep } from 'lodash'; - -import { SerializableRecord } from '@kbn/utility-types'; - -import { GridLayoutApi, GridLayoutData, GridLayoutStateManager } from './types'; -import { compactGridRow } from './utils/resolve_grid_row'; -import { runPanelPlacementStrategy } from './utils/run_panel_placement'; - -export const useGridLayoutApi = ({ - gridLayoutStateManager, -}: { - gridLayoutStateManager: GridLayoutStateManager; -}): GridLayoutApi => { - const api: GridLayoutApi = useMemo(() => { - return { - addPanel: (panelId, placementSettings) => { - const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); - const [firstRow, ...rest] = currentLayout; // currently, only adding panels to the first row is supported - const { columnCount: gridColumnCount } = gridLayoutStateManager.runtimeSettings$.getValue(); - const nextRow = runPanelPlacementStrategy( - firstRow, - { - id: panelId, - width: placementSettings.width, - height: placementSettings.height, - }, - gridColumnCount, - placementSettings?.strategy - ); - gridLayoutStateManager.gridLayout$.next([nextRow, ...rest]); - }, - - removePanel: (panelId) => { - const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); - - // find the row where the panel exists and delete it from the corresponding panels object - let rowIndex = 0; - let updatedPanels; - for (rowIndex; rowIndex < currentLayout.length; rowIndex++) { - const row = currentLayout[rowIndex]; - if (Object.keys(row.panels).includes(panelId)) { - updatedPanels = { ...row.panels }; // prevent mutation of original panel object - delete updatedPanels[panelId]; - break; - } - } - - // if the panels were updated (i.e. the panel was successfully found and deleted), update the layout - if (updatedPanels) { - const newLayout = cloneDeep(currentLayout); - newLayout[rowIndex] = compactGridRow({ - ...newLayout[rowIndex], - panels: updatedPanels, - }); - gridLayoutStateManager.gridLayout$.next(newLayout); - } - }, - - replacePanel: (oldPanelId, newPanelId) => { - const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); - - // find the row where the panel exists and update its ID to trigger a re-render - let rowIndex = 0; - let updatedPanels; - for (rowIndex; rowIndex < currentLayout.length; rowIndex++) { - const row = { ...currentLayout[rowIndex] }; - if (Object.keys(row.panels).includes(oldPanelId)) { - updatedPanels = { ...row.panels }; // prevent mutation of original panel object - const oldPanel = updatedPanels[oldPanelId]; - delete updatedPanels[oldPanelId]; - updatedPanels[newPanelId] = { ...oldPanel, id: newPanelId }; - break; - } - } - - // if the panels were updated (i.e. the panel was successfully found and replaced), update the layout - if (updatedPanels) { - const newLayout = cloneDeep(currentLayout); - newLayout[rowIndex].panels = updatedPanels; - gridLayoutStateManager.gridLayout$.next(newLayout); - } - }, - - getPanelCount: () => { - return gridLayoutStateManager.gridLayout$.getValue().reduce((prev, row) => { - return prev + Object.keys(row.panels).length; - }, 0); - }, - - serializeState: () => { - const currentLayout = gridLayoutStateManager.gridLayout$.getValue(); - return cloneDeep(currentLayout) as GridLayoutData & SerializableRecord; - }, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return api; -}; 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 fe657ae253107..a107cbacef2f2 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -22,9 +22,11 @@ import { } from './types'; export const useGridLayoutState = ({ - getCreationOptions, + layout, + gridSettings, }: { - getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings }; + layout: GridLayoutData; + gridSettings: GridSettings; }): { gridLayoutStateManager: GridLayoutStateManager; setDimensionsRef: (instance: HTMLDivElement | null) => void; @@ -32,11 +34,8 @@ export const useGridLayoutState = ({ const rowRefs = useRef>([]); const panelRefs = useRef>([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const { initialLayout, gridSettings } = useMemo(() => getCreationOptions(), []); - const gridLayoutStateManager = useMemo(() => { - const gridLayout$ = new BehaviorSubject(initialLayout); + const gridLayout$ = new BehaviorSubject(layout); const gridDimensions$ = new BehaviorSubject({ width: 0, height: 0 }); const interactionEvent$ = new BehaviorSubject(undefined); const activePanel$ = new BehaviorSubject(undefined); @@ -45,7 +44,7 @@ export const useGridLayoutState = ({ columnPixelWidth: 0, }); const panelIds$ = new BehaviorSubject( - initialLayout.map(({ panels }) => Object.keys(panels)) + layout.map(({ panels }) => Object.keys(panels)) ); return { diff --git a/packages/kbn-grid-layout/index.ts b/packages/kbn-grid-layout/index.ts index 924369fe5ab4c..be46f9d5a7b88 100644 --- a/packages/kbn-grid-layout/index.ts +++ b/packages/kbn-grid-layout/index.ts @@ -8,12 +8,6 @@ */ export { GridLayout } from './grid/grid_layout'; -export type { - GridLayoutApi, - GridLayoutData, - GridPanelData, - GridRowData, - GridSettings, -} from './grid/types'; +export type { GridLayoutData, GridPanelData, GridRowData, GridSettings } from './grid/types'; export { isLayoutEqual } from './grid/utils/equality_checks'; diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json index 14ab38ba76ba9..f0dd3232a42d5 100644 --- a/packages/kbn-grid-layout/tsconfig.json +++ b/packages/kbn-grid-layout/tsconfig.json @@ -19,6 +19,5 @@ "kbn_references": [ "@kbn/ui-theme", "@kbn/i18n", - "@kbn/utility-types", ] }