diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 9519fd2af43a9..4f0a6c4a1dfa7 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -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 })); }); diff --git a/examples/grid_example/public/use_mock_dashboard_api.tsx b/examples/grid_example/public/use_mock_dashboard_api.tsx index 51933f3a038e4..5b26b6c7eca02 100644 --- a/examples/grid_example/public/use_mock_dashboard_api.tsx +++ b/examples/grid_example/public/use_mock_dashboard_api.tsx @@ -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(savedState.rows), diff --git a/packages/kbn-grid-layout/grid/grid_layout.test.tsx b/packages/kbn-grid-layout/grid/grid_layout.test.tsx index 33b1bad784618..f28703f748bf7 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.test.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.test.tsx @@ -32,6 +32,7 @@ describe('GridLayout', () => { rerender(), }; }; + 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()); diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts new file mode 100644 index 0000000000000..b194e89c3241e --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts @@ -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 }, + }, + }); + }); +}); diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 38b778b5d0571..d41e3216ec1fb 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -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; +}