Skip to content

Commit 39abd8f

Browse files
HeenawterJoseLuisGJ
authored andcommitted
[kbn-grid-layout] Allow rows to be reordered (elastic#213166)
Closes elastic#190381 ## Summary This PR adds the ability to drag and drop rows by their headers in order to reorder them: ![Mar-12-2025 16-07-04](https://github.com/user-attachments/assets/de6afb8e-f009-4c00-b1dc-4804769e54eb) It can be a bit confusing dragging section headers around when other sections are expanded - it is easy to lose track of them, especially when the expanded sections are very large. I experimented with auto-collapsing all sections on drag, but this felt extremely disorienting because you instantly lost all of your context - so, to improve the UI here, I added a "scroll to" effect on drop like so: https://github.com/user-attachments/assets/0b519783-a4f5-4590-9a1c-580df66a2f66 Reminder that, to test this feature, you need to run Kibana with examples via `yarn start --run-examples` and navigate to the grid examples app via `Analytics > Developer examples > Grid Example`. ### 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) ### Identify risks Collapsible sections are not available on Dashboard yet and so there is no user-facing risk to this PR.
1 parent f67b320 commit 39abd8f

26 files changed

+686
-203
lines changed

examples/grid_example/public/app.tsx

Lines changed: 51 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import {
2323
EuiFlexItem,
2424
EuiPageTemplate,
2525
EuiSpacer,
26+
UseEuiTheme,
2627
transparentize,
27-
useEuiTheme,
2828
} from '@elastic/eui';
2929
import { css } from '@emotion/react';
3030
import { AppMountParameters } from '@kbn/core-application-browser';
@@ -58,8 +58,6 @@ export const GridExample = ({
5858
coreStart: CoreStart;
5959
uiActions: UiActionsStart;
6060
}) => {
61-
const { euiTheme } = useEuiTheme();
62-
6361
const savedState = useRef<MockSerializedDashboardState>(getSerializedDashboardState());
6462
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
6563
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
@@ -90,8 +88,8 @@ export const GridExample = ({
9088
const currentPanel = panels[panelId];
9189
const savedPanel = savedState.current.panels[panelId];
9290
panelsAreEqual = deepEqual(
93-
{ row: 'first', ...currentPanel.gridData },
94-
{ row: 'first', ...savedPanel.gridData }
91+
{ row: 'first', ...currentPanel?.gridData },
92+
{ row: 'first', ...savedPanel?.gridData }
9593
);
9694
}
9795
const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows));
@@ -173,41 +171,6 @@ export const GridExample = ({
173171
mockDashboardApi.rows$.next(rows);
174172
}, [mockDashboardApi.panels$, mockDashboardApi.rows$]);
175173

176-
const customLayoutCss = useMemo(() => {
177-
const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2);
178-
return css`
179-
.kbnGridRow--targeted {
180-
background-position: top calc((var(--kbnGridGutterSize) / 2) * -1px) left
181-
calc((var(--kbnGridGutterSize) / 2) * -1px);
182-
background-size: calc((var(--kbnGridColumnWidth) + var(--kbnGridGutterSize)) * 1px)
183-
calc((var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) * 1px);
184-
background-image: linear-gradient(to right, ${gridColor} 1px, transparent 1px),
185-
linear-gradient(to bottom, ${gridColor} 1px, transparent 1px);
186-
background-color: ${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.1)};
187-
}
188-
189-
.kbnGridPanel--dragPreview {
190-
border-radius: ${euiTheme.border.radius};
191-
background-color: ${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2)};
192-
transition: opacity 100ms linear;
193-
}
194-
195-
.kbnGridPanel--resizeHandle {
196-
opacity: 0;
197-
transition: opacity 0.2s, border 0.2s;
198-
border-radius: 7px 0 7px 0;
199-
border-bottom: 2px solid ${euiTheme.colors.accentSecondary};
200-
border-right: 2px solid ${euiTheme.colors.accentSecondary};
201-
&:hover,
202-
&:focus {
203-
outline-style: none !important;
204-
opacity: 1;
205-
background-color: ${transparentize(euiTheme.colors.accentSecondary, 0.05)};
206-
}
207-
}
208-
`;
209-
}, [euiTheme]);
210-
211174
return (
212175
<KibanaRenderContextProvider {...coreStart}>
213176
<EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
@@ -314,7 +277,7 @@ export const GridExample = ({
314277
useCustomDragHandle={true}
315278
renderPanelContents={renderPanelContents}
316279
onLayoutChange={onLayoutChange}
317-
css={customLayoutCss}
280+
css={layoutStyles}
318281
/>
319282
</EuiPageTemplate.Section>
320283
</EuiPageTemplate>
@@ -330,3 +293,50 @@ export const renderGridExampleApp = (
330293

331294
return () => ReactDOM.unmountComponentAtNode(element);
332295
};
296+
297+
const layoutStyles = ({ euiTheme }: UseEuiTheme) => {
298+
const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2);
299+
return css({
300+
// background for grid row that is being targetted
301+
'.kbnGridRow--targeted': {
302+
backgroundPosition: `top calc((var(--kbnGridGutterSize) / 2) * -1px) left calc((var(--kbnGridGutterSize) / 2) * -1px)`,
303+
backgroundSize: `calc((var(--kbnGridColumnWidth) + var(--kbnGridGutterSize)) * 1px) calc((var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) * 1px)`,
304+
backgroundImage: `linear-gradient(to right, ${gridColor} 1px, transparent 1px), linear-gradient(to bottom, ${gridColor} 1px, transparent 1px)`,
305+
backgroundColor: `${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.1)}`,
306+
},
307+
// styling for the "locked to grid" preview for what the panel will look like when dropped / resized
308+
'.kbnGridPanel--dragPreview': {
309+
borderRadius: `${euiTheme.border.radius}`,
310+
backgroundColor: `${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2)}`,
311+
transition: `opacity 100ms linear`,
312+
},
313+
// styling for panel resize handle
314+
'.kbnGridPanel--resizeHandle': {
315+
opacity: '0',
316+
transition: `opacity 0.2s, border 0.2s`,
317+
borderRadius: `7px 0 7px 0`,
318+
borderBottom: `2px solid ${euiTheme.colors.accentSecondary}`,
319+
borderRight: `2px solid ${euiTheme.colors.accentSecondary}`,
320+
'&:hover, &:focus': {
321+
outlineStyle: `none !important`,
322+
opacity: 1,
323+
backgroundColor: `${transparentize(euiTheme.colors.accentSecondary, 0.05)}`,
324+
},
325+
},
326+
// styling for what the grid row header looks like when being dragged
327+
'.kbnGridRowHeader--active': {
328+
backgroundColor: euiTheme.colors.backgroundBasePlain,
329+
border: `1px solid ${euiTheme.border.color}`,
330+
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}`,
331+
paddingLeft: '8px',
332+
// hide accordian arrow + panel count text when row is being dragged
333+
'& .kbnGridRowTitle--button svg, & .kbnGridLayout--panelCount': {
334+
display: 'none',
335+
},
336+
},
337+
// styles for the area where the row will be dropped
338+
'.kbnGridPanel--rowDragPreview': {
339+
backgroundColor: euiTheme.components.dragDropDraggingBackground,
340+
},
341+
});
342+
};

src/platform/packages/private/kbn-grid-layout/grid/grid_layout.test.tsx

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -117,40 +117,66 @@ describe('GridLayout', () => {
117117
expect(onLayoutChange).toBeCalledTimes(1);
118118
});
119119

120-
it(`'renderPanelContents' is not called during dragging`, () => {
121-
renderGridLayout();
122-
123-
// assert that renderPanelContents has been called ONLY ONCE for each of 10 panels on initial render
124-
expect(mockRenderPanelContents).toHaveBeenCalledTimes(expectedInitPanelIdsInOrder.length);
125-
jest.clearAllMocks();
120+
describe('dragging rows', () => {
121+
beforeAll(() => {
122+
// scroll into view is not mocked by RTL so we need to add this to prevent these tests from throwing
123+
Element.prototype.scrollIntoView = jest.fn();
124+
});
126125

127-
const panelHandle = getPanelHandle('panel1');
128-
mouseStartDragging(panelHandle);
129-
mouseMoveTo({ clientX: 256, clientY: 128 });
126+
it('row gets active when dragged', () => {
127+
renderGridLayout();
128+
expect(screen.getByTestId('kbnGridRowHeader-second')).not.toHaveClass(
129+
'kbnGridRowHeader--active'
130+
);
130131

131-
// assert that renderPanelContents has not been called during dragging
132-
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
132+
const rowHandle = screen.getByTestId(`kbnGridRowHeader-second--dragHandle`);
133+
mouseStartDragging(rowHandle);
134+
mouseMoveTo({ clientX: 256, clientY: 128 });
135+
expect(screen.getByTestId('kbnGridRowHeader-second')).toHaveClass('kbnGridRowHeader--active');
133136

134-
mouseDrop(panelHandle);
135-
// assert that renderPanelContents has not been called after reordering
136-
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
137+
mouseDrop(rowHandle);
138+
expect(screen.getByTestId('kbnGridRowHeader-second')).not.toHaveClass(
139+
'kbnGridRowHeader--active'
140+
);
141+
});
137142
});
138143

139-
it('panel gets active when dragged', () => {
140-
renderGridLayout();
141-
const panelHandle = getPanelHandle('panel1');
142-
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
143-
exact: true,
144+
describe('dragging panels', () => {
145+
it(`'renderPanelContents' is not called during dragging`, () => {
146+
renderGridLayout();
147+
148+
// assert that renderPanelContents has been called ONLY ONCE for each of 10 panels on initial render
149+
expect(mockRenderPanelContents).toHaveBeenCalledTimes(expectedInitPanelIdsInOrder.length);
150+
jest.clearAllMocks();
151+
152+
const panelHandle = getPanelHandle('panel1');
153+
mouseStartDragging(panelHandle);
154+
mouseMoveTo({ clientX: 256, clientY: 128 });
155+
156+
// assert that renderPanelContents has not been called during dragging
157+
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
158+
159+
mouseDrop(panelHandle);
160+
// assert that renderPanelContents has not beesn called after reordering
161+
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
144162
});
145-
mouseStartDragging(panelHandle);
146-
mouseMoveTo({ clientX: 256, clientY: 128 });
147-
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
148-
'kbnGridPanel kbnGridPanel--active',
149-
{ exact: true }
150-
);
151-
mouseDrop(panelHandle);
152-
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
153-
exact: true,
163+
164+
it('panel gets active when dragged', () => {
165+
renderGridLayout();
166+
const panelHandle = getPanelHandle('panel1');
167+
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
168+
exact: true,
169+
});
170+
mouseStartDragging(panelHandle);
171+
mouseMoveTo({ clientX: 256, clientY: 128 });
172+
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
173+
'kbnGridPanel kbnGridPanel--active',
174+
{ exact: true }
175+
);
176+
mouseDrop(panelHandle);
177+
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
178+
exact: true,
179+
});
154180
});
155181
});
156182

@@ -163,6 +189,7 @@ describe('GridLayout', () => {
163189
await assertTabThroughPanel('panel2');
164190
await assertTabThroughPanel('panel3');
165191
});
192+
166193
it('on initializing', () => {
167194
renderGridLayout();
168195
expect(getAllThePanelIds()).toEqual(expectedInitPanelIdsInOrder);
@@ -191,6 +218,7 @@ describe('GridLayout', () => {
191218
'panel10',
192219
]);
193220
});
221+
194222
it('after reordering some panels via touch events', async () => {
195223
renderGridLayout();
196224

src/platform/packages/private/kbn-grid-layout/grid/grid_layout.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import classNames from 'classnames';
1111
import deepEqual from 'fast-deep-equal';
1212
import { cloneDeep } from 'lodash';
1313
import React, { useEffect, useMemo, useRef, useState } from 'react';
14-
import { combineLatest, pairwise } from 'rxjs';
14+
import { combineLatest, pairwise, map, distinctUntilChanged } from 'rxjs';
1515

1616
import { css } from '@emotion/react';
1717

@@ -90,6 +90,23 @@ export const GridLayout = ({
9090
}
9191
});
9292

93+
/**
94+
* This subscription ensures that rows get re-rendered when their orders change
95+
*/
96+
const rowOrderSubscription = combineLatest([
97+
gridLayoutStateManager.proposedGridLayout$,
98+
gridLayoutStateManager.gridLayout$,
99+
])
100+
.pipe(
101+
map(([proposedGridLayout, gridLayout]) =>
102+
getRowKeysInOrder(proposedGridLayout ?? gridLayout)
103+
),
104+
distinctUntilChanged(deepEqual)
105+
)
106+
.subscribe((rowKeys) => {
107+
setRowIdsInOrder(rowKeys);
108+
});
109+
93110
/**
94111
* This subscription adds and/or removes the necessary class names related to styling for
95112
* mobile view and a static (non-interactable) grid layout
@@ -115,6 +132,7 @@ export const GridLayout = ({
115132

116133
return () => {
117134
onLayoutChangeSubscription.unsubscribe();
135+
rowOrderSubscription.unsubscribe();
118136
gridLayoutClassSubscription.unsubscribe();
119137
};
120138
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -160,7 +178,7 @@ const styles = {
160178
padding: 'calc(var(--kbnGridGutterSize) * 1px)',
161179
}),
162180
hasActivePanel: css({
163-
'&:has(.kbnGridPanel--active)': {
181+
'&:has(.kbnGridPanel--active), &:has(.kbnGridRowHeader--active)': {
164182
// disable pointer events and user select on drag + resize
165183
userSelect: 'none',
166184
pointerEvents: 'none',

src/platform/packages/private/kbn-grid-layout/grid/grid_panel/drag_handle/default_drag_handle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const styles = ({ euiTheme }: UseEuiTheme) =>
4747
border: `1px solid ${euiTheme.border.color}`,
4848
borderBottom: 'none',
4949
backgroundColor: euiTheme.colors.backgroundBasePlain,
50-
borderRadius: `${euiTheme.border.radius} ${euiTheme.border.radius} 0 0`,
50+
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium} 0 0`,
5151
transition: `${euiTheme.animation.slow} opacity`,
5252
touchAction: 'none',
5353
'.kbnGridPanel:hover &, .kbnGridPanel:focus-within &, &:active, &:focus': {

src/platform/packages/private/kbn-grid-layout/grid/grid_panel/drag_handle/use_drag_handle_api.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
import { useCallback, useEffect, useRef } from 'react';
1111

12-
import { useGridLayoutEvents } from '../../use_grid_layout_events';
13-
import { UserInteractionEvent } from '../../use_grid_layout_events/types';
1412
import { useGridLayoutContext } from '../../use_grid_layout_context';
13+
import { useGridLayoutPanelEvents } from '../../use_grid_layout_events';
14+
import { UserInteractionEvent } from '../../use_grid_layout_events/types';
1515

1616
export interface DragHandleApi {
1717
startDrag: (e: UserInteractionEvent) => void;
@@ -27,7 +27,7 @@ export const useDragHandleApi = ({
2727
}): DragHandleApi => {
2828
const { useCustomDragHandle } = useGridLayoutContext();
2929

30-
const startInteraction = useGridLayoutEvents({
30+
const startInteraction = useGridLayoutPanelEvents({
3131
interactionType: 'drag',
3232
panelId,
3333
rowId,

src/platform/packages/private/kbn-grid-layout/grid/grid_panel/grid_panel.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { css } from '@emotion/react';
1616
import { useGridLayoutContext } from '../use_grid_layout_context';
1717
import { DefaultDragHandle } from './drag_handle/default_drag_handle';
1818
import { useDragHandleApi } from './drag_handle/use_drag_handle_api';
19-
import { ResizeHandle } from './resize_handle';
19+
import { ResizeHandle } from './grid_panel_resize_handle';
2020

2121
export interface GridPanelProps {
2222
panelId: string;
@@ -50,6 +50,14 @@ export const GridPanel = React.memo(({ panelId, rowId }: GridPanelProps) => {
5050
`;
5151
}, [gridLayoutStateManager, rowId, panelId]);
5252

53+
useEffect(() => {
54+
return () => {
55+
// remove reference on unmount
56+
delete gridLayoutStateManager.panelRefs.current[rowId][panelId];
57+
};
58+
// eslint-disable-next-line react-hooks/exhaustive-deps
59+
}, []);
60+
5361
useEffect(
5462
() => {
5563
/** Update the styles of the panel via a subscription to prevent re-renders */

src/platform/packages/private/kbn-grid-layout/grid/drag_preview.tsx renamed to src/platform/packages/private/kbn-grid-layout/grid/grid_panel/grid_panel_drag_preview.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import React, { useEffect, useRef } from 'react';
1111
import { combineLatest, skip } from 'rxjs';
1212

1313
import { css } from '@emotion/react';
14-
import { useGridLayoutContext } from './use_grid_layout_context';
14+
import { useGridLayoutContext } from '../use_grid_layout_context';
1515

16-
export const DragPreview = React.memo(({ rowId }: { rowId: string }) => {
16+
export const GridPanelDragPreview = React.memo(({ rowId }: { rowId: string }) => {
1717
const { gridLayoutStateManager } = useGridLayoutContext();
1818

1919
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
@@ -54,4 +54,4 @@ export const DragPreview = React.memo(({ rowId }: { rowId: string }) => {
5454

5555
const styles = css({ display: 'none', pointerEvents: 'none' });
5656

57-
DragPreview.displayName = 'KbnGridLayoutDragPreview';
57+
GridPanelDragPreview.displayName = 'KbnGridLayoutPanelDragPreview';

src/platform/packages/private/kbn-grid-layout/grid/grid_panel/resize_handle.tsx renamed to src/platform/packages/private/kbn-grid-layout/grid/grid_panel/grid_panel_resize_handle.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import type { UseEuiTheme } from '@elastic/eui';
1313
import { css } from '@emotion/react';
1414
import { i18n } from '@kbn/i18n';
1515

16-
import { useGridLayoutEvents } from '../use_grid_layout_events';
16+
import { useGridLayoutPanelEvents } from '../use_grid_layout_events';
1717

1818
export const ResizeHandle = React.memo(({ rowId, panelId }: { rowId: string; panelId: string }) => {
19-
const startInteraction = useGridLayoutEvents({
19+
const startInteraction = useGridLayoutPanelEvents({
2020
interactionType: 'resize',
2121
panelId,
2222
rowId,

src/platform/packages/private/kbn-grid-layout/grid/grid_row/delete_grid_row_modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import {
1919
} from '@elastic/eui';
2020
import { i18n } from '@kbn/i18n';
2121

22-
import { deleteRow, movePanelsToRow } from '../utils/row_management';
2322
import { useGridLayoutContext } from '../use_grid_layout_context';
23+
import { deleteRow, movePanelsToRow } from '../utils/row_management';
2424

2525
export const DeleteGridRowModal = ({
2626
rowId,

0 commit comments

Comments
 (0)