diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a907b60bf..0150fcadd80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [`main`](https://github.com/elastic/eui/tree/main) +- Added the ability to control internal `EuiDataGrid` fullscreen, cell focus, and cell popover state via the `ref` prop ([#5590](https://github.com/elastic/eui/pull/5590)) + **Bug fixes** - Fixed `EuiInMemoryTable`'s `onTableChange` callback not returning the correct `sort.field` value on pagination ([#5588](https://github.com/elastic/eui/pull/5588)) diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 11d3ff15f0a..23c7d8366e5 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -89,6 +89,7 @@ import { DataGridControlColumnsExample } from './views/datagrid/datagrid_control import { DataGridFooterRowExample } from './views/datagrid/datagrid_footer_row_example'; import { DataGridVirtualizationExample } from './views/datagrid/datagrid_virtualization_example'; import { DataGridRowHeightOptionsExample } from './views/datagrid/datagrid_height_options_example'; +import { DataGridRefExample } from './views/datagrid/datagrid_ref_example'; import { DatePickerExample } from './views/date_picker/date_picker_example'; @@ -489,6 +490,7 @@ const navigation = [ DataGridFooterRowExample, DataGridVirtualizationExample, DataGridRowHeightOptionsExample, + DataGridRefExample, TableExample, TableInMemoryExample, ].map((example) => createExample(example)), diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index a23052743aa..70e0c508fd0 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -6,6 +6,7 @@ import React, { createContext, useContext, useRef, + createRef, } from 'react'; import { Link } from 'react-router-dom'; import { fake } from 'faker'; @@ -29,11 +30,13 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiPopover, + EuiScreenReaderOnly, EuiText, EuiTitle, } from '../../../../src/components/'; -const DataContext = createContext(); +const gridRef = createRef(); +const DataContext = createContext(); const raw_data = []; for (let i = 1; i < 100; i++) { @@ -216,13 +219,20 @@ const trailingControlColumns = [ { id: 'actions', width: 40, - headerCellRender: () => null, - rowCellRender: function RowCellRender() { + headerCellRender: () => ( + + Controls + + ), + rowCellRender: function RowCellRender({ rowIndex, colIndex }) { const [isPopoverVisible, setIsPopoverVisible] = useState(false); const closePopover = () => setIsPopoverVisible(false); const [isModalVisible, setIsModalVisible] = useState(false); - const closeModal = () => setIsModalVisible(false); + const closeModal = () => { + setIsModalVisible(false); + gridRef.current.setFocusedCell({ rowIndex, colIndex }); + }; const showModal = () => { closePopover(); setIsModalVisible(true); @@ -263,7 +273,10 @@ const trailingControlColumns = [ } const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const closeFlyout = () => setIsFlyoutVisible(false); + const closeFlyout = () => { + setIsFlyoutVisible(false); + gridRef.current.setFocusedCell({ rowIndex, colIndex }); + }; const showFlyout = () => { closePopover(); setIsFlyoutVisible(true); @@ -415,6 +428,7 @@ export default () => { onChangePage: onChangePage, }} onColumnResize={onColumnResize.current} + ref={gridRef} /> ); diff --git a/src-docs/src/views/datagrid/datagrid_example.js b/src-docs/src/views/datagrid/datagrid_example.js index 47a606c471c..3ecd3c20e5b 100644 --- a/src-docs/src/views/datagrid/datagrid_example.js +++ b/src-docs/src/views/datagrid/datagrid_example.js @@ -33,6 +33,7 @@ import { EuiDataGridRowHeightsOptions, EuiDataGridCellValueElementProps, EuiDataGridSchemaDetector, + EuiDataGridRefProps, } from '!!prop-loader!../../../../src/components/datagrid/data_grid_types'; const gridSnippet = ` @@ -164,6 +165,8 @@ const gridSnippet = ` ); }, }} + // Optional. For advanced control of internal data grid popover/focus state, passes back an object of API methods + ref={dataGridRef} /> `; @@ -323,6 +326,19 @@ const gridConcepts = [ ), }, + { + title: 'ref', + description: ( + + Passes back an object of internal EuiDataGridRefProps{' '} + methods for advanced control of data grid popover/focus state. See{' '} + + Data grid ref methods + {' '} + for more details and examples. + + ), + }, ]; export const DataGridExample = { @@ -414,6 +430,7 @@ export const DataGridExample = { EuiDataGridToolBarAdditionalControlsLeftOptions, EuiDataGridPopoverContentProps, EuiDataGridRowHeightsOptions, + EuiDataGridRefProps, }, demo: ( diff --git a/src-docs/src/views/datagrid/datagrid_ref_example.js b/src-docs/src/views/datagrid/datagrid_ref_example.js new file mode 100644 index 00000000000..66795c4e25b --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid_ref_example.js @@ -0,0 +1,117 @@ +import React from 'react'; + +import { GuideSectionTypes } from '../../components'; +import { EuiCode, EuiSpacer, EuiCallOut } from '../../../../src/components'; + +import { EuiDataGridRefProps } from '!!prop-loader!../../../../src/components/datagrid/data_grid_types'; +import DataGridRef from './ref'; +const dataGridRefSource = require('!!raw-loader!./ref'); +const dataGridRefSnippet = `const dataGridRef = useRef(); + + +// Mnaually toggle the data grid's full screen state +dataGridRef.current.setIsFullScreen(true); + +// Mnaually focus a specific cell within the data grid +dataGridRef.current.setFocusedCell({ rowIndex, colIndex }); + +// Manually opens the popover of a specified cell within the data grid +dataGridRef.current.openCellPopover({ rowIndex, colIndex }); + +// Close any open cell popover +dataGridRef.current.closeCellPopover(); +`; + +export const DataGridRefExample = { + title: 'Data grid ref methods', + sections: [ + { + source: [ + { + type: GuideSectionTypes.TSX, + code: dataGridRefSource, + }, + ], + text: ( + <> +

+ For advanced use cases, and particularly for data grids that manage + associated modals/flyouts and need to manually control their grid + cell popovers & focus states, we expose certain internal methods via + the ref prop of EuiDataGrid. + These methods are: +

+ + + + + + 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. +
  • +
+
+ + + +

+ The below example shows how to use the internal APIs for a data grid + that opens a modal via cell actions. +

+ + ), + components: { DataGridRef }, + demo: , + snippet: dataGridRefSnippet, + props: { EuiDataGridRefProps }, + }, + ], +}; diff --git a/src-docs/src/views/datagrid/ref.tsx b/src-docs/src/views/datagrid/ref.tsx new file mode 100644 index 00000000000..267892df41e --- /dev/null +++ b/src-docs/src/views/datagrid/ref.tsx @@ -0,0 +1,233 @@ +import React, { useCallback, useMemo, useState, useRef } from 'react'; +// @ts-ignore - faker does not have type declarations +import { fake } from 'faker'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiFormRow, + EuiFieldNumber, + EuiButton, + EuiDataGrid, + EuiDataGridRefProps, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, +} from '../../../../src/components'; + +const raw_data: Array<{ [key: string]: string }> = []; +for (let i = 1; i < 100; i++) { + raw_data.push({ + name: fake('{{name.lastName}}, {{name.firstName}}'), + email: fake('{{internet.email}}'), + location: fake('{{address.city}}, {{address.country}}'), + account: fake('{{finance.account}}'), + date: fake('{{date.past}}'), + }); +} + +export default () => { + const dataGridRef = useRef(null); + + // Modal + const [isModalVisible, setIsModalVisible] = useState(false); + 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 + }, [lastFocusedCell]); + + const showModal = useCallback(({ rowIndex, colIndex }) => { + setIsModalVisible(true); + dataGridRef.current!.closeCellPopover(); // Close any open cell popovers + setLastFocusedCell({ rowIndex, colIndex }); // Store the cell that opened this modal + }, []); + + const openModalAction = useCallback( + ({ Component, rowIndex, colIndex }) => { + return ( + showModal({ rowIndex, colIndex })} + iconType="faceHappy" + aria-label="Open modal" + > + Open modal + + ); + }, + [showModal] + ); + + // Columns + const columns = useMemo( + () => [ + { + id: 'name', + displayAsText: 'Name', + cellActions: [openModalAction], + }, + { + id: 'email', + displayAsText: 'Email address', + initialWidth: 130, + cellActions: [openModalAction], + }, + { + id: 'location', + displayAsText: 'Location', + cellActions: [openModalAction], + }, + { + id: 'account', + displayAsText: 'Account', + cellActions: [openModalAction], + }, + { + id: 'date', + displayAsText: 'Date', + cellActions: [openModalAction], + }, + ], + [openModalAction] + ); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); + + // Pagination + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + 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); + const [colIndexAction, setColIndexAction] = useState(0); + + return ( + <> + + + + setRowIndexAction(Number(e.target.value))} + compressed + /> + + + + + setColIndexAction(Number(e.target.value))} + compressed + /> + + + + + dataGridRef.current!.setFocusedCell({ + rowIndex: rowIndexAction, + colIndex: colIndexAction, + }) + } + > + Set cell focus + + + + + dataGridRef.current!.openCellPopover({ + rowIndex: rowIndexAction, + colIndex: colIndexAction, + }) + } + > + Open cell popover + + + + dataGridRef.current!.setIsFullScreen(true)} + > + Set grid to full screen + + + + + + + raw_data[rowIndex][columnId] + } + pagination={{ + ...pagination, + pageSizeOptions: [25, 50], + onChangePage: onChangePage, + onChangeItemsPerPage: onChangePageSize, + }} + height={400} + ref={dataGridRef} + /> + {isModalVisible && ( + + + +

