Skip to content

Commit

Permalink
[8.x] [Dashboard] [Collapsable Panels] Switch to using props (#200793) (
Browse files Browse the repository at this point in the history
#201253)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Dashboard] [Collapsable Panels] Switch to using props
(#200793)](#200793)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Hannah
Mudge","email":"[email protected]"},"sourceCommit":{"committedDate":"2024-11-21T17:27:56Z","message":"[Dashboard]
[Collapsable Panels] Switch to using props (#200793)\n\nCloses
https://github.com/elastic/kibana/issues/200090\r\n\r\n##
Summary\r\n\r\nThis PR migrates the `GridLayout` component a more
traditional React\r\ndesign using **props** rather than providing an
API. This change serves\r\ntwo purposes:\r\n1. It makes the eventual
Dashboard migration easier, since it is more\r\nsimilar to
`react-grid-layout`'s implementation\r\n3. It makes the `GridLayout`
component less opinionated by moving the\r\nlogic for panel management
(i.e. panel placement, etc) to the parent\r\ncomponent.\r\n\r\nI tried
to keep efficiency in mind for this comparison, and ensured that\r\nwe
are still keeping the number of rerenders **o a minimum**. This
PR\r\nshould not introduce **any** extra renders in comparison to the
API\r\nversion.\r\n\r\n### Checklist\r\n\r\n- [x] The PR description
includes the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n###
Identify risks\r\n\r\nThere are no risks to this PR, since all work is
contained in the\r\n`examples`
plugin.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<[email protected]>","sha":"549532240cd8bc271a78b74846021c9023e2da64","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Dashboard","Team:Presentation","loe:medium","release_note:skip","impact:high","v9.0.0","backport:prev-minor","Project:Collapsable
Panels"],"title":"[Dashboard] [Collapsable Panels] Switch to using
props","number":200793,"url":"https://github.com/elastic/kibana/pull/200793","mergeCommit":{"message":"[Dashboard]
[Collapsable Panels] Switch to using props (#200793)\n\nCloses
https://github.com/elastic/kibana/issues/200090\r\n\r\n##
Summary\r\n\r\nThis PR migrates the `GridLayout` component a more
traditional React\r\ndesign using **props** rather than providing an
API. This change serves\r\ntwo purposes:\r\n1. It makes the eventual
Dashboard migration easier, since it is more\r\nsimilar to
`react-grid-layout`'s implementation\r\n3. It makes the `GridLayout`
component less opinionated by moving the\r\nlogic for panel management
(i.e. panel placement, etc) to the parent\r\ncomponent.\r\n\r\nI tried
to keep efficiency in mind for this comparison, and ensured that\r\nwe
are still keeping the number of rerenders **o a minimum**. This
PR\r\nshould not introduce **any** extra renders in comparison to the
API\r\nversion.\r\n\r\n### Checklist\r\n\r\n- [x] The PR description
includes the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n###
Identify risks\r\n\r\nThere are no risks to this PR, since all work is
contained in the\r\n`examples`
plugin.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<[email protected]>","sha":"549532240cd8bc271a78b74846021c9023e2da64"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/200793","number":200793,"mergeCommit":{"message":"[Dashboard]
[Collapsable Panels] Switch to using props (#200793)\n\nCloses
https://github.com/elastic/kibana/issues/200090\r\n\r\n##
Summary\r\n\r\nThis PR migrates the `GridLayout` component a more
traditional React\r\ndesign using **props** rather than providing an
API. This change serves\r\ntwo purposes:\r\n1. It makes the eventual
Dashboard migration easier, since it is more\r\nsimilar to
`react-grid-layout`'s implementation\r\n3. It makes the `GridLayout`
component less opinionated by moving the\r\nlogic for panel management
(i.e. panel placement, etc) to the parent\r\ncomponent.\r\n\r\nI tried
to keep efficiency in mind for this comparison, and ensured that\r\nwe
are still keeping the number of rerenders **o a minimum**. This
PR\r\nshould not introduce **any** extra renders in comparison to the
API\r\nversion.\r\n\r\n### Checklist\r\n\r\n- [x] The PR description
includes the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n###
Identify risks\r\n\r\nThere are no risks to this PR, since all work is
contained in the\r\n`examples`
plugin.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<[email protected]>","sha":"549532240cd8bc271a78b74846021c9023e2da64"}}]}]
BACKPORT-->

Co-authored-by: Hannah Mudge <[email protected]>
  • Loading branch information
kibanamachine and Heenawter authored Nov 21, 2024
1 parent b342de5 commit 8f11c13
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 8f11c13

Please sign in to comment.