From a31880b28da88469e0d165e9220469690d66e86b Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 1 Feb 2022 12:43:45 -0800 Subject: [PATCH] [EuiDataGrid] Handle exposed ref APIs potentially pointing to invalid, off-page, or out of view cells (#5572) * Enable sorting + targeting row indices outside of the current page - to test handling the exposed APIs when dealing with sorted/paginated data * Switch data grid example to Typescript - to test type issues during consumer usage + @ts-ignore faker complaints * Fix cell expansion buttons on paginated pages not working correctly * Attempt to more clearly document `rowIndex`s that are actually `visibleRowIndex`s * [setup] Move imperative handler setup to its own util file - this will let us set up ref-specific helpers & add more comment context without bloating the main file * Add catch/check for cell locations that do not exist in the current grid * Add getVisibleRowIndex helper - Converts the `rowIndex` from the consumer to a `visibleRowIndex` that our internal state can use - Account for sorted grid by finding the inversed index of the `sortedRowMap` - To make this easier, I converted soredRowMap to an array (since it's already only uses numbers for both keys and values), since arrays have a handy .findIndex method - Handles automatically paginating the grid if the targeted cell is on a different page * Replace grid ref Jest tests with more complete Cypress tests * Update documentation with new behavior * [PR feedback] Rename fns to indicate multiple concerns - having a side effect in a getter feels bad, so change that to a `find` - rename use hook to indicate sorting and pagination concerns --- .../views/datagrid/datagrid_ref_example.js | 30 ++- .../src/views/datagrid/{ref.js => ref.tsx} | 49 +++-- .../datagrid/body/data_grid_cell.tsx | 6 +- .../header/data_grid_header_cell.test.tsx | 2 +- src/components/datagrid/data_grid.test.tsx | 29 +-- src/components/datagrid/data_grid.tsx | 27 +-- src/components/datagrid/data_grid_types.ts | 10 +- src/components/datagrid/utils/ref.spec.tsx | 198 ++++++++++++++++++ src/components/datagrid/utils/ref.test.ts | 105 ++++++++++ src/components/datagrid/utils/ref.ts | 154 ++++++++++++++ src/components/datagrid/utils/scrolling.ts | 3 + src/components/datagrid/utils/sorting.ts | 30 +-- 12 files changed, 556 insertions(+), 87 deletions(-) rename src-docs/src/views/datagrid/{ref.js => ref.tsx} (78%) create mode 100644 src/components/datagrid/utils/ref.spec.tsx create mode 100644 src/components/datagrid/utils/ref.test.ts create mode 100644 src/components/datagrid/utils/ref.ts diff --git a/src-docs/src/views/datagrid/datagrid_ref_example.js b/src-docs/src/views/datagrid/datagrid_ref_example.js index ee550b19479..40a2c47f54b 100644 --- a/src-docs/src/views/datagrid/datagrid_ref_example.js +++ b/src-docs/src/views/datagrid/datagrid_ref_example.js @@ -28,7 +28,7 @@ export const DataGridRefExample = { { source: [ { - type: GuideSectionTypes.JS, + type: GuideSectionTypes.TSX, code: dataGridRefSource, }, ], @@ -63,10 +63,30 @@ export const DataGridRefExample = {
  • -

    - openCellPopover({'{ rowIndex, colIndex }'}) - - opens the specified cell's popover contents. -

    + openCellPopover({'{ rowIndex, colIndex }'}) - + opens the specified cell's popover contents. + + + When using setFocusedCell or{' '} + openCellPopover, keep in mind: +
      +
    • + colIndex is affected by the user reordering or hiding + columns. +
    • +
    • + If the passed cell indices are outside the data grid's + total row count or visible column count, an error will be + thrown. +
    • +
    • + If the data grid is paginated or sorted, the grid will + handle automatically finding specified row index's + correct location for you. +
    • +
    +
    +
  • diff --git a/src-docs/src/views/datagrid/ref.js b/src-docs/src/views/datagrid/ref.tsx similarity index 78% rename from src-docs/src/views/datagrid/ref.js rename to src-docs/src/views/datagrid/ref.tsx index 2ce537c6884..267892df41e 100644 --- a/src-docs/src/views/datagrid/ref.js +++ b/src-docs/src/views/datagrid/ref.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, useState, useRef } from 'react'; +// @ts-ignore - faker does not have type declarations import { fake } from 'faker'; import { @@ -9,15 +10,16 @@ import { EuiFieldNumber, EuiButton, EuiDataGrid, + EuiDataGridRefProps, EuiModal, EuiModalBody, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, EuiText, -} from '../../../../src/components/'; +} from '../../../../src/components'; -const raw_data = []; +const raw_data: Array<{ [key: string]: string }> = []; for (let i = 1; i < 100; i++) { raw_data.push({ name: fake('{{name.lastName}}, {{name.firstName}}'), @@ -29,20 +31,23 @@ for (let i = 1; i < 100; i++) { } export default () => { - const dataGridRef = useRef(); + const dataGridRef = useRef(null); // Modal const [isModalVisible, setIsModalVisible] = useState(false); - const [lastFocusedCell, setLastFocusedCell] = useState({}); + const [lastFocusedCell, setLastFocusedCell] = useState({ + rowIndex: 0, + colIndex: 0, + }); const closeModal = useCallback(() => { setIsModalVisible(false); - dataGridRef.current.setFocusedCell(lastFocusedCell); // Set the data grid focus back to the cell that opened the modal + dataGridRef.current!.setFocusedCell(lastFocusedCell); // Set the data grid focus back to the cell that opened the modal }, [lastFocusedCell]); const showModal = useCallback(({ rowIndex, colIndex }) => { setIsModalVisible(true); - dataGridRef.current.closeCellPopover(); // Close any open cell popovers + dataGridRef.current!.closeCellPopover(); // Close any open cell popovers setLastFocusedCell({ rowIndex, colIndex }); // Store the cell that opened this modal }, []); @@ -101,11 +106,18 @@ export default () => { // Pagination const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); - const onChangePage = useCallback( - (pageIndex) => - setPagination((pagination) => ({ ...pagination, pageIndex })), - [] - ); + const onChangePage = useCallback((pageIndex) => { + setPagination((pagination) => ({ ...pagination, pageIndex })); + }, []); + const onChangePageSize = useCallback((pageSize) => { + setPagination((pagination) => ({ ...pagination, pageSize })); + }, []); + + // Sorting + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback((sortingColumns) => { + setSortingColumns(sortingColumns); + }, []); // Manual cell focus const [rowIndexAction, setRowIndexAction] = useState(0); @@ -118,7 +130,7 @@ export default () => { setRowIndexAction(Number(e.target.value))} compressed @@ -129,7 +141,7 @@ export default () => { setColIndexAction(Number(e.target.value))} compressed @@ -140,7 +152,7 @@ export default () => { - dataGridRef.current.setFocusedCell({ + dataGridRef.current!.setFocusedCell({ rowIndex: rowIndexAction, colIndex: colIndexAction, }) @@ -153,7 +165,7 @@ export default () => { - dataGridRef.current.openCellPopover({ + dataGridRef.current!.openCellPopover({ rowIndex: rowIndexAction, colIndex: colIndexAction, }) @@ -165,7 +177,7 @@ export default () => { dataGridRef.current.setIsFullScreen(true)} + onClick={() => dataGridRef.current!.setIsFullScreen(true)} > Set grid to full screen @@ -177,14 +189,17 @@ export default () => { aria-label="Data grid demo" columns={columns} columnVisibility={{ visibleColumns, setVisibleColumns }} + sorting={{ columns: sortingColumns, onSort }} + inMemory={{ level: 'sorting' }} rowCount={raw_data.length} renderCellValue={({ rowIndex, columnId }) => raw_data[rowIndex][columnId] } pagination={{ ...pagination, - pageSizeOptions: [25], + pageSizeOptions: [25, 50], onChangePage: onChangePage, + onChangeItemsPerPage: onChangePageSize, }} height={400} ref={dataGridRef} diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/data_grid_cell.tsx index 7413dfc58e8..b6041330b0f 100644 --- a/src/components/datagrid/body/data_grid_cell.tsx +++ b/src/components/datagrid/body/data_grid_cell.tsx @@ -490,7 +490,7 @@ export class EuiDataGridCell extends Component< rowManager, ...rest } = this.props; - const { rowIndex, colIndex } = rest; + const { rowIndex, visibleRowIndex, colIndex } = rest; const popoverIsOpen = this.isPopoverOpen(); const hasCellButtons = isExpandable || column?.cellActions; @@ -534,7 +534,7 @@ export class EuiDataGridCell extends Component< case keys.ENTER: case keys.F2: event.preventDefault(); - openCellPopover({ rowIndex, colIndex }); + openCellPopover({ rowIndex: visibleRowIndex, colIndex }); break; } } else { @@ -641,7 +641,7 @@ export class EuiDataGridCell extends Component< if (popoverIsOpen) { closeCellPopover(); } else { - openCellPopover({ rowIndex, colIndex }); + openCellPopover({ rowIndex: visibleRowIndex, colIndex }); } }} /> diff --git a/src/components/datagrid/body/header/data_grid_header_cell.test.tsx b/src/components/datagrid/body/header/data_grid_header_cell.test.tsx index e17c2f65a82..2e26839e72f 100644 --- a/src/components/datagrid/body/header/data_grid_header_cell.test.tsx +++ b/src/components/datagrid/body/header/data_grid_header_cell.test.tsx @@ -49,7 +49,7 @@ describe('EuiDataGridHeaderCell', () => { diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index 019b20824b7..846d8f27d73 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import React, { useEffect, useState, createRef } from 'react'; +import React, { useEffect, useState } from 'react'; import { mount, ReactWrapper, render } from 'enzyme'; import { EuiDataGrid } from './'; -import { EuiDataGridProps, EuiDataGridRefProps } from './data_grid_types'; +import { EuiDataGridProps } from './data_grid_types'; import { findTestSubject, requiredProps, @@ -2749,29 +2749,4 @@ describe('EuiDataGrid', () => { expect(takeMountedSnapshot(component)).toMatchSnapshot(); }); }); - - it('returns a ref which exposes internal imperative APIs', () => { - const gridRef = createRef(); - - mount( - {}, - }} - rowCount={1} - renderCellValue={() => 'value'} - ref={gridRef} - /> - ); - - expect(gridRef.current).toEqual({ - setIsFullScreen: expect.any(Function), - setFocusedCell: expect.any(Function), - openCellPopover: expect.any(Function), - closeCellPopover: expect.any(Function), - }); - }); }); diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index e84c0caa00a..bed2f94fb14 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -13,7 +13,6 @@ import React, { useMemo, useRef, useState, - useImperativeHandle, } from 'react'; import { VariableSizeGrid as Grid, @@ -56,6 +55,7 @@ import { schemaDetectors as providedSchemaDetectors, useMergedSchema, } from './utils/data_grid_schema'; +import { useImperativeGridRef } from './utils/ref'; import { EuiDataGridColumn, EuiDataGridProps, @@ -302,23 +302,18 @@ export const EuiDataGrid = forwardRef( }; /** - * Expose internal APIs as ref to consumer + * Expose certain internal APIs as ref to consumer */ - const { setFocusedCell } = focusContext; // eslint complains about the dependency array otherwise - const { openCellPopover, closeCellPopover } = cellPopoverContext; - - useImperativeHandle( + useImperativeGridRef({ ref, - () => ({ - setIsFullScreen, - setFocusedCell: ({ rowIndex, colIndex }) => { - setFocusedCell([colIndex, rowIndex]); // Transmog args from obj to array - }, - openCellPopover, - closeCellPopover, - }), - [setFocusedCell, openCellPopover, closeCellPopover] - ); + setIsFullScreen, + focusContext, + cellPopoverContext, + sortingContext, + pagination, + rowCount, + visibleColCount, + }); /** * Classes diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index d3d8abdbb1e..48618ab813d 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -179,10 +179,13 @@ export interface EuiDataGridVisibleRows { export interface DataGridSortingContextShape { sorting?: EuiDataGridSorting; - sortedRowMap: { [key: number]: number }; - getCorrectRowIndex: (rowIndex: number) => number; + sortedRowMap: number[]; + getCorrectRowIndex: (visibleRowIndex: number) => number; } +// An array of [x,y] coordinates. Note that the `y` value expected internally is a `visibleRowIndex` +export type EuiDataGridFocusedCell = [number, number]; + export interface DataGridFocusContextShape { focusedCell?: EuiDataGridFocusedCell; setFocusedCell: (cell: EuiDataGridFocusedCell) => void; @@ -196,6 +199,7 @@ export interface DataGridFocusContextShape { export interface DataGridCellPopoverContextShape { popoverIsOpen: boolean; + // Note that the rowIndex used to locate cells internally is a `visibleRowIndex` cellLocation: { rowIndex: number; colIndex: number }; openCellPopover(args: { rowIndex: number; colIndex: number }): void; closeCellPopover(): void; @@ -764,8 +768,6 @@ export interface EuiDataGridInMemory { skipColumns?: string[]; } -export type EuiDataGridFocusedCell = [number, number]; - export interface EuiDataGridInMemoryValues { [rowIndex: string]: { [columnId: string]: string }; } diff --git a/src/components/datagrid/utils/ref.spec.tsx b/src/components/datagrid/utils/ref.spec.tsx new file mode 100644 index 00000000000..59f8937ddd3 --- /dev/null +++ b/src/components/datagrid/utils/ref.spec.tsx @@ -0,0 +1,198 @@ +/* + * 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 React, { useState, createRef, forwardRef } from 'react'; +import { EuiDataGrid } from '../'; +import { EuiDataGridRefProps } from '../data_grid_types'; + +// We need to set up a test component here for sorting/pagination state to work +// The underlying imperative ref should still be forwarded and work as normal +const GridTest = forwardRef((_, ref) => { + // Pagination + const [pageIndex, setPageIndex] = useState(0); + const onChangePage = (pageIndex) => setPageIndex(pageIndex); + + // Sorting + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = (sortingColumns) => setSortingColumns(sortingColumns); + + return ( + {}, + }} + rowCount={100} + renderCellValue={({ rowIndex, columnId }) => `${columnId}, ${rowIndex}`} + pagination={{ + pageIndex, + pageSize: 25, + onChangePage, + onChangeItemsPerPage: () => {}, + }} + sorting={{ columns: sortingColumns, onSort }} + inMemory={{ level: 'sorting' }} + /> + ); +}); +GridTest.displayName = 'GridTest'; + +describe('useImperativeGridRef', () => { + const ref = createRef(); + + beforeEach(() => { + cy.mount(); + cy.get('[data-test-subj="euiDataGridBody"]'); // Wait for the grid to finish rendering and pass back the ref + }); + + describe('setIsFullScreen', () => { + it('allows the consumer to manually toggle full screen mode', () => { + ref.current.setIsFullScreen(true); + cy.get('[data-test-subj="euiDataGrid"]').should( + 'have.class', + 'euiDataGrid--fullScreen' + ); + // Has to be a separate .then() block from the above for some Cypress-y reason + cy.then(() => { + ref.current.setIsFullScreen(false); + cy.get('[data-test-subj="euiDataGrid"]').should( + 'not.have.class', + 'euiDataGrid--fullScreen' + ); + }); + }); + }); + + describe('setFocusedCell', () => { + it('allows the consumer to manually focus into a specific grid cell', () => { + ref.current.setFocusedCell({ rowIndex: 1, colIndex: 1 }); + cy.focused() + .should('have.attr', 'data-gridcell-visible-row-index', '1') + .should('have.attr', 'data-gridcell-column-index', '1'); + }); + + it('should scroll to cells that are not in view', () => { + ref.current.setFocusedCell({ rowIndex: 24, colIndex: 5 }); + cy.focused() + .should('have.attr', 'data-gridcell-visible-row-index', '24') + .should('have.attr', 'data-gridcell-column-index', '5'); + }); + + it('should paginate to cells that are not on the current page', () => { + ref.current.setFocusedCell({ rowIndex: 50, colIndex: 0 }); + cy.get('.euiPagination .euiScreenReaderOnly').should( + 'have.text', + 'Page 3 of 4' + ); + cy.focused() + .should('have.attr', 'data-gridcell-visible-row-index', '0') + .should('have.attr', 'data-gridcell-column-index', '0'); + }); + + it('should correctly find the specified rowIndex when sorted', () => { + cy.get('[data-test-subj="dataGridHeaderCell-A"]').click(); + cy.contains('Sort High-Low').click(); + cy.then(() => { + ref.current.setFocusedCell({ rowIndex: 95, colIndex: 0 }); + cy.focused() + .should('have.attr', 'data-gridcell-visible-row-index', '4') + .should('have.attr', 'data-gridcell-column-index', '0'); + }); + }); + + it('should throw an error if the passed cell indices are invalid', () => { + cy.on('fail', (err) => { + expect(err.message).to.equal( + 'Row 150 is not a valid row. The maximum visible row index is 99.' + ); + }); + ref.current.setFocusedCell({ rowIndex: 150, colIndex: 0 }); + }); + }); + + describe('openCellPopover', () => { + it("allows the consumer to manually open a specific grid cell's popover", () => { + ref.current.openCellPopover({ rowIndex: 2, colIndex: 2 }); + cy.focused() + .should('have.attr', 'data-test-subj', 'euiDataGridExpansionPopover') + .find('.euiText') + .should('have.text', 'C, 2'); + }); + + it('should scroll to cells that are not in view', () => { + ref.current.openCellPopover({ rowIndex: 23, colIndex: 0 }); + cy.focused() + .should('have.attr', 'data-test-subj', 'euiDataGridExpansionPopover') + .find('.euiText') + .should('have.text', 'A, 23'); + }); + + it('should paginate to cells that are not on the current page', () => { + ref.current.openCellPopover({ rowIndex: 99, colIndex: 5 }); + cy.get('.euiPagination .euiScreenReaderOnly').should( + 'have.text', + 'Page 4 of 4' + ); + cy.focused() + .should('have.attr', 'data-test-subj', 'euiDataGridExpansionPopover') + .find('.euiText') + .should('have.text', 'F, 99'); + }); + + it('should correctly find the specified rowIndex when sorted', () => { + cy.get('[data-test-subj="dataGridHeaderCell-A"]').click(); + cy.contains('Sort High-Low').click(); + cy.then(() => { + ref.current.openCellPopover({ rowIndex: 98, colIndex: 1 }); + cy.focused() + .should('have.attr', 'data-test-subj', 'euiDataGridExpansionPopover') + .find('.euiText') + .should('have.text', 'B, 98'); + }); + }); + + it('should throw an error if the passed cell indices are invalid', () => { + cy.on('fail', (err) => { + expect(err.message).to.equal( + 'Column 10 is not a valid column. The maximum visible column index is 5.' + ); + }); + ref.current.openCellPopover({ rowIndex: 0, colIndex: 10 }); + }); + }); + + describe('closeCellPopover', () => { + it('allows the consumer to manually close any open popovers', () => { + ref.current.setFocusedCell({ colIndex: 0, rowIndex: 0 }); + cy.realPress('Enter'); + cy.get('[data-test-subj="euiDataGridExpansionPopover"]').should( + 'have.length', + 1 + ); + cy.then(() => { + ref.current.closeCellPopover(); + cy.get('[data-test-subj="euiDataGridExpansionPopover"]').should( + 'have.length', + 0 + ); + }); + }); + }); +}); diff --git a/src/components/datagrid/utils/ref.test.ts b/src/components/datagrid/utils/ref.test.ts new file mode 100644 index 00000000000..bf49b5fcd87 --- /dev/null +++ b/src/components/datagrid/utils/ref.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { testCustomHook } from '../../../test/test_custom_hook.test_helper'; +import { useCellLocationCheck, useSortPageCheck } from './ref'; + +// see ref.spec.tsx for E2E useImperativeGridRef tests + +describe('useCellLocationCheck', () => { + const { + return: { checkCellExists }, + } = testCustomHook(() => useCellLocationCheck(10, 5)); + + it("throws an error if the passed rowIndex is higher than the grid's rowCount", () => { + expect(() => { + checkCellExists({ rowIndex: 12, colIndex: 0 }); + }).toThrow( + 'Row 12 is not a valid row. The maximum visible row index is 9.' + ); + }); + + it("throws an error if the passed colIndex is higher than the grid's visibleColCount", () => { + expect(() => { + checkCellExists({ rowIndex: 1, colIndex: 5 }); + }).toThrow( + 'Column 5 is not a valid column. The maximum visible column index is 4.' + ); + }); + + it('does not throw if the rowIndex and colIndex are within grid bounds', () => { + expect(() => { + checkCellExists({ rowIndex: 0, colIndex: 0 }); + }).not.toThrow(); + }); +}); + +describe('useSortPageCheck', () => { + describe('if the grid is not sorted or paginated', () => { + const pagination = undefined; + const sortedRowMap: number[] = []; + + it('returns the passed rowIndex as-is', () => { + const { + return: { findVisibleRowIndex }, + } = testCustomHook(() => useSortPageCheck(pagination, sortedRowMap)); + + expect(findVisibleRowIndex(5)).toEqual(5); + }); + }); + + describe('if the grid is sorted', () => { + const pagination = undefined; + const sortedRowMap = [3, 4, 1, 2, 0]; + + it('returns the visibleRowIndex of the passed rowIndex (which is the index of the sortedRowMap)', () => { + const { + return: { findVisibleRowIndex }, + } = testCustomHook(() => useSortPageCheck(pagination, sortedRowMap)); + + expect(findVisibleRowIndex(0)).toEqual(4); + expect(findVisibleRowIndex(1)).toEqual(2); + expect(findVisibleRowIndex(2)).toEqual(3); + expect(findVisibleRowIndex(3)).toEqual(0); + expect(findVisibleRowIndex(4)).toEqual(1); + }); + }); + + describe('if the grid is paginated', () => { + const pagination = { + pageSize: 20, + pageIndex: 0, + onChangePage: jest.fn(), + onChangeItemsPerPage: jest.fn(), + }; + const sortedRowMap: number[] = []; + + beforeEach(() => jest.clearAllMocks()); + + it('calculates what page the row should be on, paginates to that page, and returns the index of the row on that page', () => { + const { + return: { findVisibleRowIndex }, + } = testCustomHook(() => useSortPageCheck(pagination, sortedRowMap)); + + expect(findVisibleRowIndex(20)).toEqual(0); // First item on 2nd page + expect(pagination.onChangePage).toHaveBeenLastCalledWith(1); + + expect(findVisibleRowIndex(75)).toEqual(15); // 16th item on 4th page + expect(pagination.onChangePage).toHaveBeenLastCalledWith(3); + }); + + it('does not paginate if the user is already on the correct page', () => { + const { + return: { findVisibleRowIndex }, + } = testCustomHook(() => useSortPageCheck(pagination, sortedRowMap)); + + expect(findVisibleRowIndex(5)).toEqual(5); + expect(pagination.onChangePage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/datagrid/utils/ref.ts b/src/components/datagrid/utils/ref.ts new file mode 100644 index 00000000000..88aebb8eb8d --- /dev/null +++ b/src/components/datagrid/utils/ref.ts @@ -0,0 +1,154 @@ +/* + * 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 { useImperativeHandle, useCallback, Ref } from 'react'; +import { + EuiDataGridRefProps, + EuiDataGridProps, + DataGridFocusContextShape, + DataGridCellPopoverContextShape, + DataGridSortingContextShape, +} from '../data_grid_types'; + +interface Dependencies { + ref: Ref; + setIsFullScreen: EuiDataGridRefProps['setIsFullScreen']; + focusContext: DataGridFocusContextShape; + cellPopoverContext: DataGridCellPopoverContextShape; + sortingContext: DataGridSortingContextShape; + pagination: EuiDataGridProps['pagination']; + rowCount: number; + visibleColCount: number; +} + +export const useImperativeGridRef = ({ + ref, + setIsFullScreen, + focusContext, + cellPopoverContext, + sortingContext: { sortedRowMap }, + pagination, + rowCount, + visibleColCount, +}: Dependencies) => { + // Cell location helpers + const { checkCellExists } = useCellLocationCheck(rowCount, visibleColCount); + const { findVisibleRowIndex } = useSortPageCheck(pagination, sortedRowMap); + + // Focus APIs + const { setFocusedCell: _setFocusedCell } = focusContext; // eslint complains about the dependency array otherwise + + // When we pass this API to the consumer, we can't know for sure that + // the targeted cell is valid or in view (unlike our internal state, where + // both of those states can be guaranteed), so we need to do some extra + // checks here to make sure the grid automatically handles all cells + const setFocusedCell = useCallback( + ({ rowIndex, colIndex }) => { + checkCellExists({ rowIndex, colIndex }); + const visibleRowIndex = findVisibleRowIndex(rowIndex); + _setFocusedCell([colIndex, visibleRowIndex]); // Transmog args from obj to array + }, + [_setFocusedCell, checkCellExists, findVisibleRowIndex] + ); + + // Popover APIs + const { + openCellPopover: _openCellPopover, + closeCellPopover, + } = cellPopoverContext; + + // When we pass this API to the consumer, we can't know for sure that + // the targeted cell is valid or in view (unlike our internal state, where + // both of those states can be guaranteed), so we need to do some extra + // checks here to make sure the grid automatically handles all cells + const openCellPopover = useCallback( + ({ rowIndex, colIndex }) => { + checkCellExists({ rowIndex, colIndex }); + const visibleRowIndex = findVisibleRowIndex(rowIndex); + _openCellPopover({ rowIndex: visibleRowIndex, colIndex }); + }, + [_openCellPopover, checkCellExists, findVisibleRowIndex] + ); + + // Set the ref APIs + useImperativeHandle( + ref, + () => ({ + setIsFullScreen, + setFocusedCell, + openCellPopover, + closeCellPopover, + }), + [setIsFullScreen, setFocusedCell, openCellPopover, closeCellPopover] + ); +}; + +/** + * Throw a digestible error if the consumer attempts to focus into an invalid + * cell range, which should also stop the APIs from continuing + */ +export const useCellLocationCheck = (rowCount: number, colCount: number) => { + const checkCellExists = useCallback( + ({ rowIndex, colIndex }) => { + if (rowIndex >= rowCount || rowIndex < 0) { + throw new Error( + `Row ${rowIndex} is not a valid row. The maximum visible row index is ${ + rowCount - 1 + }.` + ); + } + if (colIndex >= colCount) { + throw new Error( + `Column ${colIndex} is not a valid column. The maximum visible column index is ${ + colCount - 1 + }.` + ); + } + }, + [rowCount, colCount] + ); + + return { checkCellExists }; +}; + +/** + * The rowIndex passed from the consumer is the unsorted and unpaginated + * index derived from their original data. We need to convert that rowIndex + * into a visibleRowIndex (which is what our internal cell APIs use) and, if + * the row is not on the current page, the grid should automatically handle + * paginating to that row. + */ +export const useSortPageCheck = ( + pagination: EuiDataGridProps['pagination'], + sortedRowMap: DataGridSortingContextShape['sortedRowMap'] +) => { + const findVisibleRowIndex = useCallback( + (rowIndex: number): number => { + // Account for sorting + const visibleRowIndex = sortedRowMap.length + ? sortedRowMap.findIndex((mappedIndex) => mappedIndex === rowIndex) + : rowIndex; + + // Account for pagination + if (pagination) { + const pageIndex = Math.floor(visibleRowIndex / pagination.pageSize); + // If the targeted row is on a different page than the current page, + // we should automatically navigate the user to the correct page + if (pageIndex !== pagination.pageIndex) { + pagination.onChangePage(pageIndex); + } + // Get the row's visible row index on that page + return visibleRowIndex % pagination.pageSize; + } + return visibleRowIndex; + }, + [pagination, sortedRowMap] + ); + + return { findVisibleRowIndex }; +}; diff --git a/src/components/datagrid/utils/scrolling.ts b/src/components/datagrid/utils/scrolling.ts index 4aaf3df4ee3..486cecfc983 100644 --- a/src/components/datagrid/utils/scrolling.ts +++ b/src/components/datagrid/utils/scrolling.ts @@ -74,6 +74,9 @@ export const useScrollCellIntoView = ({ hasStickyFooter, }: Dependencies) => { const scrollCellIntoView = useCallback( + // Note: in order for this UX to work correctly with react-window's APIs, + // the `rowIndex` arg expected is actually our internal `visibleRowIndex`, + // not the `rowIndex` from the raw unsorted/unpaginated user data async ({ rowIndex, colIndex }: ScrollCellIntoView) => { if (!gridRef.current || !outerGridRef.current || !innerGridRef.current) { return; // Grid isn't rendered yet or is empty diff --git a/src/components/datagrid/utils/sorting.ts b/src/components/datagrid/utils/sorting.ts index 9a4f17e6eed..59a9d11fce7 100644 --- a/src/components/datagrid/utils/sorting.ts +++ b/src/components/datagrid/utils/sorting.ts @@ -21,7 +21,7 @@ export const DataGridSortingContext = createContext< DataGridSortingContextShape >({ sorting: undefined, - sortedRowMap: {}, + sortedRowMap: [], getCorrectRowIndex: (number) => number, }); @@ -43,7 +43,7 @@ export const useSorting = ({ const sortingColumns = sorting?.columns; const sortedRowMap = useMemo(() => { - const rowMap: DataGridSortingContextShape['sortedRowMap'] = {}; + const rowMap: DataGridSortingContextShape['sortedRowMap'] = []; if ( inMemory?.level === 'sorting' && @@ -103,19 +103,21 @@ export const useSorting = ({ schemaDetectors, ]); + // Given a visible row index, obtain the unpaginated & unsorted + // row index from the passed cell data const getCorrectRowIndex = useCallback( - (rowIndex: number) => { - let rowIndexWithOffset = rowIndex; - - if (rowIndex - startRow < 0) { - rowIndexWithOffset = rowIndex + startRow; - } - - const correctRowIndex = sortedRowMap.hasOwnProperty(rowIndexWithOffset) - ? sortedRowMap[rowIndexWithOffset] - : rowIndexWithOffset; - - return correctRowIndex; + (visibleRowIndex: number) => { + const isPaginated = visibleRowIndex - startRow < 0; + const unpaginatedRowIndex = isPaginated + ? visibleRowIndex + startRow + : visibleRowIndex; + + const unsortedRowIndex = + unpaginatedRowIndex in sortedRowMap + ? sortedRowMap[unpaginatedRowIndex] + : unpaginatedRowIndex; + + return unsortedRowIndex; }, [startRow, sortedRowMap] );