[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>
This commit is contained in:
Hannah Mudge 2024-11-21 10:27:56 -07:00 committed by GitHub
parent cba99de545
commit 549532240c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 525 additions and 438 deletions

View file

@ -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<MockSerializedDashboardState>(getSerializedDashboardState());
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
dashboardInputToGridLayout(savedState.current)
);
const [layoutKey, setLayoutKey] = useState<string>(uuidv4());
const [gridLayoutApi, setGridLayoutApi] = useState<GridLayoutApi | null>();
const savedLayout = useRef<GridLayoutData>(getSerializedGridLayout());
const currentLayout = useRef<GridLayoutData>(savedLayout.current);
const mockDashboardApi = useMockDashboardApi({ savedState: savedState.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 (
<>
<div style={{ padding: 8 }}>{id}</div>
<EuiButtonEmpty
onClick={() => {
mockDashboardApi.removePanel(id);
}}
>
{i18n.translate('examples.gridExample.deletePanelButton', {
defaultMessage: 'Delete panel',
})}
</EuiButtonEmpty>
<EuiButtonEmpty
onClick={async () => {
const newPanelId = await getPanelId({
coreStart,
suggestion: id,
});
if (newPanelId) mockDashboardApi.replacePanel(id, newPanelId);
}}
>
{i18n.translate('examples.gridExample.replacePanelButton', {
defaultMessage: 'Replace panel',
})}
</EuiButtonEmpty>
</>
);
},
[coreStart, mockDashboardApi]
);
return (
<EuiProvider>
@ -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 }) => {
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={() => {
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 }) => {
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => {
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 }) => {
</EuiFlexGroup>
<EuiSpacer size="m" />
<GridLayout
key={layoutKey}
layout={currentLayout}
gridSettings={{
gutterSize: DASHBOARD_MARGIN_SIZE,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
}}
renderPanelContents={renderBasicPanel}
onLayoutChange={(newLayout) => {
currentLayout.current = cloneDeep(newLayout);
setHasUnsavedChanges(!isLayoutEqual(savedLayout.current, newLayout));
}}
ref={setGridLayoutApi}
renderPanelContents={(id) => {
return (
<>
<div style={{ padding: 8 }}>{id}</div>
<EuiButtonEmpty
onClick={() => {
gridLayoutApi?.removePanel(id);
}}
>
{i18n.translate('examples.gridExample.deletePanelButton', {
defaultMessage: 'Delete panel',
})}
</EuiButtonEmpty>
<EuiButtonEmpty
onClick={async () => {
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',
})}
</EuiButtonEmpty>
</>
);
}}
getCreationOptions={() => {
return {
gridSettings: {
gutterSize: DASHBOARD_MARGIN_SIZE,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
},
initialLayout: cloneDeep(currentLayout.current),
};
const { panels, rows } = gridLayoutToDashboardPanelMap(newLayout);
mockDashboardApi.panels$.next(panels);
mockDashboardApi.rows$.next(rows);
}}
/>
</EuiPageTemplate.Section>

View file

@ -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 },
],
};

View file

@ -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;
}

View file

@ -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<MockedDashboardPanelMap>(savedState.panels),
rows$: new BehaviorSubject<MockedDashboardRowMap>(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;
};

View file

@ -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;
};

View file

