Skip to content

Commit

Permalink
[Dashboard] [Collapsable Panels] Switch to using props (#200793)
Browse files Browse the repository at this point in the history
Closes #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 <[email protected]>
  • Loading branch information
Heenawter and kibanamachine authored Nov 21, 2024
1 parent cba99de commit 5495322
Show file tree
Hide file tree
Showing 13 changed files with 525 additions and 438 deletions.
153 changes: 83 additions & 70 deletions examples/grid_example/public/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 mockDashboardApi = useMockDashboardApi({ savedState: 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);
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>
Expand All @@ -69,7 +118,7 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
color="accent"
size="s"
onClick={() => {
clearSerializedGridLayout();
clearSerializedDashboardState();
window.location.reload();
}}
>
Expand All @@ -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', {
Expand All @@ -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', {
Expand All @@ -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', {
Expand All @@ -144,50 +190,17 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
</EuiFlexGroup>
<EuiSpacer size="m" />
<GridLayout
key={layoutKey}
onLayoutChange={(newLayout) => {
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 (
<>
<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),
};
renderPanelContents={renderBasicPanel}
onLayoutChange={(newLayout) => {
const { panels, rows } = gridLayoutToDashboardPanelMap(newLayout);
mockDashboardApi.panels$.next(panels);
mockDashboardApi.rows$.next(rows);
}}
/>
</EuiPageTemplate.Section>
Expand Down
55 changes: 24 additions & 31 deletions examples/grid_example/public/serialized_grid_layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
};
27 changes: 27 additions & 0 deletions examples/grid_example/public/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
78 changes: 78 additions & 0 deletions examples/grid_example/public/use_mock_dashboard_api.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
Loading

0 comments on commit 5495322

Please sign in to comment.