[8.x] [Dashboard][Collapsable Panels] New collision resolution algorithm (#204134) (#205509)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Dashboard][Collapsable Panels] New collision resolution
algorithm](https://github.com/elastic/kibana/pull/204134)

<!--- Backport version: 8.9.8 -->
This commit is contained in:
Hannah Mudge 2025-01-03 10:58:23 -07:00 committed by GitHub
parent eca9ebccac
commit 1ab448126b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 222 additions and 27 deletions

View file

@ -69,15 +69,19 @@ export const GridExample = ({
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(
Object.values(panels).map(({ gridData }) => ({ row: 0, ...gridData })),
Object.values(savedState.current.panels).map(({ gridData }) => ({
row: 0, // if row is undefined, then default to 0
...gridData,
}))
) && deepEqual(rows, savedState.current.rows)
);
const panelIds = Object.keys(panels);
let panelsAreEqual = true;
for (const panelId of panelIds) {
if (!panelsAreEqual) break;
const currentPanel = panels[panelId];
const savedPanel = savedState.current.panels[panelId];
panelsAreEqual = deepEqual(
{ row: 0, ...currentPanel.gridData },
{ row: 0, ...savedPanel.gridData }
);
}
const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows));
setHasUnsavedChanges(hasChanges);
setCurrentLayout(dashboardInputToGridLayout({ panels, rows }));
});

View file

@ -46,6 +46,8 @@ export const useMockDashboardApi = ({
from: 'now-24h',
to: 'now',
}),
filters$: new BehaviorSubject([]),
query$: new BehaviorSubject(''),
viewMode: new BehaviorSubject('edit'),
panels$,
rows$: new BehaviorSubject<MockedDashboardRowMap>(savedState.rows),

View file

@ -32,6 +32,7 @@ describe('GridLayout', () => {
rerender(<GridLayout {...defaultProps} {...overrides} />),
};
};
const getAllThePanelIds = () =>
screen
.getAllByRole('button', { name: /panelId:panel/i })
@ -40,9 +41,11 @@ describe('GridLayout', () => {
const startDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => {
fireEvent.mouseDown(handle, options);
};
const moveTo = (options = { clientX: 256, clientY: 128 }) => {
fireEvent.mouseMove(document, options);
};
const drop = (handle: HTMLElement) => {
fireEvent.mouseUp(handle);
};
@ -123,6 +126,7 @@ describe('GridLayout', () => {
'panel10',
]);
});
it('after removing a panel', async () => {
const { rerender } = renderGridLayout();
const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout());
@ -141,6 +145,7 @@ describe('GridLayout', () => {
'panel10',
]);
});
it('after replacing a panel id', async () => {
const { rerender } = renderGridLayout();
const modifiedLayout = cloneDeep(getSampleLayout());

View file

@ -0,0 +1,155 @@
/*
* 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 { resolveGridRow } from './resolve_grid_row';
describe('resolve grid row', () => {
test('does nothing if grid row has no collisions', () => {
const gridRow = {
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 },
},
};
const result = resolveGridRow(gridRow);
expect(result).toEqual(gridRow);
});
test('resolves grid row if it has collisions without drag event', () => {
const result = resolveGridRow({
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 },
panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 },
},
});
expect(result).toEqual({
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down
panel4: { id: 'panel4', row: 3, column: 3, height: 5, width: 4 }, // pushed down
},
});
});
test('drag causes no collision', () => {
const result = resolveGridRow(
{
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
},
},
{ id: 'panel4', row: 0, column: 7, height: 3, width: 1 }
);
expect(result).toEqual({
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 7, height: 3, width: 1 },
},
});
});
test('drag causes collision with one panel that pushes down others', () => {
const result = resolveGridRow(
{
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 8 },
panel4: { id: 'panel4', row: 3, column: 4, height: 3, width: 4 },
},
},
{ id: 'panel5', row: 2, column: 0, height: 3, width: 3 }
);
expect(result).toEqual({
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 8 }, // pushed down
panel4: { id: 'panel4', row: 6, column: 4, height: 3, width: 4 }, // pushed down
panel5: { id: 'panel5', row: 2, column: 0, height: 3, width: 3 },
},
});
});
test('drag causes collision with multiple panels', () => {
const result = resolveGridRow(
{
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 },
},
},
{ id: 'panel4', row: 0, column: 3, height: 5, width: 4 }
);
expect(result).toEqual({
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 5, column: 0, height: 3, width: 4 }, // pushed down
panel2: { id: 'panel2', row: 8, column: 0, height: 2, width: 2 }, // pushed down
panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down
panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 },
},
});
});
test('drag causes collision with every panel', () => {
const result = resolveGridRow(
{
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
},
},
{ id: 'panel4', row: 0, column: 6, height: 3, width: 1 }
);
expect(result).toEqual({
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 },
},
});
});
});

View file

@ -34,6 +34,18 @@ const getAllCollisionsWithPanel = (
return collidingPanels;
};
const getFirstCollision = (gridLayout: GridRowData, keysInOrder: string[]): string | undefined => {
for (const panelA of keysInOrder) {
for (const panelB of keysInOrder) {
if (panelA === panelB) continue;
if (collides(gridLayout.panels[panelA], gridLayout.panels[panelB])) {
return panelA;
}
}
}
return undefined;
};
export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string): string[] => {
const panelKeys = Object.keys(panels);
return panelKeys.sort((panelKeyA, panelKeyB) => {
@ -81,28 +93,45 @@ export const resolveGridRow = (
originalRowData: GridRowData,
dragRequest?: GridPanelData
): GridRowData => {
const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } };
// Apply drag request
let nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } };
// apply drag request
if (dragRequest) {
nextRowData.panels[dragRequest.id] = dragRequest;
}
// return nextRowData;
// push all panels down if they collide with another panel
// 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);
for (const key of sortedKeys) {
const panel = nextRowData.panels[key];
const collisions = getAllCollisionsWithPanel(panel, nextRowData, sortedKeys);
for (const collision of collisions) {
const rowOverlap = panel.row + panel.height - collision.row;
if (rowOverlap > 0) {
collision.row += rowOverlap;
}
}
// while the layout has at least one collision, try to resolve them in order
let collision = getFirstCollision(nextRowData, sortedKeys);
while (collision !== undefined) {
nextRowData = resolvePanelCollisions(nextRowData, nextRowData.panels[collision], sortedKeys);
collision = getFirstCollision(nextRowData, sortedKeys);
}
const compactedGrid = compactGridRow(nextRowData);
return compactedGrid;
return compactGridRow(nextRowData); // compact the grid to close any gaps
};
/**
* for each panel that collides with `panelToResolve`, push the colliding panel down by a single row and
* recursively handle any collisions that result from that move
*/
function resolvePanelCollisions(
rowData: GridRowData,
panelToResolve: GridPanelData,
keysInOrder: string[]
): GridRowData {
const collisions = getAllCollisionsWithPanel(panelToResolve, rowData, keysInOrder);
for (const collision of collisions) {
if (collision.id === panelToResolve.id) continue;
rowData.panels[collision.id].row++;
rowData = resolvePanelCollisions(
rowData,
rowData.panels[collision.id],
/**
* when recursively resolving any collisions that result from moving this colliding panel down,
* ignore if `collision` is still colliding with `panelToResolve` to prevent an infinite loop
*/
keysInOrder.filter((key) => key !== panelToResolve.id)
);
}
return rowData;
}