diff --git a/packages/kbn-grid-layout/grid/grid_layout.test.tsx b/packages/kbn-grid-layout/grid/grid_layout.test.tsx new file mode 100644 index 0000000000000..33b1bad784618 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_layout.test.tsx @@ -0,0 +1,167 @@ +/* + * 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 React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getSampleLayout } from './test_utils/sample_layout'; +import { GridLayout, GridLayoutProps } from './grid_layout'; +import { gridSettings, mockRenderPanelContents } from './test_utils/mocks'; +import { cloneDeep } from 'lodash'; + +describe('GridLayout', () => { + const renderGridLayout = (propsOverrides: Partial = {}) => { + const defaultProps: GridLayoutProps = { + accessMode: 'EDIT', + layout: getSampleLayout(), + gridSettings, + renderPanelContents: mockRenderPanelContents, + onLayoutChange: jest.fn(), + }; + + const { rerender, ...rtlRest } = render(); + + return { + ...rtlRest, + rerender: (overrides: Partial) => + rerender(), + }; + }; + const getAllThePanelIds = () => + screen + .getAllByRole('button', { name: /panelId:panel/i }) + .map((el) => el.getAttribute('aria-label')?.replace(/panelId:/g, '')); + + 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); + }; + + const assertTabThroughPanel = async (panelId: string) => { + await userEvent.tab(); // tab to drag handle + await userEvent.tab(); // tab to the panel + expect(screen.getByLabelText(`panelId:${panelId}`)).toHaveFocus(); + await userEvent.tab(); // tab to the resize handle + }; + + const expectedInitialOrder = [ + 'panel1', + 'panel5', + 'panel2', + 'panel3', + 'panel7', + 'panel6', + 'panel8', + 'panel4', + 'panel9', + 'panel10', + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it(`'renderPanelContents' is not called during dragging`, () => { + renderGridLayout(); + + expect(mockRenderPanelContents).toHaveBeenCalledTimes(10); // renderPanelContents is called for each of 10 panels + jest.clearAllMocks(); + + const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; + startDragging(panel1DragHandle); + moveTo({ clientX: 256, clientY: 128 }); + expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called during dragging + + drop(panel1DragHandle); + expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called after reordering + }); + + describe('panels order: panels are rendered from left to right, from top to bottom', () => { + it('focus management - tabbing through the panels', async () => { + renderGridLayout(); + // we only test a few panels because otherwise that test would execute for too long + await assertTabThroughPanel('panel1'); + await assertTabThroughPanel('panel5'); + await assertTabThroughPanel('panel2'); + await assertTabThroughPanel('panel3'); + }); + it('on initializing', () => { + renderGridLayout(); + expect(getAllThePanelIds()).toEqual(expectedInitialOrder); + }); + + it('after reordering some panels', async () => { + renderGridLayout(); + + const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; + startDragging(panel1DragHandle); + + moveTo({ clientX: 256, clientY: 128 }); + expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we drop + + drop(panel1DragHandle); + expect(getAllThePanelIds()).toEqual([ + 'panel2', + 'panel5', + 'panel3', + 'panel7', + 'panel1', + 'panel8', + 'panel6', + 'panel4', + 'panel9', + 'panel10', + ]); + }); + it('after removing a panel', async () => { + const { rerender } = renderGridLayout(); + const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout()); + delete sampleLayoutWithoutPanel1[0].panels.panel1; + rerender({ layout: sampleLayoutWithoutPanel1 }); + + expect(getAllThePanelIds()).toEqual([ + 'panel2', + 'panel5', + 'panel3', + 'panel7', + 'panel6', + 'panel8', + 'panel4', + 'panel9', + 'panel10', + ]); + }); + 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; + + rerender({ layout: modifiedLayout }); + + expect(getAllThePanelIds()).toEqual([ + 'panel11', + 'panel5', + 'panel2', + 'panel3', + 'panel7', + 'panel6', + 'panel8', + 'panel4', + 'panel9', + 'panel10', + ]); + }); + }); +}); diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 2a14456b1ef62..1406d4b6eb55d 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -21,7 +21,7 @@ import { useGridLayoutState } from './use_grid_layout_state'; import { isLayoutEqual } from './utils/equality_checks'; import { resolveGridRow } from './utils/resolve_grid_row'; -interface GridLayoutProps { +export interface GridLayoutProps { layout: GridLayoutData; gridSettings: GridSettings; renderPanelContents: (panelId: string) => React.ReactNode; @@ -121,11 +121,6 @@ export const GridLayout = ({ rowIndex={rowIndex} renderPanelContents={renderPanelContents} gridLayoutStateManager={gridLayoutStateManager} - toggleIsCollapsed={() => { - const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); - newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; - gridLayoutStateManager.gridLayout$.next(newLayout); - }} setInteractionEvent={(nextInteractionEvent) => { if (!nextInteractionEvent) { gridLayoutStateManager.activePanel$.next(undefined); diff --git a/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx new file mode 100644 index 0000000000000..90305812ff8d5 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx @@ -0,0 +1,74 @@ +/* + * 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 React from 'react'; + +import { EuiIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { i18n } from '@kbn/i18n'; +import { PanelInteractionEvent } from '../types'; + +export const DragHandle = ({ + interactionStart, +}: { + interactionStart: ( + type: PanelInteractionEvent['type'] | 'drop', + e: React.MouseEvent + ) => void; +}) => { + const { euiTheme } = useEuiTheme(); + return ( + + ); +}; diff --git a/packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx new file mode 100644 index 0000000000000..2829a320abab4 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GridPanel, GridPanelProps } from './grid_panel'; +import { gridLayoutStateManagerMock } from '../test_utils/mocks'; + +describe('GridPanel', () => { + const mockRenderPanelContents = jest.fn((panelId) =>
Panel Content {panelId}
); + const mockInteractionStart = jest.fn(); + + const renderGridPanel = (propsOverrides: Partial = {}) => { + return render( + + ); + }; + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders panel contents correctly', () => { + renderGridPanel(); + expect(screen.getByText('Panel Content panel1')).toBeInTheDocument(); + }); + + describe('drag handle interaction', () => { + it('calls `drag` interactionStart on mouse down', () => { + renderGridPanel(); + const dragHandle = screen.getByRole('button', { name: /drag to move/i }); + fireEvent.mouseDown(dragHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('drag', expect.any(Object)); + }); + it('calls `drop` interactionStart on mouse up', () => { + renderGridPanel(); + const dragHandle = screen.getByRole('button', { name: /drag to move/i }); + fireEvent.mouseUp(dragHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('drop', expect.any(Object)); + }); + }); + describe('resize handle interaction', () => { + it('calls `resize` interactionStart on mouse down', () => { + renderGridPanel(); + const resizeHandle = screen.getByRole('button', { name: /resize/i }); + fireEvent.mouseDown(resizeHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('resize', expect.any(Object)); + }); + it('calls `drop` interactionStart on mouse up', () => { + renderGridPanel(); + const resizeHandle = screen.getByRole('button', { name: /resize/i }); + fireEvent.mouseUp(resizeHandle); + expect(mockInteractionStart).toHaveBeenCalledWith('drop', expect.any(Object)); + }); + }); +}); diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx similarity index 67% rename from packages/kbn-grid-layout/grid/grid_panel.tsx rename to packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx index 91f935f4507f1..e817f5fc3871b 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx @@ -10,39 +10,30 @@ import React, { forwardRef, useEffect, useMemo } from 'react'; import { combineLatest, skip } from 'rxjs'; -import { - EuiIcon, - EuiPanel, - euiFullHeight, - transparentize, - useEuiOverflowScroll, - useEuiTheme, -} from '@elastic/eui'; +import { EuiPanel, euiFullHeight, useEuiOverflowScroll } from '@elastic/eui'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; - -import { GridLayoutStateManager, PanelInteractionEvent } from './types'; -import { getKeysInOrder } from './utils/resolve_grid_row'; - -export const GridPanel = forwardRef< - HTMLDivElement, - { - panelId: string; - rowIndex: number; - renderPanelContents: (panelId: string) => React.ReactNode; - interactionStart: ( - type: PanelInteractionEvent['type'] | 'drop', - e: React.MouseEvent - ) => void; - gridLayoutStateManager: GridLayoutStateManager; - } ->( +import { GridLayoutStateManager, PanelInteractionEvent } from '../types'; +import { getKeysInOrder } from '../utils/resolve_grid_row'; +import { DragHandle } from './drag_handle'; +import { ResizeHandle } from './resize_handle'; + +export interface GridPanelProps { + panelId: string; + rowIndex: number; + renderPanelContents: (panelId: string) => React.ReactNode; + interactionStart: ( + type: PanelInteractionEvent['type'] | 'drop', + e: React.MouseEvent + ) => void; + gridLayoutStateManager: GridLayoutStateManager; +} + +export const GridPanel = forwardRef( ( { panelId, rowIndex, renderPanelContents, interactionStart, gridLayoutStateManager }, panelRef ) => { - const { euiTheme } = useEuiTheme(); - /** Set initial styles based on state at mount to prevent styles from "blipping" */ const initialStyles = useMemo(() => { const initialPanel = gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels[panelId]; @@ -158,7 +149,7 @@ export const GridPanel = forwardRef< const panel = allPanels[panelId]; if (!ref || !panel) return; - const sortedKeys = getKeysInOrder(gridLayout[rowIndex]); + const sortedKeys = getKeysInOrder(gridLayout[rowIndex].panels); const currentPanelPosition = sortedKeys.indexOf(panelId); const sortedKeysBefore = sortedKeys.slice(0, currentPanelPosition); const responsiveGridRowStart = sortedKeysBefore.reduce( @@ -180,7 +171,6 @@ export const GridPanel = forwardRef< // eslint-disable-next-line react-hooks/exhaustive-deps [] ); - /** * Memoize panel contents to prevent unnecessary re-renders */ @@ -189,93 +179,29 @@ export const GridPanel = forwardRef< }, [panelId, renderPanelContents]); return ( - <> -
- + + +
- {/* drag handle */} -
interactionStart('drag', e)} - onMouseUp={(e) => interactionStart('drop', e)} - > - -
- {/* Resize handle */} -
interactionStart('resize', e)} - onMouseUp={(e) => interactionStart('drop', e)} - css={css` - right: 0; - bottom: 0; - opacity: 0; - margin: -2px; - position: absolute; - width: ${euiThemeVars.euiSizeL}; - height: ${euiThemeVars.euiSizeL}; - transition: opacity 0.2s, border 0.2s; - border-radius: 7px 0 7px 0; - border-bottom: 2px solid ${euiThemeVars.euiColorSuccess}; - border-right: 2px solid ${euiThemeVars.euiColorSuccess}; - :hover { - opacity: 1; - background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; - cursor: se-resize; - } - .kbnGrid--static & { - opacity: 0 !important; - display: none; - } - `} - /> -
- {panelContents} -
- -
- + {panelContents} +
+ +
+
); } ); diff --git a/packages/kbn-grid-layout/grid/grid_panel/index.tsx b/packages/kbn-grid-layout/grid/grid_panel/index.tsx new file mode 100644 index 0000000000000..e286fc92fd9f7 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/index.tsx @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { GridPanel } from './grid_panel'; diff --git a/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx new file mode 100644 index 0000000000000..4c4a2d60ee5cb --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx @@ -0,0 +1,70 @@ +/* + * 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 React from 'react'; + +import { transparentize } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { i18n } from '@kbn/i18n'; +import { PanelInteractionEvent } from '../types'; + +export const ResizeHandle = ({ + interactionStart, +}: { + interactionStart: ( + type: PanelInteractionEvent['type'] | 'drop', + e: React.MouseEvent + ) => void; +}) => { + return ( + +)); + +const runtimeSettings$ = new BehaviorSubject({ + ...gridSettings, + columnPixelWidth: 0, +}); + +export const gridLayoutStateManagerMock: GridLayoutStateManager = { + expandedPanelId$: new BehaviorSubject(undefined), + isMobileView$: new BehaviorSubject(false), + gridLayout$, + runtimeSettings$, + panelRefs: { current: [] }, + rowRefs: { current: [] }, + interactionEvent$: new BehaviorSubject(undefined), + activePanel$: new BehaviorSubject(undefined), + gridDimensions$: new BehaviorSubject({ width: 600, height: 900 }), +}; diff --git a/packages/kbn-grid-layout/grid/test_utils/sample_layout.ts b/packages/kbn-grid-layout/grid/test_utils/sample_layout.ts new file mode 100644 index 0000000000000..035a6f1dda2ee --- /dev/null +++ b/packages/kbn-grid-layout/grid/test_utils/sample_layout.ts @@ -0,0 +1,101 @@ +/* + * 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 { GridLayoutData } from '../types'; + +export const getSampleLayout = (): GridLayoutData => [ + { + title: 'Large section', + isCollapsed: false, + panels: { + panel1: { + id: 'panel1', + row: 0, + column: 0, + width: 12, + height: 6, + }, + panel2: { + id: 'panel2', + row: 6, + column: 0, + width: 8, + height: 4, + }, + panel3: { + id: 'panel3', + row: 6, + column: 8, + width: 12, + height: 4, + }, + panel4: { + id: 'panel4', + row: 10, + column: 0, + width: 48, + height: 4, + }, + panel5: { + id: 'panel5', + row: 0, + column: 12, + width: 36, + height: 6, + }, + panel6: { + id: 'panel6', + row: 6, + column: 24, + width: 24, + height: 4, + }, + panel7: { + id: 'panel7', + row: 6, + column: 20, + width: 4, + height: 2, + }, + panel8: { + id: 'panel8', + row: 8, + column: 20, + width: 4, + height: 2, + }, + }, + }, + { + title: 'Small section', + isCollapsed: false, + panels: { + panel9: { + id: 'panel9', + row: 0, + column: 0, + width: 12, + height: 16, + }, + }, + }, + { + title: 'Another small section', + isCollapsed: false, + panels: { + panel10: { + id: 'panel10', + row: 0, + column: 24, + width: 12, + height: 6, + }, + }, + }, +]; diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts index 9a6d6d2303909..64cc8f482838e 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -87,6 +87,7 @@ export const useGridLayoutEvents = ({ bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom, right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right, }; + gridLayoutStateManager.activePanel$.next({ id: interactionEvent.id, position: previewRect }); // find the grid that the preview rect is over 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 9a6f28d006e0a..38b778b5d0571 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -34,11 +34,11 @@ const getAllCollisionsWithPanel = ( return collidingPanels; }; -export const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => { - const panelKeys = Object.keys(rowData.panels); +export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string): string[] => { + const panelKeys = Object.keys(panels); return panelKeys.sort((panelKeyA, panelKeyB) => { - const panelA = rowData.panels[panelKeyA]; - const panelB = rowData.panels[panelKeyB]; + const panelA = panels[panelKeyA]; + const panelB = panels[panelKeyB]; // sort by row first if (panelA.row > panelB.row) return 1; @@ -60,7 +60,7 @@ export const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string const compactGridRow = (originalLayout: GridRowData) => { const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } }; // compact all vertical space. - const sortedKeysAfterMove = getKeysInOrder(nextRowData); + const sortedKeysAfterMove = getKeysInOrder(nextRowData.panels); for (const panelKey of sortedKeysAfterMove) { const panel = nextRowData.panels[panelKey]; // try moving panel up one row at a time until it collides @@ -90,7 +90,7 @@ export const resolveGridRow = ( // return nextRowData; // push all panels down if they collide with another panel - const sortedKeys = getKeysInOrder(nextRowData, dragRequest?.id); + const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id); for (const key of sortedKeys) { const panel = nextRowData.panels[key]; diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json index f0dd3232a42d5..bd16ae0f0adeb 100644 --- a/packages/kbn-grid-layout/tsconfig.json +++ b/packages/kbn-grid-layout/tsconfig.json @@ -2,12 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "target/types", - "types": [ - "jest", - "node", - "react", - "@emotion/react/types/css-prop" - ] }, "include": [ "**/*.ts",