@ -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<GridLayoutApi, GridLayoutProps>(
({ getCreationOptions, renderPanelContents, onLayoutChange }, ref) => {
const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
getCreationOptions,
});
useGridLayoutEvents({ gridLayoutStateManager });
export const GridLayout = ({
layout,
gridSettings,
renderPanelContents,
onLayoutChange,
}: GridLayoutProps) => {
const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
layout,
gridSettings,
});
useGridLayoutEvents({ gridLayoutStateManager });
const gridLayoutApi = useGridLayoutApi({ gridLayoutStateManager });
useImperativeHandle(ref, () => gridLayoutApi, [gridLayoutApi]);
const [rowCount, setRowCount] = useState<number>(
gridLayoutStateManager.gridLayout$.getValue().length
);
const [rowCount, setRowCount] = useState<number>(
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
}, []);
/**
* Memoize row children components to prevent unnecessary re-renders
*/
const children = useMemo(() => {
return Array.from({ length: rowCount }, (_, rowIndex) => {
return (
<GridRow
key={rowIndex}
rowIndex={rowIndex}
renderPanelContents={renderPanelContents}
gridLayoutStateManager={gridLayoutStateManager}
toggleIsCollapsed={() => {
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 () => {
rowCountSubscription.unsubscribe();
onLayoutChangeSubscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
<div
ref={(divElement) => {
setDimensionsRef(divElement);
}}
>
{Array.from({ length: rowCount }, (_, rowIndex) => {
return (
<GridRow
key={rowIndex}
rowIndex={rowIndex}
renderPanelContents={renderPanelContents}
gridLayoutStateManager={gridLayoutStateManager}
toggleIsCollapsed={() => {
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)}
/>
);
})}
</div>
</GridHeightSmoother>
</>
);
}
);
return (
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
<div
ref={(divElement) => {
setDimensionsRef(divElement);
}}
>
{children}
</div>
</GridHeightSmoother>
);
};

View file

@ -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 (
<div ref={panelRef} css={initialStyles}>
<EuiPanel
hasShadow={false}
hasBorder={true}
css={css`
padding: 0;
position: relative;
height: 100%;
`}
>
{/* drag handle */}
<div
className="dragHandle"
<>
<div ref={panelRef} css={initialStyles}>
<EuiPanel
hasShadow={false}
hasBorder={true}
css={css`
opacity: 0;
display: flex;
cursor: move;
position: absolute;
align-items: center;
justify-content: center;
top: -${euiThemeVars.euiSizeL};
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
z-index: ${euiThemeVars.euiZLevel3};
margin-left: ${euiThemeVars.euiSizeS};
border: 1px solid ${euiTheme.border.color};
background-color: ${euiTheme.colors.emptyShade};
border-radius: ${euiThemeVars.euiBorderRadius} ${euiThemeVars.euiBorderRadius} 0 0;
&:hover {
cursor: grab;
opacity: 1 !important;
}
&:active {
cursor: grabbing;
opacity: 1 !important;
}
padding: 0;
position: relative;
height: 100%;
`}
onMouseDown={(e) => interactionStart('drag', e)}
onMouseUp={(e) => interactionStart('drop', e)}
>
<EuiIcon type="grabOmnidirectional" />
</div>
{/* Resize handle */}
<div
className="resizeHandle"
onMouseDown={(e) => 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;
}
`}
/>
<div
css={css`
${euiFullHeight()}
${useEuiOverflowScroll('y', false)}
{/* drag handle */}
<div
className="dragHandle"
css={css`
opacity: 0;
display: flex;
cursor: move;
position: absolute;
align-items: center;
justify-content: center;
top: -${euiThemeVars.euiSizeL};
width: ${euiThemeVars.euiSizeL};
height: ${euiThemeVars.euiSizeL};
z-index: ${euiThemeVars.euiZLevel3};
margin-left: ${euiThemeVars.euiSizeS};
border: 1px solid ${euiTheme.border.color};
background-color: ${euiTheme.colors.emptyShade};
border-radius: ${euiThemeVars.euiBorderRadius} ${euiThemeVars.euiBorderRadius} 0 0;
&:hover {
cursor: grab;
opacity: 1 !important;
}
&:active {
cursor: grabbing;
opacity: 1 !important;
}
`}
onMouseDown={(e) => interactionStart('drag', e)}
onMouseUp={(e) => interactionStart('drop', e)}
>
<EuiIcon type="grabOmnidirectional" />
</div>
{/* Resize handle */}
<div
className="resizeHandle"
onMouseDown={(e) => 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;
}
`}
/>
<div
css={css`
${euiFullHeight()}
${useEuiOverflowScroll('y', false)}
${useEuiOverflowScroll('x', false)}
`}
>
{renderPanelContents(panelId)}
</div>
</EuiPanel>
</div>
`}
>
{panelContents}
</div>
</EuiPanel>
</div>
</>
);
}
);

View file

@ -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) => (
<GridPanel
key={panelId}
panelId={panelId}
rowIndex={rowIndex}
gridLayoutStateManager={gridLayoutStateManager}
renderPanelContents={renderPanelContents}
interactionStart={(type, e) => {
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) => (
<GridPanel
key={panelId}
panelId={panelId}
rowIndex={rowIndex}
gridLayoutStateManager={gridLayoutStateManager}
renderPanelContents={renderPanelContents}
interactionStart={(type, e) => {
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}
<DragPreview rowIndex={rowIndex} gridLayoutStateManager={gridLayoutStateManager} />
</div>
)}

View file

@ -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. */

View file

@ -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;
};

View file

@ -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<Array<HTMLDivElement | null>>([]);
const panelRefs = useRef<Array<{ [id: string]: HTMLDivElement | null }>>([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const { initialLayout, gridSettings } = useMemo(() => getCreationOptions(), []);
const gridLayoutStateManager = useMemo(() => {
const gridLayout$ = new BehaviorSubject<GridLayoutData>(initialLayout);
const gridLayout$ = new BehaviorSubject<GridLayoutData>(layout);
const gridDimensions$ = new BehaviorSubject<ObservedSize>({ width: 0, height: 0 });
const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined);
const activePanel$ = new BehaviorSubject<ActivePanel | undefined>(undefined);
@ -45,7 +44,7 @@ export const useGridLayoutState = ({
columnPixelWidth: 0,
});
const panelIds$ = new BehaviorSubject<string[][]>(
initialLayout.map(({ panels }) => Object.keys(panels))
layout.map(({ panels }) => Object.keys(panels))
);
return {

View file

@ -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';

View file

@ -19,6 +19,5 @@
"kbn_references": [
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/utility-types",
]
}