Example modal

+
+
+ + + +

+ When closed, this modal should re-focus into the cell that + toggled it. +

+
+
+ + + + Close + + +
+ )} + + ); +}; diff --git a/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap b/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap index 03fc9306cda..c8489a5a6ee 100644 --- a/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap +++ b/src/components/datagrid/body/__snapshots__/data_grid_cell.test.tsx.snap @@ -7,6 +7,19 @@ exports[`EuiDataGridCell renders 1`] = ` interactiveCellId="someId" isExpandable={true} popoverContent={[Function]} + popoverContext={ + Object { + "cellLocation": Object { + "colIndex": 0, + "rowIndex": 0, + }, + "closeCellPopover": [MockFunction], + "openCellPopover": [MockFunction], + "popoverIsOpen": false, + "setPopoverAnchor": [MockFunction], + "setPopoverContent": [MockFunction], + } + } renderCellValue={[Function]} rowHeightUtils={ Object { @@ -118,6 +131,7 @@ exports[`EuiDataGridCell renders 1`] = ` style={Object {}} > `; + +exports[`EuiDataGridCell componentDidUpdate handles the cell popover by forwarding the cell's DOM node and contents to the parent popover context 1`] = ` +Array [ +
+
+ + +
+
, +
+
+
+
+
+
, +] +`; diff --git a/src/components/datagrid/body/data_grid_body.tsx b/src/components/datagrid/body/data_grid_body.tsx index 5585073c02b..7acd43a0f1b 100644 --- a/src/components/datagrid/body/data_grid_body.tsx +++ b/src/components/datagrid/body/data_grid_body.tsx @@ -28,6 +28,7 @@ import { EuiDataGridCell } from './data_grid_cell'; import { EuiDataGridFooterRow } from './data_grid_footer_row'; import { EuiDataGridHeaderRow } from './header'; import { DefaultColumnFormatter } from './popover_utils'; +import { DataGridCellPopoverContext } from './data_grid_cell_popover'; import { EuiDataGridBodyProps, EuiDataGridRowManager, @@ -70,6 +71,7 @@ export const Cell: FunctionComponent = ({ rowHeightUtils, rowManager, } = data; + const popoverContext = useContext(DataGridCellPopoverContext); const { headerRowHeight } = useContext(DataGridWrapperRowsContext); const { getCorrectRowIndex } = useContext(DataGridSortingContext); @@ -118,7 +120,8 @@ export const Cell: FunctionComponent = ({ rowHeightsOptions, rowHeightUtils, setRowHeight: isFirstColumn ? setRowHeight : undefined, - rowManager: rowManager, + rowManager, + popoverContext, }; if (isLeadingControlColumn) { diff --git a/src/components/datagrid/body/data_grid_cell.test.tsx b/src/components/datagrid/body/data_grid_cell.test.tsx index e74c9efd830..07e6bb5474c 100644 --- a/src/components/datagrid/body/data_grid_cell.test.tsx +++ b/src/components/datagrid/body/data_grid_cell.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; +import { mount, render, ReactWrapper } from 'enzyme'; import { keys } from '../../../services'; import { mockRowHeightUtils } from '../utils/__mocks__/row_heights'; import { mockFocusContext } from '../utils/__mocks__/focus_context'; @@ -16,6 +16,14 @@ import { DataGridFocusContext } from '../utils/focus'; import { EuiDataGridCell } from './data_grid_cell'; describe('EuiDataGridCell', () => { + const mockPopoverContext = { + popoverIsOpen: false, + cellLocation: { rowIndex: 0, colIndex: 0 }, + closeCellPopover: jest.fn(), + openCellPopover: jest.fn(), + setPopoverAnchor: jest.fn(), + setPopoverContent: jest.fn(), + }; const requiredProps = { rowIndex: 0, visibleRowIndex: 0, @@ -29,7 +37,10 @@ describe('EuiDataGridCell', () => { ), - popoverContent: () =>
popover
, + popoverContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + popoverContext: mockPopoverContext, rowHeightUtils: mockRowHeightUtils, }; @@ -51,19 +62,24 @@ describe('EuiDataGridCell', () => { }} /> ); - component.setState({ popoverIsOpen: true }); - - const cellButtons = component.find('EuiDataGridCellButtons'); - expect(component.find('EuiDataGridCellButtons')).toHaveLength(1); + component.setState({ enableInteractions: true }); - // Should handle re-closing the popover correctly + const getCellButtons = () => component.find('EuiDataGridCellButtons'); + expect(getCellButtons()).toHaveLength(1); - (cellButtons.prop('onExpandClick') as Function)(); - expect(component.state('popoverIsOpen')).toEqual(false); + // Should handle opening the popover + (getCellButtons().prop('onExpandClick') as Function)(); + expect(mockPopoverContext.openCellPopover).toHaveBeenCalled(); - component.setState({ popoverIsOpen: true }); - (cellButtons.prop('closePopover') as Function)(); - expect(component.state('popoverIsOpen')).toEqual(false); + // Should handle closing the popover + component.setProps({ + isExpandable: true, + popoverContext: { ...mockPopoverContext, popoverIsOpen: true }, + }); + (getCellButtons().prop('onExpandClick') as Function)(); + expect(mockPopoverContext.closeCellPopover).toHaveBeenCalledTimes(1); + (getCellButtons().prop('closePopover') as Function)(); + expect(mockPopoverContext.closeCellPopover).toHaveBeenCalledTimes(2); }); describe('shouldComponentUpdate', () => { @@ -117,6 +133,19 @@ describe('EuiDataGridCell', () => { it('popoverContent', () => { component.setProps({ popoverContent: () =>
test
}); }); + it('popoverContext.popoverIsOpen', () => { + component.setProps({ + popoverContext: { ...mockPopoverContext, popoverIsOpen: true }, + }); + }); + it('popoverContext.cellLocation', () => { + component.setProps({ + popoverContext: { + ...mockPopoverContext, + cellLocation: { rowIndex: 5, colIndex: 5 }, + }, + }); + }); it('style', () => { component.setProps({ style: {} }); component.setProps({ style: { top: 0 } }); @@ -132,9 +161,6 @@ describe('EuiDataGridCell', () => { it('cellProps', () => { component.setState({ cellProps: {} }); }); - it('popoverIsOpen', () => { - component.setState({ popoverIsOpen: true }); - }); it('isEntered', () => { component.setState({ isEntered: true }); }); @@ -164,6 +190,29 @@ describe('EuiDataGridCell', () => { component.setProps({ columnId: 'newColumnId' }); expect(setState).toHaveBeenCalledWith({ cellProps: {} }); }); + + it("handles the cell popover by forwarding the cell's DOM node and contents to the parent popover context", () => { + const component = mount( +