From b9afffe56177f0e0683cd9142a7d55fdb41ea495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Tue, 5 Jul 2022 18:32:21 +0000 Subject: [PATCH 01/10] Expose scrollTo and scrollToItem on imperative ref --- src/components/datagrid/data_grid.tsx | 1 + src/components/datagrid/data_grid_types.ts | 11 +++++++++ src/components/datagrid/utils/ref.ts | 28 +++++++++++++++++++--- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index 6c105fccee8..00262028ee8 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -287,6 +287,7 @@ export const EuiDataGrid = forwardRef( */ useImperativeGridRef({ ref, + gridRef, setIsFullScreen, focusContext, cellPopoverContext, diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index e07d17fac40..77b134c296b 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -15,6 +15,7 @@ import { ReactElement, AriaAttributes, MutableRefObject, + Component, } from 'react'; import { VariableSizeGridProps, @@ -28,6 +29,8 @@ import { RowHeightUtils } from './utils/row_heights'; import { IconType } from '../icon'; import { EuiTokenProps } from '../token'; +export type ImperativeGridApi = Omit; + export interface EuiDataGridToolbarProps { gridWidth: number; minSizeForControls?: number; @@ -358,6 +361,14 @@ export interface EuiDataGridRefProps { * Closes any currently open popovers in the data grid. */ closeCellPopover: () => void; + /** + * Scrolls to a specified top and left offset. + */ + scrollTo?: ImperativeGridApi['scrollTo']; + /** + * Scrolls to a specified rowIndex. + */ + scrollToItem?: ImperativeGridApi['scrollToItem']; } export interface EuiDataGridColumnResizerProps { diff --git a/src/components/datagrid/utils/ref.ts b/src/components/datagrid/utils/ref.ts index 5c4d6a0085e..f3e2409651c 100644 --- a/src/components/datagrid/utils/ref.ts +++ b/src/components/datagrid/utils/ref.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { useImperativeHandle, useCallback, Ref } from 'react'; +import { useImperativeHandle, useCallback, Ref, RefObject } from 'react'; +import type { VariableSizeGrid } from 'react-window'; import { EuiDataGridRefProps, EuiDataGridProps, @@ -17,6 +18,7 @@ import { interface Dependencies { ref: Ref; + gridRef: RefObject; setIsFullScreen: EuiDataGridRefProps['setIsFullScreen']; focusContext: DataGridFocusContextShape; cellPopoverContext: DataGridCellPopoverContextShape; @@ -28,6 +30,7 @@ interface Dependencies { export const useImperativeGridRef = ({ ref, + gridRef, setIsFullScreen, focusContext, cellPopoverContext, @@ -75,16 +78,35 @@ export const useImperativeGridRef = ({ [_openCellPopover, checkCellExists, findVisibleRowIndex] ); + const scrollTo = useCallback( + (...args) => gridRef.current?.scrollTo(...args), + [gridRef] + ); + + const scrollToItem = useCallback( + (...args) => gridRef.current?.scrollToItem(...args), + [gridRef] + ); + // Set the ref APIs useImperativeHandle( ref, - () => ({ + (): EuiDataGridRefProps => ({ setIsFullScreen, setFocusedCell, openCellPopover, closeCellPopover, + scrollTo, + scrollToItem, }), - [setIsFullScreen, setFocusedCell, openCellPopover, closeCellPopover] + [ + setIsFullScreen, + setFocusedCell, + openCellPopover, + closeCellPopover, + scrollTo, + scrollToItem, + ] ); }; From 1a7b9769f7a29d7714f384ccef447b62d97f2fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Tue, 5 Jul 2022 18:34:53 +0000 Subject: [PATCH 02/10] Add method to compensate for layout shifts --- .../datagrid/body/data_grid_body.tsx | 4 +- src/components/datagrid/data_grid_types.ts | 8 + .../datagrid/utils/row_heights.test.ts | 283 ++++++++++++++---- src/components/datagrid/utils/row_heights.ts | 95 ++++-- src/services/hooks/index.ts | 1 + src/services/hooks/useLatest.ts | 15 + 6 files changed, 328 insertions(+), 78 deletions(-) create mode 100644 src/services/hooks/useLatest.ts diff --git a/src/components/datagrid/body/data_grid_body.tsx b/src/components/datagrid/body/data_grid_body.tsx index cefbeb804e9..07d378dbe1a 100644 --- a/src/components/datagrid/body/data_grid_body.tsx +++ b/src/components/datagrid/body/data_grid_body.tsx @@ -386,7 +386,9 @@ export const EuiDataGridBody: FunctionComponent = ( * Heights */ const rowHeightUtils = useRowHeightUtils({ - gridRef: gridRef.current, + gridRef, + outerGridElementRef: outerGridRef, + gridItemsRenderedRef: gridItemsRendered, gridStyles, columns, rowHeightsOptions, diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index 77b134c296b..0ce3d60357e 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -892,6 +892,8 @@ export type EuiDataGridRowHeightOption = | 'auto' | ExclusiveUnion<{ lineCount: number }, { height: number }>; +export type EuiDataGridScrollAnchorRow = 'start' | 'center' | undefined; + export interface EuiDataGridRowHeightsOptions { /** * Defines the default size for all rows. It can be line count or just height. @@ -913,6 +915,12 @@ export interface EuiDataGridRowHeightsOptions { * Can be used for, e.g. storing user `rowHeightsOptions` in a local storage object. */ onChange?: (rowHeightsOptions: EuiDataGridRowHeightsOptions) => void; + /** + * When set to 'start' or 'center' the topmost or middle visible row will try + * to compensate for changes in their top offsets by adjust the grid's scroll + * position. + */ + scrollAnchorRow?: EuiDataGridScrollAnchorRow; } export interface EuiDataGridRowManager { diff --git a/src/components/datagrid/utils/row_heights.test.ts b/src/components/datagrid/utils/row_heights.test.ts index dc63ec3d68b..b71a9eb63d6 100644 --- a/src/components/datagrid/utils/row_heights.test.ts +++ b/src/components/datagrid/utils/row_heights.test.ts @@ -6,18 +6,45 @@ * Side Public License, v 1. */ +import type { MutableRefObject } from 'react'; import { act } from 'react-dom/test-utils'; import { testCustomHook } from '../../../test/internal'; import { startingStyles } from '../controls'; - +import type { ImperativeGridApi } from '../data_grid_types'; import { - RowHeightUtils, cellPaddingsMap, + RowHeightUtils, useRowHeightUtils, } from './row_heights'; describe('RowHeightUtils', () => { - const rowHeightUtils = new RowHeightUtils(); + const gridRef: MutableRefObject = { + current: { + resetAfterIndices: jest.fn(), + resetAfterColumnIndex: jest.fn(), + resetAfterRowIndex: jest.fn(), + scrollTo: jest.fn(), + scrollToItem: jest.fn(), + }, + }; + const outerGridElementRef = { current: null }; + const gridItemsRenderedRef = { current: null }; + const rerenderGridBodyRef = { current: jest.fn() }; + const rowHeightUtils = new RowHeightUtils( + gridRef, + outerGridElementRef, + gridItemsRenderedRef, + rerenderGridBodyRef + ); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); describe('getRowHeightOption', () => { const rowHeightsOptions = { @@ -117,8 +144,15 @@ describe('RowHeightUtils', () => { }); describe('auto height', () => { - const getRowHeightSpy = jest.spyOn(rowHeightUtils, 'getRowHeight'); - beforeEach(() => getRowHeightSpy.mockClear()); + let getRowHeightSpy: jest.SpyInstance; + + beforeEach(() => { + getRowHeightSpy = jest.spyOn(rowHeightUtils, 'getRowHeight'); + }); + + afterEach(() => { + getRowHeightSpy.mockRestore(); + }); it('gets the max height for the current row from the heights cache', () => { expect(rowHeightUtils.getCalculatedHeight('auto', 34, 1)).toEqual(0); // 0 is expected since the cache is empty @@ -212,13 +246,19 @@ describe('RowHeightUtils', () => { }); describe('calculateHeightForLineCount', () => { + let getComputedStyleSpy: jest.SpyInstance; + const cell = document.createElement('div'); + beforeEach(() => { rowHeightUtils.cacheStyles({ cellPadding: 'm' }); - jest + getComputedStyleSpy = jest .spyOn(window, 'getComputedStyle') .mockReturnValue({ lineHeight: '24px' } as CSSStyleDeclaration); }); - const cell = document.createElement('div'); + + afterEach(() => { + getComputedStyleSpy.mockRestore(); + }); it('calculates the row height based on the number of lines and cell line height/padding', () => { expect(rowHeightUtils.calculateHeightForLineCount(cell, 1)).toEqual(36); // 1 * 24 + 6 + 6 @@ -275,10 +315,17 @@ describe('RowHeightUtils', () => { }); describe('row height cache', () => { - describe('setRowHeight', () => { - const resetRowSpy = jest.spyOn(rowHeightUtils, 'resetRow'); - beforeEach(() => resetRowSpy.mockClear()); + let resetRowSpy: jest.SpyInstance; + + beforeEach(() => { + resetRowSpy = jest.spyOn(rowHeightUtils, 'resetRow'); + }); + + afterEach(() => { + resetRowSpy.mockRestore(); + }); + describe('setRowHeight', () => { it('setRowHeight', () => { rowHeightUtils.setRowHeight(5, 'a', 50, 0); rowHeightUtils.setRowHeight(5, 'b', 34, 0); @@ -302,11 +349,9 @@ describe('RowHeightUtils', () => { }); it('calls rerenderGridBody', () => { - const rerenderGridBody = jest.fn(); - rowHeightUtils.setRerenderGridBody(rerenderGridBody); - expect(rerenderGridBody).toHaveBeenCalledTimes(0); + expect(rerenderGridBodyRef.current).toHaveBeenCalledTimes(0); rowHeightUtils.setRowHeight(1, 'a', 34, 1); - expect(rerenderGridBody).toHaveBeenCalledTimes(1); + expect(rerenderGridBodyRef.current).toHaveBeenCalledTimes(1); }); }); @@ -321,9 +366,6 @@ describe('RowHeightUtils', () => { }); describe('pruneHiddenColumnHeights', () => { - const resetRowSpy = jest.spyOn(rowHeightUtils, 'resetRow'); - beforeEach(() => resetRowSpy.mockClear()); - it('checks each row height map and deletes column IDs that are no longer visible', () => { rowHeightUtils.pruneHiddenColumnHeights([{ id: 'a' }, { id: 'b' }]); expect(rowHeightUtils.getRowHeight(5)).toEqual(62); @@ -343,21 +385,16 @@ describe('RowHeightUtils', () => { }); describe('grid resetting', () => { - const mockGrid = { resetAfterRowIndex: jest.fn() } as any; - beforeEach(() => jest.clearAllMocks()); - - describe('setGrid', () => { - it('stores the react-window grid as an instance variable', () => { - rowHeightUtils.setGrid(mockGrid); + describe('resetRow', () => { + let resetGridSpy: jest.SpyInstance; - // @ts-ignore this var is private, but we're inspecting it for the sake of the unit test - expect(rowHeightUtils.grid).toEqual(mockGrid); + beforeEach(() => { + resetGridSpy = jest.spyOn(rowHeightUtils, 'resetGrid'); }); - }); - describe('resetRow', () => { - const resetGridSpy = jest.spyOn(rowHeightUtils, 'resetGrid'); - jest.useFakeTimers(); + afterEach(() => { + resetGridSpy.mockRestore(); + }); it('sets this.lastUpdatedRow and resets the grid', () => { rowHeightUtils.resetRow(0); @@ -374,54 +411,185 @@ describe('RowHeightUtils', () => { describe('resetGrid', () => { it('invokes grid.resetAfterRowIndex with the last visible row', () => { rowHeightUtils.setRowHeight(99, 'a', 34, 99); - rowHeightUtils.resetGrid(); - expect(mockGrid.resetAfterRowIndex).toHaveBeenCalledWith(99); + + jest.runAllTimers(); + + expect(gridRef.current?.resetAfterRowIndex).toHaveBeenCalledWith(99); }); it('invokes resetAfterRowIndex only once with the smallest cached row index', () => { - rowHeightUtils.setRowHeight(97, 'a', 34, 97); - rowHeightUtils.setRowHeight(99, 'a', 34, 99); - rowHeightUtils.resetGrid(); - expect(mockGrid.resetAfterRowIndex).toHaveBeenCalledTimes(1); - expect(mockGrid.resetAfterRowIndex).toHaveBeenCalledWith(97); + rowHeightUtils.setRowHeight(97, 'a', 35, 97); + rowHeightUtils.setRowHeight(99, 'a', 36, 99); + + jest.runAllTimers(); + + expect(gridRef.current?.resetAfterRowIndex).toHaveBeenCalledTimes(1); + expect(gridRef.current?.resetAfterRowIndex).toHaveBeenCalledWith(97); }); }); }); + + describe('layout shift compensation', () => { + it('can compensate vertical shifts of the start anchor row', () => { + const rowHeightUtils = new RowHeightUtils( + gridRef, + { + current: { + scrollTop: 100, + } as any, + }, + { + current: { + overscanRowStartIndex: 1, + overscanRowStopIndex: 12, + overscanColumnStartIndex: 0, + overscanColumnStopIndex: 1, + visibleRowStartIndex: 2, + visibleRowStopIndex: 11, + visibleColumnStartIndex: 0, + visibleColumnStopIndex: 1, + }, + }, + rerenderGridBodyRef + ); + + // the center row shifted by 10 pixels + rowHeightUtils.compensateForLayoutShift(4, 10, 'start'); + + // no scrolling should have taken place + expect(gridRef.current?.scrollTo).toHaveBeenCalledTimes(0); + + // the anchor row shifted by 23 pixels + rowHeightUtils.compensateForLayoutShift(2, 23, 'start'); + + // the grid should have scrolled accordingly + expect(gridRef.current?.scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ + scrollTop: 123, + }) + ); + }); + + it('can compensate vertical shifts of the center anchor row', () => { + const rowHeightUtils = new RowHeightUtils( + gridRef, + { + current: { + scrollTop: 100, + } as any, + }, + { + current: { + overscanRowStartIndex: 1, + overscanRowStopIndex: 12, + overscanColumnStartIndex: 0, + overscanColumnStopIndex: 1, + visibleRowStartIndex: 2, + visibleRowStopIndex: 11, + visibleColumnStartIndex: 0, + visibleColumnStopIndex: 1, + }, + }, + rerenderGridBodyRef + ); + + // the topmost visible row shifted by 10 pixels + rowHeightUtils.compensateForLayoutShift(2, 10, 'center'); + + // no scrolling should have taken place + expect(gridRef.current?.scrollTo).toHaveBeenCalledTimes(0); + + // the anchor row shifted by 23 pixels + rowHeightUtils.compensateForLayoutShift(4, 23, 'center'); + + // the grid should have scrolled accordingly + expect(gridRef.current?.scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ + scrollTop: 123, + }) + ); + }); + + it("doesn't compensate vertical shifts when no anchor row is specified", () => { + const rowHeightUtils = new RowHeightUtils( + gridRef, + { + current: { + scrollTop: 100, + } as any, + }, + { + current: { + overscanRowStartIndex: 1, + overscanRowStopIndex: 12, + overscanColumnStartIndex: 0, + overscanColumnStopIndex: 1, + visibleRowStartIndex: 2, + visibleRowStopIndex: 11, + visibleColumnStartIndex: 0, + visibleColumnStopIndex: 1, + }, + }, + rerenderGridBodyRef + ); + + // the topmost visible row shifted by 23 pixels, but no anchor has been specified + rowHeightUtils.compensateForLayoutShift(2, 23, undefined); + + // no scrolling should have taken place + expect(gridRef.current?.scrollTo).toHaveBeenCalledTimes(0); + }); + }); }); }); describe('useRowHeightUtils', () => { + const gridRef: MutableRefObject = { + current: { + resetAfterIndices: jest.fn(), + resetAfterColumnIndex: jest.fn(), + + resetAfterRowIndex: jest.fn(), + scrollTo: jest.fn(), + scrollToItem: jest.fn(), + }, + }; + const outerGridElementRef = { current: null }; + const gridItemsRenderedRef = { current: null }; + const mockArgs = { - gridRef: null, + gridRef, + outerGridElementRef, + gridItemsRenderedRef, gridStyles: startingStyles, columns: [{ id: 'A' }, { id: 'B' }], rowHeightOptions: undefined, }; + let requestAnimationFrameSpy: jest.SpyInstance; + + beforeEach(() => { + requestAnimationFrameSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: any) => cb()); + }); + + afterEach(() => { + requestAnimationFrameSpy.mockRestore(); + }); + it('instantiates and returns an instance of RowHeightUtils', () => { - const { return: rowHeightUtils } = testCustomHook(() => + const { return: rowHeightUtils } = testCustomHook(() => useRowHeightUtils(mockArgs) ); expect(rowHeightUtils).toBeInstanceOf(RowHeightUtils); }); - it('populates internal RowHeightUtils vars from outside dependencies', () => { - const args = { ...mockArgs, gridRef: {} as any }; - const { return: rowHeightUtils } = testCustomHook(() => - useRowHeightUtils(args) - ); - // @ts-ignore - intentionally inspecting private var for test - expect(rowHeightUtils.grid).toEqual(args.gridRef); - // @ts-ignore - intentionally inspecting private var for test - expect(rowHeightUtils.rerenderGridBody).toBeInstanceOf(Function); - }); - it('forces a rerender every time rowHeightsOptions changes', () => { - const requestAnimationFrameSpy = jest - .spyOn(window, 'requestAnimationFrame') - .mockImplementation((cb: any) => cb()); - - const { updateHookArgs } = testCustomHook(useRowHeightUtils, mockArgs); + const { updateHookArgs } = testCustomHook( + useRowHeightUtils, + mockArgs + ); expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); updateHookArgs({ rowHeightsOptions: { defaultHeight: 300 } }); @@ -434,10 +602,9 @@ describe('useRowHeightUtils', () => { }); it('updates internal cached styles whenever gridStyle.cellPadding changes', () => { - const { return: rowHeightUtils, updateHookArgs } = testCustomHook( - useRowHeightUtils, - mockArgs - ); + const { return: rowHeightUtils, updateHookArgs } = testCustomHook< + RowHeightUtils + >(useRowHeightUtils, mockArgs); updateHookArgs({ gridStyles: { ...startingStyles, cellPadding: 's' } }); // @ts-ignore - intentionally inspecting private var for test diff --git a/src/components/datagrid/utils/row_heights.ts b/src/components/datagrid/utils/row_heights.ts index 6d313fcf4a4..4c8ec26d093 100644 --- a/src/components/datagrid/utils/row_heights.ts +++ b/src/components/datagrid/utils/row_heights.ts @@ -13,16 +13,22 @@ import { useCallback, useContext, CSSProperties, + MutableRefObject, + Component, } from 'react'; -import type { VariableSizeGrid as Grid } from 'react-window'; +import type { + GridOnItemsRenderedProps, + VariableSizeGrid as Grid, +} from 'react-window'; import { isObject, isNumber } from '../../../services/predicate'; -import { useForceRender } from '../../../services'; +import { useForceRender, useLatest } from '../../../services'; import { EuiDataGridStyleCellPaddings, EuiDataGridStyle, EuiDataGridRowHeightOption, EuiDataGridRowHeightsOptions, EuiDataGridColumn, + EuiDataGridScrollAnchorRow, } from '../data_grid_types'; import { DataGridSortingContext } from './sorting'; @@ -36,7 +42,16 @@ export const cellPaddingsMap: Record = { export const AUTO_HEIGHT = 'auto'; export const DEFAULT_ROW_HEIGHT = 34; +type IGrid = Omit; + export class RowHeightUtils { + constructor( + private gridRef: MutableRefObject, + private outerGridElementRef: MutableRefObject, + private gridItemsRenderedRef: MutableRefObject, + private rerenderGridBodyRef: MutableRefObject<(() => void) | null> + ) {} + getRowHeightOption( rowIndex: number, rowHeightsOptions?: EuiDataGridRowHeightsOptions @@ -157,9 +172,7 @@ export class RowHeightUtils { private heightsCache = new Map>(); private timerId?: number; - private grid?: Grid; private lastUpdatedRow: number = Infinity; - private rerenderGridBody: Function = () => {}; isAutoHeight( rowIndex: number, @@ -205,7 +218,7 @@ export class RowHeightUtils { // When an auto row height is updated, force a re-render // of the grid body to update the unconstrained height - this.rerenderGridBody(); + this.rerenderGridBodyRef.current?.(); } pruneHiddenColumnHeights(visibleColumns: EuiDataGridColumn[]) { @@ -236,16 +249,52 @@ export class RowHeightUtils { } resetGrid() { - this.grid?.resetAfterRowIndex(this.lastUpdatedRow); + this.gridRef.current?.resetAfterRowIndex(this.lastUpdatedRow); this.lastUpdatedRow = Infinity; } - setGrid(grid: Grid) { - this.grid = grid; - } + compensateForLayoutShift( + rowIndex: number, + verticalLayoutShift: number, + anchorRow: EuiDataGridScrollAnchorRow + ) { + const grid = this.gridRef.current; + const outerGridElement = this.outerGridElementRef.current; + const renderedItems = this.gridItemsRenderedRef.current; + + if ( + grid == null || + outerGridElement == null || + renderedItems == null || + anchorRow == null || + !Number.isFinite(verticalLayoutShift) + ) { + return; + } - setRerenderGridBody(rerenderGridBody: Function) { - this.rerenderGridBody = rerenderGridBody; + // skip if the start row is the anchor row but it hasn't shifted + if ( + anchorRow === 'start' && + renderedItems.visibleRowStartIndex !== rowIndex + ) { + return; + } + + // skip if the center row is the anchor row but it hasn't shifted + if ( + anchorRow === 'center' && + Math.floor( + (renderedItems.visibleRowStopIndex - + renderedItems.visibleRowStartIndex) / + 2 + ) !== rowIndex + ) { + return; + } + + grid.scrollTo({ + scrollTop: outerGridElement.scrollTop + verticalLayoutShift, + }); } } @@ -255,23 +304,31 @@ export class RowHeightUtils { */ export const useRowHeightUtils = ({ gridRef, + outerGridElementRef, + gridItemsRenderedRef, gridStyles, columns, rowHeightsOptions, }: { - gridRef: Grid | null; + gridRef: MutableRefObject; + outerGridElementRef: MutableRefObject; + gridItemsRenderedRef: MutableRefObject; gridStyles: EuiDataGridStyle; columns: EuiDataGridColumn[]; rowHeightsOptions?: EuiDataGridRowHeightsOptions; }) => { - const rowHeightUtils = useMemo(() => new RowHeightUtils(), []); - - // Update rowHeightUtils with internal vars from outside dependencies const forceRender = useForceRender(); - useEffect(() => { - if (gridRef) rowHeightUtils.setGrid(gridRef); - rowHeightUtils.setRerenderGridBody(forceRender); - }, [gridRef, forceRender, rowHeightUtils]); + const forceRenderRef = useLatest(forceRender); + const rowHeightUtils = useMemo( + () => + new RowHeightUtils( + gridRef, + outerGridElementRef, + gridItemsRenderedRef, + forceRenderRef + ), + [forceRenderRef, gridItemsRenderedRef, gridRef, outerGridElementRef] + ); // Forces a rerender whenever the row heights change, as this can cause the // grid to change height/have scrollbars. Without this, grid rerendering is stale diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index 6f7778cc4f0..16243548f85 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -10,5 +10,6 @@ export * from './useDependentState'; export * from './useCombinedRefs'; export * from './useForceRender'; export * from './useIsWithinBreakpoints'; +export * from './useLatest'; export * from './useMouseMove'; export * from './useUpdateEffect'; diff --git a/src/services/hooks/useLatest.ts b/src/services/hooks/useLatest.ts new file mode 100644 index 00000000000..d56f7730bee --- /dev/null +++ b/src/services/hooks/useLatest.ts @@ -0,0 +1,15 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { MutableRefObject, useRef } from 'react'; + +export function useLatest(value: Value): MutableRefObject { + const latestValueRef = useRef(value); + latestValueRef.current = value; + return latestValueRef; +} From be6c40ac030e93029b5b9ef9c90e8f36d8a355a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 14:24:55 +0000 Subject: [PATCH 03/10] Trigger layout shift compensation from cells --- src/components/datagrid/body/data_grid_cell.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/data_grid_cell.tsx index c8938e512a7..a03be2b7483 100644 --- a/src/components/datagrid/body/data_grid_cell.tsx +++ b/src/components/datagrid/body/data_grid_cell.tsx @@ -291,6 +291,21 @@ export class EuiDataGridCell extends Component< this.recalculateLineHeight(); } + if ( + this.props.colIndex === 0 && // once per row + this.props.columnId === prevProps.columnId && // if this is still the same column + this.props.rowIndex === prevProps.rowIndex && // if this is still the same row + this.props.style?.top !== prevProps.style?.top // if the top position has changed + ) { + const previousTop = parseFloat(prevProps.style?.top as string); + const currentTop = parseFloat(this.props.style?.top as string); + this.props.rowHeightUtils?.compensateForLayoutShift( + this.props.rowIndex, + currentTop - previousTop, + this.props.rowHeightsOptions?.scrollAnchorRow + ); + } + if ( this.props.popoverContext.popoverIsOpen !== prevProps.popoverContext.popoverIsOpen || From 016cf98b3d242d4200f0dcf247629268ae5bfb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 14:25:23 +0000 Subject: [PATCH 04/10] Add example to data grid docs --- .../advanced/datagrid_advanced_example.js | 17 + .../datagrid/advanced/scroll_anchoring.tsx | 554 ++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 src-docs/src/views/datagrid/advanced/scroll_anchoring.tsx diff --git a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js index e978a551224..f61ca821482 100644 --- a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js +++ b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js @@ -25,6 +25,8 @@ dataGridRef.current.openCellPopover({ rowIndex, colIndex }); dataGridRef.current.closeCellPopover(); `; +import DataGridScrollAnchoring from './scroll_anchoring'; + export const DataGridAdvancedExample = { title: 'Data grid advanced', sections: [ @@ -118,5 +120,20 @@ export const DataGridAdvancedExample = { props: { EuiDataGridRefProps }, }, ...DataGridMemoryExample.sections, + { + title: 'Scrolling and incremental loading', + text: ( + <> +

+ This example shows a grid that performs incremental loading when + either end is reached. It also attempts to minimize the visible + layout shift when newly rendered items are measured and sized for + the first time. +

+ + ), + components: { DataGridScrollAnchoring }, + demo: , + }, ], }; diff --git a/src-docs/src/views/datagrid/advanced/scroll_anchoring.tsx b/src-docs/src/views/datagrid/advanced/scroll_anchoring.tsx new file mode 100644 index 00000000000..5f6e4747957 --- /dev/null +++ b/src-docs/src/views/datagrid/advanced/scroll_anchoring.tsx @@ -0,0 +1,554 @@ +import React, { + createContext, + memo, + useCallback, + useContext, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; +import { GridOnItemsRenderedProps } from 'react-window'; +import * as uuid from 'uuid'; +import { + EuiButtonEmpty, + EuiDataGrid, + EuiDataGridCellValueElementProps, + EuiDataGridRefProps, + EuiDataGridRowHeightsOptions, + EuiDataGridStyle, + EuiDataGridToolBarAdditionalControlsOptions, + EuiSwitch, +} from '../../../../../src'; + +interface Item { + timestamp: number; + message: string; +} + +interface LoadingChunkBefore { + id: string; + status: 'loading-before'; + endTimestamp: number; + count: number; + rowIndex: number; +} + +interface LoadingChunkAfter { + id: string; + status: 'loading-after'; + startTimestamp: number; + count: number; + rowIndex: number; +} + +interface LoadedChunk { + id: string; + status: 'loaded'; + startTimestamp: number; + endTimestamp: number; + items: Item[]; + rowIndex: number; +} + +type Chunk = LoadingChunkBefore | LoadingChunkAfter | LoadedChunk; + +type Chunks = [ + LoadedChunk | LoadingChunkBefore, + LoadedChunk | LoadingChunkAfter +]; + +const chunkSize = 50; +const loadingDelay = 3000; +const virtualRowCount = 10000; + +export default function DataGridScrollAnchoring() { + const { chunks, loadChunkBefore, loadChunkAfter, resetChunks } = useChunks(); + + const { startTimestamp, endTimestamp } = useMemo( + () => getTimestamps(chunks), + [chunks] + ); + + const { startRowIndex, endRowIndex } = useMemo(() => getRowIndices(chunks), [ + chunks, + ]); + + const loadChunkBeforeEarliestTimestamp = useMemo(() => { + if (startTimestamp == null) { + return; + } + + return () => loadChunkBefore(startTimestamp); + }, [loadChunkBefore, startTimestamp]); + + const loadChunkAfterLatestTimestamp = useMemo(() => { + if (endTimestamp == null) { + return; + } + + return () => loadChunkAfter(endTimestamp); + }, [loadChunkAfter, endTimestamp]); + + return ( + + + + ); +} + +const Grid = memo(function ScrollingExampleGrid({ + endRowIndex, + loadChunkBeforeEarliestTimestamp, + loadChunkAfterLatestTimestamp, + resetChunks, + startRowIndex, + virtualRowCount, +}: { + endRowIndex: number; + loadChunkBeforeEarliestTimestamp?: () => void; + loadChunkAfterLatestTimestamp?: () => void; + resetChunks?: () => void; + startRowIndex: number; + virtualRowCount: number; +}) { + const [euiGridKey, setEuiGridKey] = useState(uuid.v4()); + const remountGrid = useCallback(() => { + setEuiGridKey(uuid.v4()); + }, []); + + const imperativeGridRef = useRef(null); + + /** + * scroll anchoring + */ + + const [isScrollAnchoring, setScrollAnchoring] = useState(true); + + const rowHeightsOptions = useMemo( + () => ({ + defaultHeight: 'auto', + scrollAnchorRow: isScrollAnchoring ? 'start' : undefined, + }), + [isScrollAnchoring] + ); + + /** + * the usual grid setup + */ + + const gridStyle = useMemo( + (): EuiDataGridStyle => ({ + rowHover: 'none', + }), + [] + ); + + const columns = useMemo( + () => [ + { id: 'index', initialWidth: 100 }, + { id: 'timestamp', initialWidth: 200 }, + { id: 'message' }, + ], + [] + ); + + const columnVisibility = useMemo( + () => ({ + visibleColumns: ['index', 'timestamp', 'message'], + setVisibleColumns: () => undefined, + }), + [] + ); + + const extraToolbarItems = useMemo< + EuiDataGridToolBarAdditionalControlsOptions + >( + () => ({ + left: { + append: ( + <> + remount grid + reset data + + prepend + + + append + + + ), + }, + right: ( + <> + + imperativeGridRef.current?.scrollToItem?.({ + rowIndex: Math.floor(virtualRowCount / 2), + align: 'start', + }) + } + > + scroll to middle + + setScrollAnchoring(evt.target.checked)} + label="Use scroll anchor" + /> + + ), + }), + [ + isScrollAnchoring, + loadChunkAfterLatestTimestamp, + loadChunkBeforeEarliestTimestamp, + remountGrid, + resetChunks, + virtualRowCount, + ] + ); + + const onItemsRendered = useCallback( + ({ + visibleRowStartIndex, + visibleRowStopIndex, + }: GridOnItemsRenderedProps) => { + if (visibleRowStartIndex === 0 && visibleRowStartIndex < startRowIndex) { + // scroll to initial position + imperativeGridRef.current?.scrollToItem?.({ + rowIndex: Math.floor((startRowIndex + endRowIndex) / 2), + align: 'start', + }); + } else if (visibleRowStartIndex === startRowIndex) { + // trigger prepending of a chunk + loadChunkBeforeEarliestTimestamp?.(); + } else if (visibleRowStopIndex === endRowIndex) { + // trigger appending of a chunk + loadChunkAfterLatestTimestamp?.(); + } else if (visibleRowStartIndex < startRowIndex) { + // block scrolling outside of loaded area + imperativeGridRef.current?.scrollToItem?.({ + rowIndex: startRowIndex, + align: 'start', + }); + } else if (visibleRowStopIndex > endRowIndex) { + // block scrolling outside of loaded area + imperativeGridRef.current?.scrollToItem?.({ + rowIndex: endRowIndex, + align: 'end', + }); + } + }, + [ + endRowIndex, + loadChunkAfterLatestTimestamp, + loadChunkBeforeEarliestTimestamp, + startRowIndex, + ] + ); + + return ( + + ); +}); + +function renderCell({ rowIndex, columnId }: EuiDataGridCellValueElementProps) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const chunks = useContext(CellDataContext); + + if (columnId === 'index') { + return ; + } else { + const { chunk: rowChunk, indexInChunk } = findRowInChunks( + rowIndex, + chunks + ) ?? { chunk: undefined, indexInChunk: -1 }; + + if (rowChunk == null) { + return null; + } + + if (columnId === 'timestamp') { + return ; + } else { + return ; + } + } +} + +const IndexCell = memo<{ rowIndex: number }>( + function ScrollingExampleIndexCell({ rowIndex }) { + return <>{`${rowIndex}`}; + } +); + +const TimestampCell = memo<{ rowChunk: Chunk; indexInChunk: number }>( + function ScrollingExampleTimestampCell({ rowChunk, indexInChunk }) { + if ( + rowChunk.status === 'loading-before' || + rowChunk.status === 'loading-after' + ) { + return <>?; + } else { + return
{`${rowChunk.items[indexInChunk].timestamp}`}
; + } + } +); + +const MessageCell = memo<{ rowChunk: Chunk; indexInChunk: number }>( + function ScrollingExampleMessageCell({ rowChunk, indexInChunk }) { + if ( + rowChunk.status === 'loading-before' || + rowChunk.status === 'loading-after' + ) { + return <>Loading...; + } else { + return
{`${rowChunk.items[indexInChunk].message}`}
; + } + } +); + +const generateRandomItems = (startTimestamp: number, count: number): Item[] => { + return Array.from(Array(count), (_value, index) => + generateRandomItem(startTimestamp + index) + ); +}; + +const generateRandomItem = (timestamp: number): Item => { + const numberOfLines = Math.ceil(Math.random() * 10) + 1; + const message = Array.from( + Array(numberOfLines), + (_value, index) => `line ${index}` + ).join('\n'); + + return { + timestamp, + message, + }; +}; + +type ChunkAction = + | { type: 'reset' } + | { type: 'load-before'; endTimestamp: number; number: number } + | { type: 'load-before-success'; items: Item[] } + | { type: 'load-after'; startTimestamp: number; number: number } + | { type: 'load-after-success'; items: Item[] }; + +const initialTimestamp = 1000000; +const initialChunks: Chunks = [ + { + id: uuid.v4(), + status: 'loaded', + startTimestamp: initialTimestamp - chunkSize, + endTimestamp: initialTimestamp - 1, + items: generateRandomItems(initialTimestamp - chunkSize, chunkSize), + rowIndex: Math.floor(virtualRowCount / 2) - chunkSize, + }, + { + id: uuid.v4(), + status: 'loaded', + startTimestamp: initialTimestamp, + endTimestamp: initialTimestamp + chunkSize, + items: generateRandomItems(initialTimestamp, chunkSize), + rowIndex: Math.floor(virtualRowCount / 2), + }, +]; + +const reduceChunksState = (state: Chunks, action: ChunkAction): Chunks => { + switch (action.type) { + case 'reset': { + return initialChunks; + } + case 'load-before': { + const [firstChunk] = state; + + if (firstChunk.status !== 'loaded') { + return state; // limit to one loading chunk for simplicity + } + + const newFirstChunk: LoadingChunkBefore = { + id: uuid.v4(), + status: 'loading-before', + endTimestamp: action.endTimestamp, + count: action.number, + rowIndex: firstChunk.rowIndex - 1, + }; + + return [newFirstChunk, firstChunk]; + } + case 'load-before-success': { + const [firstChunk, ...rest] = state; + + if (firstChunk.status !== 'loading-before') { + return state; // ignore if it wasn't loading + } + + const updatedFirstChunk: LoadedChunk = { + id: firstChunk.id, + status: 'loaded', + startTimestamp: action.items[0].timestamp, + endTimestamp: action.items[action.items.length - 1].timestamp, + items: action.items, + rowIndex: + firstChunk.rowIndex + + (getRowCountOfChunk(firstChunk) - action.items.length), + }; + + return [updatedFirstChunk, ...rest]; + } + case 'load-after': { + const [, lastChunk] = state; + + if (lastChunk.status !== 'loaded') { + return state; // limit to one loading chunk for simplicity + } + + const newLastChunk: LoadingChunkAfter = { + id: uuid.v4(), + status: 'loading-after', + startTimestamp: action.startTimestamp, + count: action.number, + rowIndex: lastChunk.rowIndex + lastChunk.items.length, + }; + + return [lastChunk, newLastChunk]; + } + case 'load-after-success': { + const [firstChunk, lastChunk] = state; + + if (lastChunk.status !== 'loading-after') { + return state; // ignore if it wasn't loading + } + + const updatedLastChunk: LoadedChunk = { + id: lastChunk.id, + status: 'loaded', + startTimestamp: action.items[0].timestamp, + endTimestamp: action.items[action.items.length - 1].timestamp, + items: action.items, + rowIndex: lastChunk.rowIndex, + }; + + return [firstChunk, updatedLastChunk]; + } + default: { + return state; + } + } +}; + +function useChunks() { + const [chunks, dispatch] = useReducer(reduceChunksState, initialChunks); + + const resetChunks = useCallback(() => { + dispatch({ type: 'reset' }); + }, []); + + const loadChunkBefore = useCallback((endTimestamp: number) => { + dispatch({ type: 'load-before', endTimestamp, number: chunkSize }); + + setTimeout(() => { + const items = generateRandomItems(endTimestamp - chunkSize, chunkSize); + + dispatch({ type: 'load-before-success', items }); + }, loadingDelay); + }, []); + + const loadChunkAfter = useCallback((startTimestamp: number) => { + dispatch({ type: 'load-after', startTimestamp, number: chunkSize }); + + setTimeout(() => { + const items = generateRandomItems(startTimestamp, chunkSize); + + dispatch({ type: 'load-after-success', items }); + }, loadingDelay); + }, []); + + return { + chunks, + resetChunks, + loadChunkBefore, + loadChunkAfter, + }; +} + +const CellDataContext = createContext(initialChunks); + +const getTimestamps = ([firstChunk, lastChunk]: Chunks): { + startTimestamp: number | undefined; + endTimestamp: number | undefined; +} => { + const startTimestamp = + firstChunk.status === 'loaded' ? firstChunk.startTimestamp : undefined; + const endTimestamp = + lastChunk.status === 'loaded' ? lastChunk.endTimestamp : undefined; + + return { + startTimestamp, + endTimestamp, + }; +}; + +const getRowIndices = ([firstChunk, lastChunk]: Chunks): { + startRowIndex: number; + endRowIndex: number; +} => { + const startRowIndex = firstChunk.rowIndex; + const endRowIndex = lastChunk.rowIndex + getRowCountOfChunk(lastChunk); + + return { + startRowIndex, + endRowIndex, + }; +}; + +const getRowCountOfChunk = (chunk: Chunk) => + chunk.status === 'loaded' ? chunk.items.length : 1; + +const findRowInChunks = ( + rowIndex: number, + chunks: Chunk[] +): { chunk: Chunk; indexInChunk: number } | undefined => { + const chunk = chunks.find( + (c) => + rowIndex >= c.rowIndex && rowIndex < c.rowIndex + getRowCountOfChunk(c) + ); + + if (chunk != null) { + return { + chunk, + indexInChunk: rowIndex - chunk.rowIndex, + }; + } +}; From a0a0b71d4c70dc3b1988e616abaed5d108e7369b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 14:45:59 +0000 Subject: [PATCH 05/10] Add `scrollAnchorRow` documentation --- .../advanced/datagrid_advanced_example.js | 8 +++- .../datagrid_height_options_example.js | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js index f61ca821482..66086894c02 100644 --- a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js +++ b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js @@ -128,7 +128,13 @@ export const DataGridAdvancedExample = { This example shows a grid that performs incremental loading when either end is reached. It also attempts to minimize the visible layout shift when newly rendered items are measured and sized for - the first time. + the first time using the scrollAnchorRow row + height option. +

+

+ Observe the layout shift compensation effect when scrolling up to + previously unviewed rows with and without the "Use scroll + anchor" toggle enabled.

), diff --git a/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js b/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js index d1c3fb48a5d..a5dc226c3b9 100644 --- a/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js +++ b/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js @@ -169,6 +169,47 @@ export const dataGridRowHeightOptionsExample = { +
  • + scrollAnchorRow +
      +
    • + Optional indicator of the row that should be used as an + anchor for vertical layout shift compensation. +
    • +
    • + Can be set to the default undefined, + "start", or + "center". +
    • +
    • + If set to "start", the topmost + visible row will monitor for unexpected changes to its + vertical position and try to compensate for these by + scrolling the grid scroll container such that the topmost + row position remains stable. +
    • +
    • + If set to "center", the middle + visible row will monitor for unexpected changes to its + vertical position and try to compensate for these by + scrolling the grid scroll container such that the middle row + position remains stable. +
    • +
    • + This is particularly useful when the grid contains + auto sized rows. Since these rows are + measured as they appear in the overscan, they can cause + surprising shifts of the vertical position of all following + rows when their measure height is different from the + estimated height. See the{' '} + + advanced data grid scrolling and incremental loading + example{' '} + + . +
    • +
    +
  • From 74b640bdfad7cd5415103bd1978d01cc36985579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 14:50:47 +0000 Subject: [PATCH 06/10] Add changelog entry --- upcoming_changelogs/6028.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 upcoming_changelogs/6028.md diff --git a/upcoming_changelogs/6028.md b/upcoming_changelogs/6028.md new file mode 100644 index 00000000000..f3458446122 --- /dev/null +++ b/upcoming_changelogs/6028.md @@ -0,0 +1,2 @@ +- The `EuiDatGrid`'s `rowHeightOptions` now contain an optional `scrollAnchorRow` property, which enables vertical layout shift compensation when rendering `auto`-sized rows. + From 02a836a2100c427a9d69a694646c074b287f4baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 15:12:24 +0000 Subject: [PATCH 07/10] Mention scrollTo in the changelog --- upcoming_changelogs/6028.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/upcoming_changelogs/6028.md b/upcoming_changelogs/6028.md index f3458446122..ea6ad34652b 100644 --- a/upcoming_changelogs/6028.md +++ b/upcoming_changelogs/6028.md @@ -1,2 +1,2 @@ -- The `EuiDatGrid`'s `rowHeightOptions` now contain an optional `scrollAnchorRow` property, which enables vertical layout shift compensation when rendering `auto`-sized rows. - +- The `EuiDataGrid`'s `rowHeightOptions` now contain an optional `scrollAnchorRow` property, which enables vertical layout shift compensation when rendering `auto`-sized rows. +- The `EuiDataGrid`'s imperative API now exposes the `scrollTo` and `scrollToItem` APIs of `react-window`. From 8ab0220a78d9ab0560ad260a3a8e933f22848111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 15:12:45 +0000 Subject: [PATCH 08/10] Add `scrollAnchorRow` to the props snippets --- src-docs/src/views/datagrid/_snippets.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src-docs/src/views/datagrid/_snippets.tsx b/src-docs/src/views/datagrid/_snippets.tsx index d377ee9f7f3..57097112053 100644 --- a/src-docs/src/views/datagrid/_snippets.tsx +++ b/src-docs/src/views/datagrid/_snippets.tsx @@ -97,6 +97,7 @@ inMemory={{ level: 'sorting' }}`, 4: 200, // row at index 4 will adjust the height to 200px 6: 'auto', // row at index 6 will automatically adjust the height }, + scrollAnchorRow: 'start', // compensate for layout shift when auto-sized rows are scrolled into view }}`, ref: `// Optional. For advanced control of internal data grid state, passes back an object of imperative API methods ref={dataGridRef}`, From 9745bb5b5219cd88ca4ff534b18f02b6530c2e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 15:24:50 +0000 Subject: [PATCH 09/10] Add documentation for `scrollTo` and `scrollToItem` --- .../advanced/datagrid_advanced_example.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js index 66086894c02..8fb59896eec 100644 --- a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js +++ b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js @@ -81,6 +81,27 @@ export const DataGridAdvancedExample = { open cell popover.

    +
  • +

    + + scrollTo({'{ scrollLeft: number, scrollTop: number }'}) + {' '} + - scrolls the grid to the specified horizontal and vertical + pixel offsets. +

    +
  • +
  • +

    + + scrollToItem( + { + '{align: string = "auto", columnIndex?: number, rowIndex?: number }' + } + ) + {' '} + - scrolls the grid to the specified row and columns indices +

    +
  • From ab7a05cd0e3caa479c40d50df0fd10d52ad4fa53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 6 Jul 2022 17:53:05 +0000 Subject: [PATCH 10/10] Fix RowHeightUtils mock implementation --- .../datagrid/body/data_grid_body.test.tsx | 29 +++++++--- .../datagrid/body/data_grid_cell.test.tsx | 9 ++- .../datagrid/utils/__mocks__/row_heights.ts | 55 ++++++++++++------- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/src/components/datagrid/body/data_grid_body.test.tsx b/src/components/datagrid/body/data_grid_body.test.tsx index 422162b9492..f0a551d2201 100644 --- a/src/components/datagrid/body/data_grid_body.test.tsx +++ b/src/components/datagrid/body/data_grid_body.test.tsx @@ -9,12 +9,28 @@ import React from 'react'; import { mount, render, shallow } from 'enzyme'; -import { mockRowHeightUtils } from '../utils/__mocks__/row_heights'; +import { RowHeightUtils } from '../utils/__mocks__/row_heights'; import { schemaDetectors } from '../utils/data_grid_schema'; import { EuiDataGridBody, Cell } from './data_grid_body'; describe('EuiDataGridBody', () => { + const gridRef = { + current: { + resetAfterColumnIndex: jest.fn(), + resetAfterRowIndex: jest.fn(), + } as any, + }; + const outerGridElementRef = { current: null }; + const gridItemsRendered = { current: null }; + const rerenderGridBodyRef = { current: null }; + const rowHeightUtils = new RowHeightUtils( + gridRef, + outerGridElementRef, + gridItemsRendered, + rerenderGridBodyRef + ); + const requiredProps = { headerIsInteractive: true, rowCount: 1, @@ -39,17 +55,12 @@ describe('EuiDataGridBody', () => { setVisibleColumns: jest.fn(), switchColumnPos: jest.fn(), schemaDetectors, - rowHeightUtils: mockRowHeightUtils, + rowHeightUtils, isFullScreen: false, gridStyles: {}, gridWidth: 300, - gridRef: { - current: { - resetAfterColumnIndex: jest.fn(), - resetAfterRowIndex: jest.fn(), - } as any, - }, - gridItemsRendered: {} as any, + gridRef, + gridItemsRendered, wrapperRef: { current: document.createElement('div') }, }; diff --git a/src/components/datagrid/body/data_grid_cell.test.tsx b/src/components/datagrid/body/data_grid_cell.test.tsx index e17973a670d..214caac9bd5 100644 --- a/src/components/datagrid/body/data_grid_cell.test.tsx +++ b/src/components/datagrid/body/data_grid_cell.test.tsx @@ -9,13 +9,20 @@ import React, { useEffect } from 'react'; import { mount, render, ReactWrapper } from 'enzyme'; import { keys } from '../../../services'; -import { mockRowHeightUtils } from '../utils/__mocks__/row_heights'; +import { RowHeightUtils } from '../utils/__mocks__/row_heights'; import { mockFocusContext } from '../utils/__mocks__/focus_context'; import { DataGridFocusContext } from '../utils/focus'; import { EuiDataGridCell } from './data_grid_cell'; describe('EuiDataGridCell', () => { + const mockRowHeightUtils = new RowHeightUtils( + { current: null }, + { current: null }, + { current: null }, + { current: null } + ); + const mockPopoverContext = { popoverIsOpen: false, cellLocation: { rowIndex: 0, colIndex: 0 }, diff --git a/src/components/datagrid/utils/__mocks__/row_heights.ts b/src/components/datagrid/utils/__mocks__/row_heights.ts index d5fbe3768b7..79d93d2af4b 100644 --- a/src/components/datagrid/utils/__mocks__/row_heights.ts +++ b/src/components/datagrid/utils/__mocks__/row_heights.ts @@ -8,26 +8,39 @@ import { RowHeightUtils as ActualRowHeightUtils } from '../row_heights'; -const actual = new ActualRowHeightUtils(); +type RowHeightUtilsPublicAPI = Pick< + ActualRowHeightUtils, + keyof ActualRowHeightUtils +>; -export const mockRowHeightUtils = ({ - cacheStyles: jest.fn(), - setGrid: jest.fn(), - getStylesForCell: jest.fn(() => ({ - wordWrap: 'break-word', - wordBreak: 'break-word', - flexGrow: 1, - })), - isAutoHeight: jest.fn(() => false), - setRowHeight: jest.fn(), - pruneHiddenColumnHeights: jest.fn(), - getRowHeight: jest.fn(() => 32), - getRowHeightOption: jest.fn(actual.getRowHeightOption), - getCalculatedHeight: jest.fn(actual.getCalculatedHeight), - getLineCount: jest.fn(actual.getLineCount), - calculateHeightForLineCount: jest.fn(() => 50), - isRowHeightOverride: jest.fn(actual.isRowHeightOverride), - setRerenderGridBody: jest.fn(), -} as unknown) as ActualRowHeightUtils; +export const RowHeightUtils = jest + .fn< + ActualRowHeightUtils, + ConstructorParameters + >() + .mockImplementation((...args) => { + const rowHeightUtils = new ActualRowHeightUtils(...args); -export const RowHeightUtils = jest.fn(() => mockRowHeightUtils); + const rowHeightUtilsMock: RowHeightUtilsPublicAPI = { + cacheStyles: jest.fn(), + getStylesForCell: jest.fn(() => ({ + wordWrap: 'break-word', + wordBreak: 'break-word', + flexGrow: 1, + })), + isAutoHeight: jest.fn(() => false), + setRowHeight: jest.fn(), + pruneHiddenColumnHeights: jest.fn(), + getRowHeight: jest.fn(() => 32), + getRowHeightOption: jest.fn(rowHeightUtils.getRowHeightOption), + getCalculatedHeight: jest.fn(rowHeightUtils.getCalculatedHeight), + getLineCount: jest.fn(rowHeightUtils.getLineCount), + calculateHeightForLineCount: jest.fn(() => 50), + isRowHeightOverride: jest.fn(rowHeightUtils.isRowHeightOverride), + resetRow: jest.fn(), + resetGrid: jest.fn(), + compensateForLayoutShift: jest.fn(), + }; + + return (rowHeightUtilsMock as any) as ActualRowHeightUtils; + });