[kbn-grid-layout] Store rows in object instead of array (#212965)

Closes https://github.com/elastic/kibana/issues/211930

## Summary

This PR makes it so that `kbn-grid-layout` stores its rows as an object
/ dictionary (`{ [key: string]: GridRowData }`) rather than an array
(`Array<GridRowData>`). This is a prerequisite for
https://github.com/elastic/kibana/issues/190381 , since it allows us to
re-order rows without re-rendering their contents. It also means that
deleting a row will no longer cause the rows below it to re-render,
since re-rendering is now dependant on the row's **ID** rather than the
row's order.

**Before**


https://github.com/user-attachments/assets/83651b24-a32c-4953-8ad5-c0eced163eb5


**After**


https://github.com/user-attachments/assets/9cef6dbc-3d62-46aa-bc40-ab24fc4e5556


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [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)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2025-03-04 13:34:39 -07:00 committed by GitHub
parent ef0c364f11
commit b32f0fe1e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 325 additions and 286 deletions

View file

@ -8,9 +8,11 @@
*/
import deepEqual from 'fast-deep-equal';
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { Subject, combineLatest, debounceTime, map, skip, take } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import {
EuiBadge,
@ -88,8 +90,8 @@ export const GridExample = ({
const currentPanel = panels[panelId];
const savedPanel = savedState.current.panels[panelId];
panelsAreEqual = deepEqual(
{ row: 0, ...currentPanel.gridData },
{ row: 0, ...savedPanel.gridData }
{ row: 'first', ...currentPanel.gridData },
{ row: 'first', ...savedPanel.gridData }
);
}
const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows));
@ -147,15 +149,17 @@ export const GridExample = ({
);
const addNewSection = useCallback(() => {
mockDashboardApi.rows$.next([
...mockDashboardApi.rows$.getValue(),
{
title: i18n.translate('examples.gridExample.defaultSectionTitle', {
defaultMessage: 'New collapsible section',
}),
collapsed: false,
},
]);
const rows = cloneDeep(mockDashboardApi.rows$.getValue());
const id = uuidv4();
rows[id] = {
id,
order: Object.keys(rows).length,
title: i18n.translate('examples.gridExample.defaultSectionTitle', {
defaultMessage: 'New collapsible section',
}),
collapsed: false,
};
mockDashboardApi.rows$.next(rows);
// scroll to bottom after row is added
layoutUpdated$.pipe(skip(1), take(1)).subscribe(() => {

View file

@ -1004,7 +1004,7 @@
"w": 48,
"h": 17,
"i": "4",
"row": 1
"row": "second"
},
"explicitInput": {
"id": "4",
@ -1035,7 +1035,7 @@
"w": 18,
"h": 8,
"i": "05da0d2b-0145-4068-b21c-00be3184d465",
"row": 1
"row": "second"
},
"explicitInput": {
"id": "05da0d2b-0145-4068-b21c-00be3184d465",
@ -1073,7 +1073,7 @@
"w": 18,
"h": 16,
"i": "b7da9075-4742-47e3-b4f8-fc9ba82de74c",
"row": 1
"row": "second"
},
"explicitInput": {
"id": "b7da9075-4742-47e3-b4f8-fc9ba82de74c",
@ -1111,7 +1111,7 @@
"w": 12,
"h": 16,
"i": "5c409557-644d-4c05-a284-ffe54bb28db0",
"row": 1
"row": "second"
},
"explicitInput": {
"id": "5c409557-644d-4c05-a284-ffe54bb28db0",
@ -1234,7 +1234,7 @@
"w": 6,
"h": 8,
"i": "af4b5c07-506e-44c2-b2bb-2113d0c5b274",
"row": 1
"row": "second"
},
"explicitInput": {
"id": "af4b5c07-506e-44c2-b2bb-2113d0c5b274",
@ -1400,7 +1400,7 @@
"w": 6,
"h": 8,
"i": "d42c4870-c028-4d8a-abd0-0effbc190ce3",
"row": 1
"row": "second"
},
"explicitInput": {
"id": "d42c4870-c028-4d8a-abd0-0effbc190ce3",
@ -1520,7 +1520,7 @@
"w": 6,
"h": 8,
"i": "4092d42c-f93b-4c71-a6db-8f12abf12791",
"row": 1
"row": "second"
},
"explicitInput": {
"id": "4092d42c-f93b-4c71-a6db-8f12abf12791",
@ -1641,7 +1641,7 @@
"w": 30,
"h": 15,
"i": "15",
"row": 2
"row": "third"
},
"explicitInput": {
"id": "15",
@ -1887,7 +1887,7 @@
"w": 18,
"h": 8,
"i": "4e64d6d7-4f92-4d5e-abbb-13796604db30",
"row": 2
"row": "third"
},
"explicitInput": {
"id": "4e64d6d7-4f92-4d5e-abbb-13796604db30v",
@ -1925,7 +1925,7 @@
"w": 6,
"h": 7,
"i": "ddce4ad8-6a82-44f0-9995-57f46f153f50",
"row": 2
"row": "third"
},
"explicitInput": {
"id": "ddce4ad8-6a82-44f0-9995-57f46f153f50",
@ -2120,7 +2120,7 @@
"w": 6,
"h": 7,
"i": "a2884704-db3b-4b92-a19a-cdfe668dec39",
"row": 2
"row": "third"
},
"explicitInput": {
"id": "a2884704-db3b-4b92-a19a-cdfe668dec39",
@ -2315,7 +2315,7 @@
"w": 6,
"h": 7,
"i": "529eec49-10e2-4a40-9c77-5c81f4eb3943",
"row": 2
"row": "third"
},
"explicitInput": {
"id": "529eec49-10e2-4a40-9c77-5c81f4eb3943",
@ -2510,7 +2510,7 @@
"w": 48,
"h": 12,
"i": "1d5f0b3f-d9d2-4b26-997b-83bc5ca3090b",
"row": 2
"row": "third"
},
"explicitInput": {
"id": "1d5f0b3f-d9d2-4b26-997b-83bc5ca3090b",
@ -2905,7 +2905,7 @@
"w": 48,
"h": 15,
"i": "9f79ecca-123f-4098-a658-6b0e998da003",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "9f79ecca-123f-4098-a658-6b0e998da003",
@ -2922,7 +2922,7 @@
"w": 24,
"h": 9,
"i": "7",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "7",
@ -3161,7 +3161,7 @@
"w": 24,
"h": 11,
"i": "10",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "10",
@ -3346,7 +3346,7 @@
"w": 24,
"h": 22,
"i": "23",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "23",
@ -3371,7 +3371,7 @@
"w": 24,
"h": 22,
"i": "31",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "31",
@ -3388,7 +3388,7 @@
"w": 24,
"h": 8,
"i": "6afc61f7-e2d5-45a3-9e7a-281160ad3eb9",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "6afc61f7-e2d5-45a3-9e7a-281160ad3eb9",
@ -3420,7 +3420,7 @@
"w": 8,
"h": 8,
"i": "392b4936-f753-47bc-a98d-a4e41a0a4cd4",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "392b4936-f753-47bc-a98d-a4e41a0a4cd4",
@ -3485,7 +3485,7 @@
"w": 8,
"h": 4,
"i": "9271deff-5a61-4665-83fc-f9fdc6bf0c0b",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "9271deff-5a61-4665-83fc-f9fdc6bf0c0b",
@ -3613,7 +3613,7 @@
"w": 8,
"h": 4,
"i": "aa591c29-1a31-4ee1-a71d-b829c06fd162",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "aa591c29-1a31-4ee1-a71d-b829c06fd162",
@ -3777,7 +3777,7 @@
"w": 8,
"h": 4,
"i": "b766e3b8-4544-46ed-99e6-9ecc4847e2a2",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "b766e3b8-4544-46ed-99e6-9ecc4847e2a2",
@ -3905,7 +3905,7 @@
"w": 8,
"h": 4,
"i": "2e33ade5-96e5-40b4-b460-493e5d4fa834",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "2e33ade5-96e5-40b4-b460-493e5d4fa834",
@ -4069,7 +4069,7 @@
"w": 24,
"h": 8,
"i": "086ac2e9-dd16-4b45-92b8-1e43ff7e3f65",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "086ac2e9-dd16-4b45-92b8-1e43ff7e3f65",
@ -4190,7 +4190,7 @@
"w": 24,
"h": 28,
"i": "fb86b32f-fb7a-45cf-9511-f366fef51bbd",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "fb86b32f-fb7a-45cf-9511-f366fef51bbd",
@ -4500,7 +4500,7 @@
"w": 24,
"h": 11,
"i": "0cc42484-16f7-42ec-b38c-9bf8be69cde7",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "0cc42484-16f7-42ec-b38c-9bf8be69cde7",
@ -4643,7 +4643,7 @@
"w": 12,
"h": 11,
"i": "5d53db36-2d5a-4adc-af7b-cec4c1a294e0",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "5d53db36-2d5a-4adc-af7b-cec4c1a294e0",
@ -4773,7 +4773,7 @@
"w": 12,
"h": 11,
"i": "ecd89a7c-9124-4472-bdc6-9bdbd70d45d5",
"row": 3
"row": "fourth"
},
"explicitInput": {
"id": "ecd89a7c-9124-4472-bdc6-9bdbd70d45d5",

View file

@ -28,10 +28,10 @@ export function setSerializedGridLayout(state: MockSerializedDashboardState) {
const initialState: MockSerializedDashboardState = {
panels: logsPanels,
rows: [
{ title: 'Request Sizes', collapsed: false },
{ title: 'Visitors', collapsed: false },
{ title: 'Response Codes', collapsed: false },
{ title: 'Entire Flights Dashboard', collapsed: true },
],
rows: {
first: { id: 'first', order: 0, title: 'Request Sizes', collapsed: false },
second: { id: 'second', order: 1, title: 'Visitors', collapsed: false },
third: { id: 'third', order: 2, title: 'Response Codes', collapsed: false },
fourth: { id: 'fourth', order: 3, title: 'Entire Flights Dashboard', collapsed: true },
},
};

View file

@ -26,7 +26,7 @@ export interface DashboardGridData {
interface DashboardPanelState {
type: string;
gridData: DashboardGridData & { row?: number };
gridData: DashboardGridData & { row?: string };
explicitInput: Partial<any> & { id: string };
version?: string;
}
@ -35,7 +35,9 @@ export interface MockedDashboardPanelMap {
[key: string]: DashboardPanelState;
}
export type MockedDashboardRowMap = Array<{ title: string; collapsed: boolean }>;
export interface MockedDashboardRowMap {
[id: string]: { id: string; order: number; title: string; collapsed: boolean };
}
export interface MockSerializedDashboardState {
panels: MockedDashboardPanelMap;

View file

@ -99,7 +99,7 @@ export const useMockDashboardApi = ({
[newId]: {
type: panelPackage.panelType,
gridData: {
row: 0,
row: 'first',
x: 0,
y: 0,
w: DEFAULT_PANEL_WIDTH,

View file

@ -15,10 +15,11 @@ 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) => {
const rows: MockedDashboardRowMap = {};
Object.entries(layout).forEach(([rowId, row]) => {
const { panels: rowPanels, isCollapsed, ...rest } = row; // drop panels
rows[rowId] = { ...rest, collapsed: isCollapsed };
Object.values(rowPanels).forEach((panelGridData) => {
panels[panelGridData.id] = {
...panelState[panelGridData.id],
gridData: {
@ -27,7 +28,7 @@ export const gridLayoutToDashboardPanelMap = (
x: panelGridData.column,
w: panelGridData.width,
h: panelGridData.height,
row: rowIndex,
row: rowId,
},
};
});
@ -42,15 +43,19 @@ export const dashboardInputToGridLayout = ({
panels: MockedDashboardPanelMap;
rows: MockedDashboardRowMap;
}): GridLayoutData => {
const layout: GridLayoutData = [];
rows.forEach((row) => {
layout.push({ title: row.title, isCollapsed: row.collapsed, panels: {} });
const layout: GridLayoutData = {};
Object.values(rows).forEach((row) => {
const { collapsed, ...rest } = row;
layout[row.id] = {
...rest,
panels: {},
isCollapsed: collapsed,
};
});
Object.keys(panels).forEach((panelId) => {
const gridData = panels[panelId].gridData;
layout[gridData.row ?? 0].panels[panelId] = {
layout[gridData.row ?? 'first'].panels[panelId] = {
id: panelId,
row: gridData.y,
column: gridData.x,

View file

@ -13,7 +13,7 @@ import { combineLatest, skip } from 'rxjs';
import { css } from '@emotion/react';
import { useGridLayoutContext } from './use_grid_layout_context';
export const DragPreview = React.memo(({ rowIndex }: { rowIndex: number }) => {
export const DragPreview = React.memo(({ rowId }: { rowId: string }) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
@ -29,10 +29,10 @@ export const DragPreview = React.memo(({ rowIndex }: { rowIndex: number }) => {
.subscribe(([activePanel, proposedGridLayout]) => {
if (!dragPreviewRef.current) return;
if (!activePanel || !proposedGridLayout?.[rowIndex].panels[activePanel.id]) {
if (!activePanel || !proposedGridLayout?.[rowId].panels[activePanel.id]) {
dragPreviewRef.current.style.display = 'none';
} else {
const panel = proposedGridLayout[rowIndex].panels[activePanel.id];
const panel = proposedGridLayout[rowId].panels[activePanel.id];
dragPreviewRef.current.style.display = 'block';
dragPreviewRef.current.style.gridColumnStart = `${panel.column + 1}`;
dragPreviewRef.current.style.gridColumnEnd = `${panel.column + 1 + panel.width}`;

View file

@ -96,10 +96,10 @@ describe('GridLayout', () => {
// if layout **has** changed, call `onLayoutChange`
const newLayout = cloneDeep(layout);
newLayout[0] = {
...newLayout[0],
newLayout.first = {
...newLayout.first,
panels: {
...newLayout[0].panels,
...newLayout.first.panels,
panel1: {
id: 'panel1',
row: 100,
@ -217,7 +217,7 @@ describe('GridLayout', () => {
it('after removing a panel', async () => {
const { rerender } = renderGridLayout();
const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout());
delete sampleLayoutWithoutPanel1[0].panels.panel1;
delete sampleLayoutWithoutPanel1.first.panels.panel1;
rerender({ layout: sampleLayoutWithoutPanel1 });
expect(getAllThePanelIds()).toEqual([
@ -236,9 +236,9 @@ describe('GridLayout', () => {
it('after replacing a panel id', async () => {
const { rerender } = renderGridLayout();
const modifiedLayout = cloneDeep(getSampleLayout());
const newPanel = { ...modifiedLayout[0].panels.panel1, id: 'panel11' };
delete modifiedLayout[0].panels.panel1;
modifiedLayout[0].panels.panel11 = newPanel;
const newPanel = { ...modifiedLayout.first.panels.panel1, id: 'panel11' };
delete modifiedLayout.first.panels.panel1;
modifiedLayout.first.panels.panel11 = newPanel;
rerender({ layout: modifiedLayout });

View file

@ -8,9 +8,10 @@
*/
import classNames from 'classnames';
import deepEqual from 'fast-deep-equal';
import { cloneDeep } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { combineLatest, distinctUntilChanged, map, pairwise, skip } from 'rxjs';
import { combineLatest, pairwise } from 'rxjs';
import { css } from '@emotion/react';
@ -20,7 +21,7 @@ import { GridAccessMode, GridLayoutData, GridSettings, UseCustomDragHandle } fro
import { GridLayoutContext, GridLayoutContextType } from './use_grid_layout_context';
import { useGridLayoutState } from './use_grid_layout_state';
import { isLayoutEqual } from './utils/equality_checks';
import { resolveGridRow } from './utils/resolve_grid_row';
import { getRowKeysInOrder, resolveGridRow } from './utils/resolve_grid_row';
export type GridLayoutProps = {
layout: GridLayoutData;
@ -50,10 +51,7 @@ export const GridLayout = ({
accessMode,
});
const [rowCount, setRowCount] = useState<number>(
gridLayoutStateManager.gridLayout$.getValue().length
);
const [rowIdsInOrder, setRowIdsInOrder] = useState<string[]>(getRowKeysInOrder(layout));
/**
* Update the `gridLayout$` behaviour subject in response to the `layout` prop changing
*/
@ -64,8 +62,8 @@ export const GridLayout = ({
* 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
*/
newLayout.forEach((row, rowIndex) => {
newLayout[rowIndex] = resolveGridRow(row);
Object.entries(newLayout).forEach(([rowId, row]) => {
newLayout[rowId] = resolveGridRow(row);
});
gridLayoutStateManager.gridLayout$.next(newLayout);
}
@ -77,27 +75,18 @@ export const GridLayout = ({
*/
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);
});
/**
* This subscription calls the passed `onLayoutChange` callback when the layout changes
* This subscription calls the passed `onLayoutChange` callback when the layout changes;
* if the row IDs have changed, it also sets `rowIdsInOrder` to trigger a re-render
*/
const onLayoutChangeSubscription = gridLayoutStateManager.gridLayout$
.pipe(pairwise())
.subscribe(([layoutBefore, layoutAfter]) => {
if (!isLayoutEqual(layoutBefore, layoutAfter)) {
onLayoutChange(layoutAfter);
if (!deepEqual(Object.keys(layoutBefore), Object.keys(layoutAfter))) {
setRowIdsInOrder(getRowKeysInOrder(layoutAfter));
}
}
});
@ -125,7 +114,6 @@ export const GridLayout = ({
});
return () => {
rowCountSubscription.unsubscribe();
onLayoutChangeSubscription.unsubscribe();
gridLayoutClassSubscription.unsubscribe();
};
@ -158,9 +146,9 @@ export const GridLayout = ({
styles.hasExpandedPanel,
]}
>
{Array.from({ length: rowCount }, (_, rowIndex) => {
return <GridRow key={rowIndex} rowIndex={rowIndex} />;
})}
{rowIdsInOrder.map((rowId) => (
<GridRow key={rowId} rowId={rowId} />
))}
</div>
</GridHeightSmoother>
</GridLayoutContext.Provider>

View file

@ -20,17 +20,17 @@ export interface DragHandleApi {
export const useDragHandleApi = ({
panelId,
rowIndex,
rowId,
}: {
panelId: string;
rowIndex: number;
rowId: string;
}): DragHandleApi => {
const { useCustomDragHandle } = useGridLayoutContext();
const startInteraction = useGridLayoutEvents({
interactionType: 'drag',
panelId,
rowIndex,
rowId,
});
const removeEventListenersRef = useRef<(() => void) | null>(null);

View file

@ -25,7 +25,7 @@ describe('GridPanel', () => {
} as GridLayoutContextType;
const panelProps = {
panelId: 'panel1',
rowIndex: 0,
rowId: 'first',
...(overrides?.propsOverrides ?? {}),
};
const { rerender, ...rtlRest } = render(

View file

@ -20,20 +20,20 @@ import { ResizeHandle } from './resize_handle';
export interface GridPanelProps {
panelId: string;
rowIndex: number;
rowId: string;
}
export const GridPanel = React.memo(({ panelId, rowIndex }: GridPanelProps) => {
export const GridPanel = React.memo(({ panelId, rowId }: GridPanelProps) => {
const { gridLayoutStateManager, useCustomDragHandle, renderPanelContents } =
useGridLayoutContext();
const { euiTheme } = useEuiTheme();
const dragHandleApi = useDragHandleApi({ panelId, rowIndex });
const dragHandleApi = useDragHandleApi({ panelId, rowId });
/** Set initial styles based on state at mount to prevent styles from "blipping" */
const initialStyles = useMemo(() => {
const initialPanel = (gridLayoutStateManager.proposedGridLayout$.getValue() ??
gridLayoutStateManager.gridLayout$.getValue())[rowIndex].panels[panelId];
gridLayoutStateManager.gridLayout$.getValue())[rowId].panels[panelId];
return css`
position: relative;
height: calc(
@ -48,7 +48,7 @@ export const GridPanel = React.memo(({ panelId, rowIndex }: GridPanelProps) => {
grid-row-start: ${initialPanel.row + 1};
grid-row-end: ${initialPanel.row + 1 + initialPanel.height};
`;
}, [gridLayoutStateManager, rowIndex, panelId]);
}, [gridLayoutStateManager, rowId, panelId]);
useEffect(
() => {
@ -60,8 +60,8 @@ export const GridPanel = React.memo(({ panelId, rowIndex }: GridPanelProps) => {
])
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
.subscribe(([activePanel, gridLayout, proposedGridLayout]) => {
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
const panel = (proposedGridLayout ?? gridLayout)[rowIndex].panels[panelId];
const ref = gridLayoutStateManager.panelRefs.current[rowId][panelId];
const panel = (proposedGridLayout ?? gridLayout)[rowId]?.panels[panelId];
if (!ref || !panel) return;
const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue();
@ -128,9 +128,9 @@ export const GridPanel = React.memo(({ panelId, rowIndex }: GridPanelProps) => {
*/
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
(expandedPanelId) => {
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
const ref = gridLayoutStateManager.panelRefs.current[rowId][panelId];
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
const panel = gridLayout[rowIndex].panels[panelId];
const panel = gridLayout[rowId].panels[panelId];
if (!ref || !panel) return;
if (expandedPanelId && expandedPanelId === panelId) {
@ -160,17 +160,17 @@ export const GridPanel = React.memo(({ panelId, rowIndex }: GridPanelProps) => {
return (
<div
ref={(element) => {
if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {
gridLayoutStateManager.panelRefs.current[rowIndex] = {};
if (!gridLayoutStateManager.panelRefs.current[rowId]) {
gridLayoutStateManager.panelRefs.current[rowId] = {};
}
gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element;
gridLayoutStateManager.panelRefs.current[rowId][panelId] = element;
}}
css={initialStyles}
className="kbnGridPanel"
>
{!useCustomDragHandle && <DefaultDragHandle dragHandleApi={dragHandleApi} />}
{panelContents}
<ResizeHandle panelId={panelId} rowIndex={rowIndex} />
<ResizeHandle panelId={panelId} rowId={rowId} />
</div>
);
});

View file

@ -15,27 +15,25 @@ import { i18n } from '@kbn/i18n';
import { useGridLayoutEvents } from '../use_grid_layout_events';
export const ResizeHandle = React.memo(
({ rowIndex, panelId }: { rowIndex: number; panelId: string }) => {
const startInteraction = useGridLayoutEvents({
interactionType: 'resize',
panelId,
rowIndex,
});
export const ResizeHandle = React.memo(({ rowId, panelId }: { rowId: string; panelId: string }) => {
const startInteraction = useGridLayoutEvents({
interactionType: 'resize',
panelId,
rowId,
});
return (
<button
css={styles}
onMouseDown={startInteraction}
onTouchStart={startInteraction}
className="kbnGridPanel--resizeHandle"
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel',
})}
/>
);
}
);
return (
<button
css={styles}
onMouseDown={startInteraction}
onTouchStart={startInteraction}
className="kbnGridPanel--resizeHandle"
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel',
})}
/>
);
});
const styles = ({ euiTheme }: UseEuiTheme) =>
css({

View file

@ -23,10 +23,10 @@ import { deleteRow, movePanelsToRow } from '../utils/row_management';
import { useGridLayoutContext } from '../use_grid_layout_context';
export const DeleteGridRowModal = ({
rowIndex,
rowId,
setDeleteModalVisible,
}: {
rowIndex: number;
rowId: string;
setDeleteModalVisible: (visible: boolean) => void;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
@ -63,12 +63,11 @@ export const DeleteGridRowModal = ({
<EuiButton
onClick={() => {
setDeleteModalVisible(false);
let newLayout = movePanelsToRow(
gridLayoutStateManager.gridLayout$.getValue(),
rowIndex,
0
);
newLayout = deleteRow(newLayout, rowIndex);
const layout = gridLayoutStateManager.gridLayout$.getValue();
const firstRowId = Object.values(layout).find(({ order }) => order === 0)?.id;
if (!firstRowId) return;
let newLayout = movePanelsToRow(layout, rowId, firstRowId);
newLayout = deleteRow(newLayout, rowId);
gridLayoutStateManager.gridLayout$.next(newLayout);
}}
color="danger"
@ -80,7 +79,7 @@ export const DeleteGridRowModal = ({
<EuiButton
onClick={() => {
setDeleteModalVisible(false);
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowIndex);
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowId);
gridLayoutStateManager.gridLayout$.next(newLayout);
}}
fill
@ -90,9 +89,8 @@ export const DeleteGridRowModal = ({
defaultMessage:
'Delete section and {panelCount} {panelCount, plural, one {panel} other {panels}}',
values: {
panelCount: Object.keys(
gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels
).length,
panelCount: Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowId].panels)
.length,
},
})}
</EuiButton>

View file

@ -32,7 +32,7 @@ describe('GridRow', () => {
} as GridLayoutContextType
}
>
<GridRow rowIndex={0} {...propsOverrides} />
<GridRow rowId={'first'} {...propsOverrides} />
</GridLayoutContext.Provider>,
{ wrapper: EuiThemeProvider }
);
@ -40,22 +40,22 @@ describe('GridRow', () => {
it('renders all the panels in a row', () => {
renderGridRow();
const firstRowPanels = Object.values(getSampleLayout()[0].panels);
const firstRowPanels = Object.values(getSampleLayout().first.panels);
firstRowPanels.forEach((panel) => {
expect(screen.getByLabelText(`panelId:${panel.id}`)).toBeInTheDocument();
});
});
it('does not show the panels in a row that is collapsed', async () => {
renderGridRow({ rowIndex: 1 });
renderGridRow({ rowId: 'second' });
expect(screen.getByTestId('kbnGridRowTitle-1').ariaExpanded).toBe('true');
expect(screen.getByTestId('kbnGridRowTitle-second').ariaExpanded).toBe('true');
expect(screen.getAllByText(/panel content/)).toHaveLength(1);
const collapseButton = screen.getByRole('button', { name: /toggle collapse/i });
await userEvent.click(collapseButton);
expect(screen.getByTestId('kbnGridRowTitle-1').ariaExpanded).toBe('false');
expect(screen.getByTestId('kbnGridRowTitle-second').ariaExpanded).toBe('false');
expect(screen.queryAllByText(/panel content/)).toHaveLength(0);
});
});

View file

@ -17,20 +17,20 @@ import { css } from '@emotion/react';
import { DragPreview } from '../drag_preview';
import { GridPanel } from '../grid_panel';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { getKeysInOrder } from '../utils/resolve_grid_row';
import { getPanelKeysInOrder } from '../utils/resolve_grid_row';
import { GridRowHeader } from './grid_row_header';
export interface GridRowProps {
rowIndex: number;
rowId: string;
}
export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
export const GridRow = React.memo(({ rowId }: GridRowProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const collapseButtonRef = useRef<HTMLButtonElement | null>(null);
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
const currentRow = gridLayoutStateManager.gridLayout$.value[rowId];
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
getKeysInOrder(currentRow.panels)
getPanelKeysInOrder(currentRow.panels)
);
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
@ -40,10 +40,10 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
const interactionStyleSubscription = gridLayoutStateManager.interactionEvent$
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
.subscribe((interactionEvent) => {
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
const rowRef = gridLayoutStateManager.rowRefs.current[rowId];
if (!rowRef) return;
const targetRow = interactionEvent?.targetRowIndex;
if (rowIndex === targetRow && interactionEvent) {
const targetRow = interactionEvent?.targetRow;
if (rowId === targetRow && interactionEvent) {
rowRef.classList.add('kbnGridRow--targeted');
} else {
rowRef.classList.remove('kbnGridRow--targeted');
@ -63,8 +63,8 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
map(([proposedGridLayout, gridLayout]) => {
const displayedGridLayout = proposedGridLayout ?? gridLayout;
return {
isCollapsed: displayedGridLayout[rowIndex]?.isCollapsed ?? false,
panelIds: Object.keys(displayedGridLayout[rowIndex]?.panels ?? {}),
isCollapsed: displayedGridLayout[rowId]?.isCollapsed ?? false,
panelIds: Object.keys(displayedGridLayout[rowId]?.panels ?? {}),
};
}),
pairwise()
@ -81,9 +81,9 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
)
) {
setPanelIdsInOrder(
getKeysInOrder(
getPanelKeysInOrder(
(gridLayoutStateManager.proposedGridLayout$.getValue() ??
gridLayoutStateManager.gridLayout$.getValue())[rowIndex]?.panels ?? {}
gridLayoutStateManager.gridLayout$.getValue())[rowId]?.panels ?? {}
)
);
}
@ -95,8 +95,8 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
* reasons (screen readers and focus management).
*/
const gridLayoutSubscription = gridLayoutStateManager.gridLayout$.subscribe((gridLayout) => {
if (!gridLayout[rowIndex]) return;
const newPanelIdsInOrder = getKeysInOrder(gridLayout[rowIndex].panels);
if (!gridLayout[rowId]) return;
const newPanelIdsInOrder = getPanelKeysInOrder(gridLayout[rowId].panels);
if (panelIdsInOrder.join() !== newPanelIdsInOrder.join()) {
setPanelIdsInOrder(newPanelIdsInOrder);
}
@ -109,14 +109,14 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[rowIndex]
[rowId]
);
const toggleIsCollapsed = useCallback(() => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
newLayout[rowId].isCollapsed = !newLayout[rowId].isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
}, [rowIndex, gridLayoutStateManager.gridLayout$]);
}, [rowId, gridLayoutStateManager.gridLayout$]);
useEffect(() => {
/**
@ -134,29 +134,29 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
'kbnGridRowContainer--collapsed': isCollapsed,
})}
>
{rowIndex !== 0 && (
{currentRow.order !== 0 && (
<GridRowHeader
rowIndex={rowIndex}
rowId={rowId}
toggleIsCollapsed={toggleIsCollapsed}
collapseButtonRef={collapseButtonRef}
/>
)}
{!isCollapsed && (
<div
id={`kbnGridRow-${rowIndex}`}
id={`kbnGridRow-${rowId}`}
className={'kbnGridRow'}
ref={(element: HTMLDivElement | null) =>
(gridLayoutStateManager.rowRefs.current[rowIndex] = element)
(gridLayoutStateManager.rowRefs.current[rowId] = element)
}
css={[styles.fullHeight, styles.grid]}
role="region"
aria-labelledby={`kbnGridRowTile-${rowIndex}`}
aria-labelledby={`kbnGridRowTile-${rowId}`}
>
{/* render the panels **in order** for accessibility, using the memoized panel components */}
{panelIdsInOrder.map((panelId) => (
<GridPanel key={panelId} panelId={panelId} rowIndex={rowIndex} />
<GridPanel key={panelId} panelId={panelId} rowId={rowId} />
))}
<DragPreview rowIndex={rowIndex} />
<DragPreview rowId={rowId} />
</div>
)}
</div>

View file

@ -20,9 +20,9 @@ import { GridLayoutContext, GridLayoutContextType } from '../use_grid_layout_con
const toggleIsCollapsed = jest
.fn()
.mockImplementation((rowIndex: number, gridLayoutStateManager: GridLayoutStateManager) => {
.mockImplementation((rowId: string, gridLayoutStateManager: GridLayoutStateManager) => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
newLayout[rowId].isCollapsed = !newLayout[rowId].isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
});
@ -44,8 +44,8 @@ describe('GridRowHeader', () => {
}
>
<GridRowHeader
rowIndex={0}
toggleIsCollapsed={() => toggleIsCollapsed(0, stateManagerMock)}
rowId={'first'}
toggleIsCollapsed={() => toggleIsCollapsed('first', stateManagerMock)}
collapseButtonRef={React.createRef()}
{...propsOverrides}
/>
@ -62,36 +62,36 @@ describe('GridRowHeader', () => {
it('renders the panel count', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const initialCount = component.getByTestId('kbnGridRowHeader-0--panelCount');
const initialCount = component.getByTestId('kbnGridRowHeader-first--panelCount');
expect(initialCount.textContent).toBe('(8 panels)');
act(() => {
const currentRow = gridLayoutStateManager.gridLayout$.getValue()[0];
gridLayoutStateManager.gridLayout$.next([
{
const currentRow = gridLayoutStateManager.gridLayout$.getValue().first;
gridLayoutStateManager.gridLayout$.next({
first: {
...currentRow,
panels: {
panel1: currentRow.panels.panel1,
},
},
]);
});
});
await waitFor(() => {
const updatedCount = component.getByTestId('kbnGridRowHeader-0--panelCount');
const updatedCount = component.getByTestId('kbnGridRowHeader-first--panelCount');
expect(updatedCount.textContent).toBe('(1 panel)');
});
});
it('clicking title calls `toggleIsCollapsed`', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const title = component.getByTestId('kbnGridRowTitle-0');
const title = component.getByTestId('kbnGridRowTitle-first');
expect(toggleIsCollapsed).toBeCalledTimes(0);
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false);
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(false);
await userEvent.click(title);
expect(toggleIsCollapsed).toBeCalledTimes(1);
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(true);
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(true);
});
describe('title editor', () => {
@ -105,44 +105,44 @@ describe('GridRowHeader', () => {
it('clicking on edit icon triggers inline title editor and does not toggle collapsed', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const editIcon = component.getByTestId('kbnGridRowTitle-0--edit');
const editIcon = component.getByTestId('kbnGridRowTitle-first--edit');
expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument();
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false);
expect(component.queryByTestId('kbnGridRowTitle-first--editor')).not.toBeInTheDocument();
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(false);
await userEvent.click(editIcon);
expect(component.getByTestId('kbnGridRowTitle-0--editor')).toBeInTheDocument();
expect(component.getByTestId('kbnGridRowTitle-first--editor')).toBeInTheDocument();
expect(toggleIsCollapsed).toBeCalledTimes(0);
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false);
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(false);
});
it('can update the title', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section');
expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section');
expect(component.getByTestId('kbnGridRowTitle-first').textContent).toBe('Large section');
expect(gridLayoutStateManager.gridLayout$.getValue().first.title).toBe('Large section');
const editIcon = component.getByTestId('kbnGridRowTitle-0--edit');
const editIcon = component.getByTestId('kbnGridRowTitle-first--edit');
await userEvent.click(editIcon);
await setTitle(component);
const saveButton = component.getByTestId('euiInlineEditModeSaveButton');
await userEvent.click(saveButton);
expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section 123');
expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section 123');
expect(component.queryByTestId('kbnGridRowTitle-first--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridRowTitle-first').textContent).toBe('Large section 123');
expect(gridLayoutStateManager.gridLayout$.getValue().first.title).toBe('Large section 123');
});
it('clicking on cancel closes the inline title editor without updating title', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const editIcon = component.getByTestId('kbnGridRowTitle-0--edit');
const editIcon = component.getByTestId('kbnGridRowTitle-first--edit');
await userEvent.click(editIcon);
await setTitle(component);
const cancelButton = component.getByTestId('euiInlineEditModeCancelButton');
await userEvent.click(cancelButton);
expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section');
expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section');
expect(component.queryByTestId('kbnGridRowTitle-first--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridRowTitle-first').textContent).toBe('Large section');
expect(gridLayoutStateManager.gridLayout$.getValue().first.title).toBe('Large section');
});
});
});

View file

@ -26,13 +26,13 @@ import { DeleteGridRowModal } from './delete_grid_row_modal';
import { GridRowTitle } from './grid_row_title';
export interface GridRowHeaderProps {
rowIndex: number;
rowId: string;
toggleIsCollapsed: () => void;
collapseButtonRef: React.MutableRefObject<HTMLButtonElement | null>;
}
export const GridRowHeader = React.memo(
({ rowIndex, toggleIsCollapsed, collapseButtonRef }: GridRowHeaderProps) => {
({ rowId, toggleIsCollapsed, collapseButtonRef }: GridRowHeaderProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const [editTitleOpen, setEditTitleOpen] = useState<boolean>(false);
@ -41,7 +41,7 @@ export const GridRowHeader = React.memo(
gridLayoutStateManager.accessMode$.getValue() === 'VIEW'
);
const [panelCount, setPanelCount] = useState<number>(
Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels).length
Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowId].panels).length
);
useEffect(() => {
@ -60,7 +60,7 @@ export const GridRowHeader = React.memo(
*/
const panelCountSubscription = gridLayoutStateManager.gridLayout$
.pipe(
map((layout) => Object.keys(layout[rowIndex]?.panels ?? {}).length),
map((layout) => Object.keys(layout[rowId]?.panels ?? {}).length),
distinctUntilChanged()
)
.subscribe((count) => {
@ -71,23 +71,21 @@ export const GridRowHeader = React.memo(
accessModeSubscription.unsubscribe();
panelCountSubscription.unsubscribe();
};
}, [gridLayoutStateManager, rowIndex]);
}, [gridLayoutStateManager, rowId]);
const confirmDeleteRow = useCallback(() => {
/**
* Memoization of this callback does not need to be dependant on the React panel count
* state, so just grab the panel count via gridLayoutStateManager instead
*/
const count = Object.keys(
gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels
).length;
const count = Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowId].panels).length;
if (!Boolean(count)) {
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowIndex);
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowId);
gridLayoutStateManager.gridLayout$.next(newLayout);
} else {
setDeleteModalVisible(true);
}
}, [gridLayoutStateManager.gridLayout$, rowIndex]);
}, [gridLayoutStateManager.gridLayout$, rowId]);
return (
<>
@ -97,10 +95,10 @@ export const GridRowHeader = React.memo(
alignItems="center"
css={styles.headerStyles}
className="kbnGridRowHeader"
data-test-subj={`kbnGridRowHeader-${rowIndex}`}
data-test-subj={`kbnGridRowHeader-${rowId}`}
>
<GridRowTitle
rowIndex={rowIndex}
rowId={rowId}
readOnly={readOnly}
toggleIsCollapsed={toggleIsCollapsed}
editTitleOpen={editTitleOpen}
@ -118,7 +116,7 @@ export const GridRowHeader = React.memo(
<EuiText
color="subdued"
size="s"
data-test-subj={`kbnGridRowHeader-${rowIndex}--panelCount`}
data-test-subj={`kbnGridRowHeader-${rowId}--panelCount`}
className={'kbnGridLayout--panelCount'}
>
{i18n.translate('kbnGridLayout.rowHeader.panelCount', {
@ -166,7 +164,7 @@ export const GridRowHeader = React.memo(
}
</EuiFlexGroup>
{deleteModalVisible && (
<DeleteGridRowModal rowIndex={rowIndex} setDeleteModalVisible={setDeleteModalVisible} />
<DeleteGridRowModal rowId={rowId} setDeleteModalVisible={setDeleteModalVisible} />
)}
</>
);

View file

@ -26,14 +26,14 @@ import { useGridLayoutContext } from '../use_grid_layout_context';
export const GridRowTitle = React.memo(
({
readOnly,
rowIndex,
rowId,
editTitleOpen,
setEditTitleOpen,
toggleIsCollapsed,
collapseButtonRef,
}: {
readOnly: boolean;
rowIndex: number;
rowId: string;
editTitleOpen: boolean;
setEditTitleOpen: (value: boolean) => void;
toggleIsCollapsed: () => void;
@ -42,7 +42,7 @@ export const GridRowTitle = React.memo(
const { gridLayoutStateManager } = useGridLayoutContext();
const inputRef = useRef<HTMLInputElement | null>(null);
const currentRow = gridLayoutStateManager.gridLayout$.getValue()[rowIndex];
const currentRow = gridLayoutStateManager.gridLayout$.getValue()[rowId];
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
useEffect(() => {
@ -51,7 +51,7 @@ export const GridRowTitle = React.memo(
*/
const titleSubscription = gridLayoutStateManager.gridLayout$
.pipe(
map((gridLayout) => gridLayout[rowIndex]?.title ?? ''),
map((gridLayout) => gridLayout[rowId]?.title ?? ''),
distinctUntilChanged()
)
.subscribe((title) => {
@ -61,7 +61,7 @@ export const GridRowTitle = React.memo(
return () => {
titleSubscription.unsubscribe();
};
}, [rowIndex, gridLayoutStateManager]);
}, [rowId, gridLayoutStateManager]);
useEffect(() => {
/**
@ -75,11 +75,11 @@ export const GridRowTitle = React.memo(
const updateTitle = useCallback(
(title: string) => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.getValue());
newLayout[rowIndex].title = title;
newLayout[rowId].title = title;
gridLayoutStateManager.gridLayout$.next(newLayout);
setEditTitleOpen(false);
},
[rowIndex, setEditTitleOpen, gridLayoutStateManager.gridLayout$]
[rowId, setEditTitleOpen, gridLayoutStateManager.gridLayout$]
);
return (
@ -94,9 +94,9 @@ export const GridRowTitle = React.memo(
iconType={'arrowDown'}
onClick={toggleIsCollapsed}
size="m"
id={`kbnGridRowTitle-${rowIndex}`}
aria-controls={`kbnGridRow-${rowIndex}`}
data-test-subj={`kbnGridRowTitle-${rowIndex}`}
id={`kbnGridRowTitle-${rowId}`}
aria-controls={`kbnGridRow-${rowId}`}
data-test-subj={`kbnGridRowTitle-${rowId}`}
textProps={false}
flush="both"
>
@ -123,7 +123,7 @@ export const GridRowTitle = React.memo(
inputAriaLabel={i18n.translate('kbnGridLayout.row.editTitleAriaLabel', {
defaultMessage: 'Edit section title',
})}
data-test-subj={`kbnGridRowTitle-${rowIndex}--editor`}
data-test-subj={`kbnGridRowTitle-${rowId}--editor`}
/>
</EuiFlexItem>
) : (
@ -137,7 +137,7 @@ export const GridRowTitle = React.memo(
aria-label={i18n.translate('kbnGridLayout.row.editRowTitle', {
defaultMessage: 'Edit section title',
})}
data-test-subj={`kbnGridRowTitle-${rowIndex}--edit`}
data-test-subj={`kbnGridRowTitle-${rowId}--edit`}
/>
</EuiFlexItem>
)}

View file

@ -9,10 +9,12 @@
import { GridLayoutData } from '../types';
export const getSampleLayout = (): GridLayoutData => [
{
export const getSampleLayout = (): GridLayoutData => ({
first: {
title: 'Large section',
isCollapsed: false,
id: 'first',
order: 0,
panels: {
panel1: {
id: 'panel1',
@ -72,9 +74,11 @@ export const getSampleLayout = (): GridLayoutData => [
},
},
},
{
second: {
title: 'Small section',
isCollapsed: false,
id: 'second',
order: 1,
panels: {
panel9: {
id: 'panel9',
@ -85,9 +89,11 @@ export const getSampleLayout = (): GridLayoutData => [
},
},
},
{
third: {
title: 'Another small section',
isCollapsed: false,
id: 'third',
order: 2,
panels: {
panel10: {
id: 'panel10',
@ -98,4 +104,4 @@ export const getSampleLayout = (): GridLayoutData => [
},
},
},
];
});

View file

@ -24,6 +24,8 @@ export interface GridPanelData extends GridRect {
}
export interface GridRowData {
id: string;
order: number;
title: string;
isCollapsed: boolean;
panels: {
@ -31,7 +33,9 @@ export interface GridRowData {
};
}
export type GridLayoutData = GridRowData[];
export interface GridLayoutData {
[rowId: string]: GridRowData;
}
export interface GridSettings {
gutterSize: number;
@ -67,8 +71,10 @@ export interface GridLayoutStateManager {
activePanel$: BehaviorSubject<ActivePanel | undefined>;
interactionEvent$: BehaviorSubject<PanelInteractionEvent | undefined>;
rowRefs: React.MutableRefObject<Array<HTMLDivElement | null>>;
panelRefs: React.MutableRefObject<Array<{ [id: string]: HTMLDivElement | null }>>;
rowRefs: React.MutableRefObject<{ [rowId: string]: HTMLDivElement | null }>;
panelRefs: React.MutableRefObject<{
[rowId: string]: { [panelId: string]: HTMLDivElement | null };
}>;
}
/**
@ -88,7 +94,7 @@ export interface PanelInteractionEvent {
/**
* The index of the grid row this panel interaction is targeting.
*/
targetRowIndex: number;
targetRow: string;
/**
* The pixel rect of the panel being interacted with.

View file

@ -29,11 +29,11 @@ import { useGridLayoutContext } from '../use_grid_layout_context';
export const useGridLayoutEvents = ({
interactionType,
rowIndex,
rowId,
panelId,
}: {
interactionType: PanelInteractionEvent['type'];
rowIndex: number;
rowId: string;
panelId: string;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
@ -45,8 +45,7 @@ export const useGridLayoutEvents = ({
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
const onStart = () =>
startAction(e, gridLayoutStateManager, interactionType, rowIndex, panelId);
const onStart = () => startAction(e, gridLayoutStateManager, interactionType, rowId, panelId);
const onMove = (ev: UserInteractionEvent) => {
if (isMouseEvent(ev) || isTouchEvent(ev)) {
@ -74,7 +73,7 @@ export const useGridLayoutEvents = ({
});
}
},
[gridLayoutStateManager, rowIndex, panelId, interactionType]
[gridLayoutStateManager, rowId, panelId, interactionType]
);
return startInteraction;

View file

@ -20,10 +20,10 @@ export const startAction = (
e: UserInteractionEvent,
gridLayoutStateManager: GridLayoutStateManager,
type: 'drag' | 'resize',
rowIndex: number,
rowId: string,
panelId: string
) => {
const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
const panelRef = gridLayoutStateManager.panelRefs.current[rowId][panelId];
if (!panelRef) return;
const panelRect = panelRef.getBoundingClientRect();
@ -32,7 +32,7 @@ export const startAction = (
type,
id: panelId,
panelDiv: panelRef,
targetRowIndex: rowIndex,
targetRow: rowId,
pointerOffsets: getPointerOffsets(e, panelRect),
});
@ -74,8 +74,7 @@ export const moveAction = (
const currentLayout = proposedGridLayout$.value;
const currentPanelData =
currentLayout?.[interactionEvent.targetRowIndex].panels[interactionEvent.id];
const currentPanelData = currentLayout?.[interactionEvent.targetRow].panels[interactionEvent.id];
if (!currentPanelData) {
return;
@ -100,37 +99,37 @@ export const moveAction = (
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings;
// find the grid that the preview rect is over
const lastRowIndex = interactionEvent.targetRowIndex;
const targetRowIndex = (() => {
if (isResize) return lastRowIndex;
const lastRowId = interactionEvent.targetRow;
const targetRowId = (() => {
if (isResize) return lastRowId;
const previewBottom = previewRect.top + rowHeight;
let highestOverlap = -Infinity;
let highestOverlapRowIndex = -1;
gridRowElements.forEach((row, index) => {
let highestOverlapRowId = '';
Object.entries(gridRowElements).forEach(([id, row]) => {
if (!row) return;
const rowRect = row.getBoundingClientRect();
const overlap =
Math.min(previewBottom, rowRect.bottom) - Math.max(previewRect.top, rowRect.top);
if (overlap > highestOverlap) {
highestOverlap = overlap;
highestOverlapRowIndex = index;
highestOverlapRowId = id;
}
});
return highestOverlapRowIndex;
return highestOverlapRowId;
})();
const hasChangedGridRow = targetRowIndex !== lastRowIndex;
const hasChangedGridRow = targetRowId !== lastRowId;
// re-render when the target row changes
if (hasChangedGridRow) {
interactionEvent$.next({
...interactionEvent,
targetRowIndex,
targetRow: targetRowId,
});
}
// calculate the requested grid position
const targetedGridRow = gridRowElements[targetRowIndex];
const targetedGridRow = gridRowElements[targetRowId];
const targetedGridLeft = targetedGridRow?.getBoundingClientRect().left ?? 0;
const targetedGridTop = targetedGridRow?.getBoundingClientRect().top ?? 0;
@ -166,21 +165,22 @@ export const moveAction = (
lastRequestedPanelPosition.current = { ...requestedPanelData };
// remove the panel from the row it's currently in.
const nextLayout = currentLayout.map((row) => {
const nextLayout = cloneDeep(currentLayout);
Object.entries(nextLayout).forEach(([rowId, row]) => {
const { [interactionEvent.id]: interactingPanel, ...otherPanels } = row.panels;
return { ...row, panels: { ...otherPanels } };
nextLayout[rowId] = { ...row, panels: { ...otherPanels } };
});
// resolve destination grid
const destinationGrid = nextLayout[targetRowIndex];
const destinationGrid = nextLayout[targetRowId];
const resolvedDestinationGrid = resolveGridRow(destinationGrid, requestedPanelData);
nextLayout[targetRowIndex] = resolvedDestinationGrid;
nextLayout[targetRowId] = resolvedDestinationGrid;
// resolve origin grid
if (hasChangedGridRow) {
const originGrid = nextLayout[lastRowIndex];
const originGrid = nextLayout[lastRowId];
const resolvedOriginGrid = resolveGridRow(originGrid);
nextLayout[lastRowIndex] = resolvedOriginGrid;
nextLayout[lastRowId] = resolvedOriginGrid;
}
if (!deepEqual(currentLayout, nextLayout)) {
proposedGridLayout$.next(nextLayout);

View file

@ -42,8 +42,8 @@ export const useGridLayoutState = ({
gridLayoutStateManager: GridLayoutStateManager;
setDimensionsRef: (instance: HTMLDivElement | null) => void;
} => {
const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const panelRefs = useRef<Array<{ [id: string]: HTMLDivElement | null }>>([]);
const rowRefs = useRef<{ [rowId: string]: HTMLDivElement | null }>({});
const panelRefs = useRef<{ [rowId: string]: { [panelId: string]: HTMLDivElement | null } }>({});
const { euiTheme } = useEuiTheme();
const expandedPanelId$ = useMemo(
@ -84,8 +84,8 @@ export const useGridLayoutState = ({
const gridLayoutStateManager = useMemo(() => {
const resolvedLayout = cloneDeep(layout);
resolvedLayout.forEach((row, rowIndex) => {
resolvedLayout[rowIndex] = resolveGridRow(row);
Object.values(resolvedLayout).forEach((row) => {
resolvedLayout[row.id] = resolveGridRow(row);
});
const gridLayout$ = new BehaviorSubject<GridLayoutData>(resolvedLayout);
@ -93,14 +93,10 @@ export const useGridLayoutState = ({
const gridDimensions$ = new BehaviorSubject<ObservedSize>({ width: 0, height: 0 });
const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined);
const activePanel$ = new BehaviorSubject<ActivePanel | undefined>(undefined);
const panelIds$ = new BehaviorSubject<string[][]>(
layout.map(({ panels }) => Object.keys(panels))
);
return {
rowRefs,
panelRefs,
panelIds$,
proposedGridLayout$,
gridLayout$,
activePanel$,

View file

@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { GridLayoutData, GridPanelData } from '../types';
export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
@ -20,14 +21,16 @@ export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
};
export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
if (a.length !== b.length) return false;
if (!deepEqual(Object.keys(a), Object.keys(b))) return false;
let isEqual = true;
for (let rowIndex = 0; rowIndex < a.length && isEqual; rowIndex++) {
const rowA = a[rowIndex];
const rowB = b[rowIndex];
const keys = Object.keys(a); // keys of A are equal to keys of b
for (const key of keys) {
const rowA = a[key];
const rowB = b[key];
isEqual =
rowA.order === rowB.order &&
rowA.title === rowB.title &&
rowA.isCollapsed === rowB.isCollapsed &&
Object.keys(rowA.panels).length === Object.keys(rowB.panels).length;
@ -38,6 +41,7 @@ export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
if (!isEqual) break;
}
}
if (!isEqual) break;
}
return isEqual;

View file

@ -12,6 +12,8 @@ import { resolveGridRow } from './resolve_grid_row';
describe('resolve grid row', () => {
test('does nothing if grid row has no collisions', () => {
const gridRow = {
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -27,6 +29,8 @@ describe('resolve grid row', () => {
test('resolves grid row if it has collisions without drag event', () => {
const result = resolveGridRow({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -37,6 +41,8 @@ describe('resolve grid row', () => {
},
});
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -51,6 +57,8 @@ describe('resolve grid row', () => {
test('drag causes no collision', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -63,6 +71,8 @@ describe('resolve grid row', () => {
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -77,6 +87,8 @@ describe('resolve grid row', () => {
test('drag causes collision with one panel that pushes down others', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -90,6 +102,8 @@ describe('resolve grid row', () => {
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -105,6 +119,8 @@ describe('resolve grid row', () => {
test('drag causes collision with multiple panels', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -116,6 +132,8 @@ describe('resolve grid row', () => {
{ id: 'panel4', row: 0, column: 3, height: 5, width: 4 }
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -130,6 +148,8 @@ describe('resolve grid row', () => {
test('drag causes collision with every panel', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
@ -142,6 +162,8 @@ describe('resolve grid row', () => {
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { GridPanelData, GridRowData } from '../types';
import { GridLayoutData, GridPanelData, GridRowData } from '../types';
const collides = (panelA: GridPanelData, panelB: GridPanelData) => {
if (panelA.id === panelB.id) return false; // same panel
@ -46,7 +46,16 @@ const getFirstCollision = (gridLayout: GridRowData, keysInOrder: string[]): stri
return undefined;
};
export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string): string[] => {
export const getRowKeysInOrder = (rows: GridLayoutData): string[] => {
return Object.values(rows)
.sort(({ order: orderA }, { order: orderB }) => orderA - orderB)
.map(({ id }) => id);
};
export const getPanelKeysInOrder = (
panels: GridRowData['panels'],
draggedId?: string
): string[] => {
const panelKeys = Object.keys(panels);
return panelKeys.sort((panelKeyA, panelKeyB) => {
const panelA = panels[panelKeyA];
@ -72,7 +81,7 @@ export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string
const compactGridRow = (originalLayout: GridRowData) => {
const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } };
// compact all vertical space.
const sortedKeysAfterMove = getKeysInOrder(nextRowData.panels);
const sortedKeysAfterMove = getPanelKeysInOrder(nextRowData.panels);
for (const panelKey of sortedKeysAfterMove) {
const panel = nextRowData.panels[panelKey];
// try moving panel up one row at a time until it collides
@ -99,7 +108,7 @@ export const resolveGridRow = (
nextRowData.panels[dragRequest.id] = dragRequest;
}
// get keys in order from top to bottom, left to right, with priority on the dragged item if it exists
const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id);
const sortedKeys = getPanelKeysInOrder(nextRowData.panels, dragRequest?.id);
// while the layout has at least one collision, try to resolve them in order
let collision = getFirstCollision(nextRowData, sortedKeys);

View file

@ -18,7 +18,7 @@ import { resolveGridRow } from './resolve_grid_row';
* @param newRow The destination row for the panels
* @returns Updated layout with panels moved from `startingRow` to `newRow`
*/
export const movePanelsToRow = (layout: GridLayoutData, startingRow: number, newRow: number) => {
export const movePanelsToRow = (layout: GridLayoutData, startingRow: string, newRow: string) => {
const newLayout = cloneDeep(layout);
const panelsToMove = newLayout[startingRow].panels;
const maxRow = Math.max(
@ -37,8 +37,8 @@ export const movePanelsToRow = (layout: GridLayoutData, startingRow: number, new
* @param rowIndex The row to be deleted
* @returns Updated layout with the row at `rowIndex` deleted
*/
export const deleteRow = (layout: GridLayoutData, rowIndex: number) => {
export const deleteRow = (layout: GridLayoutData, rowId: string) => {
const newLayout = cloneDeep(layout);
newLayout.splice(rowIndex, 1);
delete newLayout[rowId];
return newLayout;
};

View file

@ -9,13 +9,14 @@
import classNames from 'classnames';
import React, { useCallback, useMemo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { css } from '@emotion/react';
import { useAppFixedViewport } from '@kbn/core-rendering-browser';
import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { useEuiTheme } from '@elastic/eui';
import { DashboardPanelState } from '../../../../common';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../../common/content_management/constants';
import { arePanelLayoutsEqual } from '../../../dashboard_api/are_panel_layouts_equal';
@ -33,6 +34,7 @@ export const DashboardGrid = ({
const layoutStyles = useLayoutStyles();
const panelRefs = useRef<{ [panelId: string]: React.Ref<HTMLDivElement> }>({});
const { euiTheme } = useEuiTheme();
const firstRowId = useRef(uuidv4());
const [expandedPanelId, panels, useMargins, viewMode] = useBatchedPublishingSubjects(
dashboardApi.expandedPanelId$,
@ -44,7 +46,9 @@ export const DashboardGrid = ({
const appFixedViewport = useAppFixedViewport();
const currentLayout: GridLayoutData = useMemo(() => {
const singleRow: GridLayoutData[number] = {
const singleRow: GridLayoutData[string] = {
id: firstRowId.current,
order: 0,
title: '', // we only support a single section currently, and it does not have a title
isCollapsed: false,
panels: {},
@ -66,7 +70,7 @@ export const DashboardGrid = ({
}
});
return [singleRow];
return { [firstRowId.current]: singleRow };
}, [panels]);
const onLayoutChange = useCallback(
@ -75,7 +79,7 @@ export const DashboardGrid = ({
const currentPanels = dashboardApi.panels$.getValue();
const updatedPanels: { [key: string]: DashboardPanelState } = Object.values(
newLayout[0].panels
newLayout[firstRowId.current].panels
).reduce((updatedPanelsAcc, panelLayout) => {
updatedPanelsAcc[panelLayout.id] = {
...currentPanels[panelLayout.id],