diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b348a6ca65..1fcfd406d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Added `getDefaultEuiMarkdownProcessingPlugins` method for better control over `EuiMarkdownEditor`'s toolbar UI ([#4383](https://github.com/elastic/eui/pull/4383)) - Changed `EuiOutsideClickDetector` events to be standard event types ([#4434](https://github.com/elastic/eui/pull/4434)) - Added `EuiFieldTextProps` in type definitions for `EuiSuggestInput` ([#4452](https://github.com/elastic/eui/pull/4452)) +- Added virtualized cell rendering to `EuiDataGrid` ([#4170](https://github.com/elastic/eui/pull/4170)) **Bug fixes** diff --git a/package.json b/package.json index 192eb3cdde7..cdd2abee70c 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "@types/react-dom": "^16.9.6", "@types/react-is": "^16.7.1", "@types/react-router-dom": "^5.1.5", - "@types/resize-observer-browser": "^0.1.3", "@types/tabbable": "^3.1.0", "@types/url-parse": "^1.4.3", "@types/uuid": "^8.3.0", diff --git a/scripts/a11y-testing.js b/scripts/a11y-testing.js index 39c9f5378b1..81f5c631533 100644 --- a/scripts/a11y-testing.js +++ b/scripts/a11y-testing.js @@ -40,6 +40,14 @@ const docsPages = async (root, page) => { `${root}#/forms/date-picker`, `${root}#/forms/suggest`, `${root}#/forms/super-date-picker`, + `${root}#/tabular-content/data-grid`, + `${root}#/tabular-content/data-grid-in-memory-settings`, + `${root}#/tabular-content/data-grid-schemas-and-popovers`, + `${root}#/tabular-content/data-grid-focus`, + `${root}#/tabular-content/data-grid-styling-and-control`, + `${root}#/tabular-content/data-grid-control-columns`, + `${root}#/tabular-content/data-grid-footer-row`, + `${root}#/tabular-content/data-grid-virtualization`, `${root}#/elastic-charts/creating-charts`, `${root}#/elastic-charts/part-to-whole-comparisons`, `${root}#/utilities/css-utility-classes`, diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 5905d4889a1..b19e7999f9c 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -75,6 +75,7 @@ import { DataGridFocusExample } from './views/datagrid/datagrid_focus_example'; import { DataGridStylingExample } from './views/datagrid/datagrid_styling_example'; import { DataGridControlColumnsExample } from './views/datagrid/datagrid_controlcolumns_example'; import { DataGridFooterRowExample } from './views/datagrid/datagrid_footer_row_example'; +import { DataGridVirtualizationExample } from './views/datagrid/datagrid_virtualization_example'; import { DatePickerExample } from './views/date_picker/date_picker_example'; @@ -368,6 +369,7 @@ const navigation = [ DataGridStylingExample, DataGridControlColumnsExample, DataGridFooterRowExample, + DataGridVirtualizationExample, 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 c4acd4628c5..f496a176b42 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -6,6 +6,7 @@ import React, { useState, createContext, useContext, + useRef, } from 'react'; import { fake } from 'faker'; @@ -191,7 +192,7 @@ const trailingControlColumns = [ ownFocus={true}> Actions
- -
-
-
- -
-
-
-
-
+ +
-
+
-
-
- -
-
-
+ +
-
+
- - - -
-
-
-
-
-

- - Row: 2, Column: 1: - -

-
- 1, A -
-
-
- -
-
+ Row: 1, Column: 1: +

-
-
-
-
-
-
-

- - Row: 2, Column: 2: - -

-
- 1, B -
-
-
- -
-
+ Row: 1, Column: 2: +

-
-
-
-
-
-
-
-
-

- - Row: 3, Column: 1: - -

-
- 2, A -
-
-
- -
-
+ Row: 2, Column: 1: +

-
-
-
-
-
-
-

- - Row: 3, Column: 2: - -

-
- 2, B -
-
-
- -
-
+ Row: 2, Column: 2: +

+
+
+
+
+ 2, A +

+ Row: 3, Column: 1: +

+
+
+
+
+ 2, B +

+ Row: 3, Column: 2: +

@@ -1521,6 +1282,7 @@ Array [ >
-
- - leading heading - -
-
-
- -
-
-
- -
-
-
- - trailing heading - -
-
-
-
-
-
-
-
-
-
-

- - Row: 1, Column: 1: - -

-
- 0 -
+
+ A +
+
+
+ +
+
+
-
-
-
-
-
-
-
+ +
+ +
+
+
+ + trailing heading +
-
-
-
-
+
+
- - Row: 1, Column: 3: - + Row: 1, Column: 1:

- 0, B + 0
-
- -
+
-
-
-
-
-
-
+ 0, A

- - Row: 1, Column: 4: - + Row: 1, Column: 2:

-
- 0 -
-
-
-
-
-
-
-
-
-
+ 0, B

- - Row: 2, Column: 1: - + Row: 1, Column: 3:

-
- 1 -
-
-
-
-
-
+
+
- - Row: 2, Column: 2: - + Row: 1, Column: 4:

- 1, A + 0
-
- -
+
-
-
-
-
+
+
- - Row: 2, Column: 3: - + Row: 2, Column: 1:

- 1, B + 1
-
- -
+
-
-
-
-
-
-
+ 1, A

- - Row: 2, Column: 4: - + Row: 2, Column: 2:

-
- 1 -
-
-
-
-
-
-
-
-
-
+ 1, B

- - Row: 3, Column: 1: - + Row: 2, Column: 3:

-
- 2 -
-
-
-
-
-
+
+
- - Row: 3, Column: 2: - + Row: 2, Column: 4:

- 2, A + 1
-
- -
+
-
-
-
-
+
+
- - Row: 3, Column: 3: - + Row: 3, Column: 1:

- 2, B + 2
-
- -
+
-
-
-
-
-
-
+ 2, A

- - Row: 3, Column: 4: - + Row: 3, Column: 2: +

+
+
+
+
+ 2, B +

+ Row: 3, Column: 3:

+
+
+
+
+
+
- 2 +
+

+ Row: 3, Column: 4: +

+
+ 2 +
+
+
-
@@ -2407,6 +1917,7 @@ Array [ >
-
-
- -
-
-
- -
-
-
+ +
-
+
-
-
-
-
-
-
+ +
-
+
-
-
-
-
-
-
-
-
-

- - Row: 2, Column: 1: - -

-
- 1, A -
-
-
- -
-
+ Row: 1, Column: 1: +

-
-
-
-
-
-
-

- - Row: 2, Column: 2: - -

-
- 1, B -
-
-
- -
-
+ Row: 1, Column: 2: +

-
-
-
-
-
-
-
-
-

- - Row: 3, Column: 1: - -

-
- 2, A -
-
-
- -
-
+ Row: 2, Column: 1: +

+
+
+
+
+ 1, B +

+ Row: 2, Column: 2: +

+
+
+
+
+ 2, A +

+ Row: 3, Column: 1: +

-
-
-
-
-
-
-

- - Row: 3, Column: 2: - -

-
- 2, B -
-
-
- -
-
+ Row: 3, Column: 2: +

@@ -2980,6 +2251,7 @@ Array [ >
-
-
- -
-
-
- -
-
-
+ +
-
+
-
-
-
-
-
-
+ +
-
+
-
-
-
-
-
-
-
-
-

- - Row: 2, Column: 1: - -

-
- 1, A -
-
-
- -
-
+ Row: 1, Column: 1: +

-
-
-
-
-
-
-

- - Row: 2, Column: 2: - -

-
- 1, B -
-
-
- -
-
+ Row: 1, Column: 2: +

-
-
-
-
-
-
-
-
-

- - Row: 3, Column: 1: - -

-
- 2, A -
-
-
- -
-
+ Row: 2, Column: 1: +

-
-
-
-
-
-
-

- - Row: 3, Column: 2: - -

-
- 2, B -
-
-
- -
-
+ Row: 2, Column: 2: +

+
+
+
+
+ 2, A +

+ Row: 3, Column: 1: +

+
+
+
+
+ 2, B +

+ Row: 3, Column: 2: +

diff --git a/src/components/datagrid/_data_grid.scss b/src/components/datagrid/_data_grid.scss index ece9c84cb8f..604414ab0e2 100644 --- a/src/components/datagrid/_data_grid.scss +++ b/src/components/datagrid/_data_grid.scss @@ -28,12 +28,8 @@ } .euiDataGrid__content { - @include euiScrollBar; - height: 100%; - overflow: auto; font-feature-settings: 'tnum' 1; // Tabular numbers - scroll-padding: 0; max-width: 100%; width: 100%; z-index: 2; // Sits above the pagination below it, but below the controls above it @@ -117,3 +113,8 @@ .euiDataGrid__focusWrap { height: 100%; } + +.euiDataGrid__virtualized { + @include euiScrollBar; + scroll-padding: 0; +} diff --git a/src/components/datagrid/_data_grid_data_row.scss b/src/components/datagrid/_data_grid_data_row.scss index a30c1d904f3..6c3164716c6 100644 --- a/src/components/datagrid/_data_grid_data_row.scss +++ b/src/components/datagrid/_data_grid_data_row.scss @@ -21,11 +21,11 @@ width: 100%; } - &:first-of-type { + &.euiDataGridRowCell--firstColumn { border-left: $euiBorderThin; } - &:last-of-type { + &.euiDataGridRowCell--lastColumn { border-right-color: $euiBorderColor; } @@ -34,30 +34,54 @@ margin-top: -1px; } - &:focus, + // Only add the transition effect on hover, so that it is instantaneous on focus + // Long delays on hover to mitigate the accordion effect &:hover { + .euiDataGridRowCell__expandButtonIcon { + animation-duration: $euiAnimSpeedExtraFast; + animation-name: euiDataGridCellButtonSlideIn; + animation-iteration-count: 1; + animation-delay: $euiAnimSpeedNormal; + animation-fill-mode: forwards; + } + + .euiDataGridRowCell__actionButtonIcon { + animation-duration: $euiAnimSpeedExtraFast; + animation-name: euiDataGridCellButtonSlideIn; + animation-iteration-count: 1; + animation-delay: $euiAnimSpeedNormal; + animation-fill-mode: forwards; + } + } + &:not(:hover), + &.euiDataGridRowCell--open { .euiDataGridRowCell__expandButtonIcon { + animation: none; margin-left: $euiDataGridCellPaddingM; width: $euiSizeM; - background: $euiColorPrimary; // Needed to prevent the bg-color of .euiButtonIcon--text:focus } .euiDataGridRowCell__actionButtonIcon { + animation: none; margin-left: $euiDataGridCellPaddingM; width: $euiSizeM; } } - // Only add the transition effect on hover, so that it is instantaneous on focus - // Long delays on hover to mitigate the accordion effect - &:hover { - .euiDataGridRowCell__expandButtonIcon { - transition: margin $euiAnimSpeedFast 1000ms, width $euiAnimSpeedFast 1000ms; + // on focus, directly show action buttons (without animation), but still slide in the open popover button + &:focus { + .euiDataGridRowCell__actionButtonIcon { + margin-left: $euiDataGridCellPaddingM; + width: $euiSizeM; } + } + // if a cell is not hovered nor focused nor open via popover, don't show buttons in general + &:not(:hover):not(:focus):not(.euiDataGridRowCell--open) { + .euiDataGridRowCell__expandButtonIcon, .euiDataGridRowCell__actionButtonIcon { - transition: margin $euiAnimSpeedFast 1000ms, width $euiAnimSpeedFast 1000ms; + display: none; } } @@ -83,12 +107,14 @@ &:not(.euiDataGridRowCell--controlColumn) { .euiDataGridRowCell__content, .euiDataGridRowCell__truncate, + &.euiDataGridRowCell__truncate, .euiDataGridRowCell__expandContent { @include euiTextTruncate; overflow: hidden; white-space: nowrap; } } + } .euiDataGridRowCell__popover { @@ -116,8 +142,13 @@ .euiDataGridRowCell__expandButton { display: flex; flex-grow: 0; + + .euiDataGridRowCell__expandButtonIcon { + background: $euiColorPrimary; // Needed to prevent the bg-color of .euiButtonIcon--text:focus + } } + .euiDataGridRowCell__expandButtonIcon { height: $euiSizeM; min-height: $euiSizeM; @@ -159,8 +190,8 @@ // Stripes @include euiDataGridStyles(stripes) { - .euiDataGridRow:nth-child(odd) { - @include euiDataGridRowCell { + @include euiDataGridRowCell { + &.euiDataGridRowCell--stripe { background: $euiColorLightestShade; } } @@ -216,3 +247,15 @@ } } } + +@keyframes euiDataGridCellButtonSlideIn { + from { + margin-left: 0; + width: 0; + } + + to { + margin-left: $euiDataGridCellPaddingM; + width: $euiSizeM; + } +} diff --git a/src/components/datagrid/column_selector.tsx b/src/components/datagrid/column_selector.tsx index 4b1f0a2c80e..f67d06197bc 100644 --- a/src/components/datagrid/column_selector.tsx +++ b/src/components/datagrid/column_selector.tsx @@ -21,6 +21,7 @@ import React, { Fragment, useState, useMemo, + useCallback, ReactElement, ChangeEvent, } from 'react'; @@ -82,18 +83,23 @@ export const useDataGridColumnSelector = ( ); const { visibleColumns, setVisibleColumns } = columnVisibility; - const visibleColumnIds = new Set(visibleColumns); + const visibleColumnIds = useMemo(() => new Set(visibleColumns), [ + visibleColumns, + ]); const [isOpen, setIsOpen] = useState(false); - function setColumns(nextColumns: string[]) { - setSortedColumns(nextColumns); + const setColumns = useCallback( + (nextColumns: string[]) => { + setSortedColumns(nextColumns); - const nextVisibleColumns = nextColumns.filter((id) => - visibleColumnIds.has(id) - ); - setVisibleColumns(nextVisibleColumns); - } + const nextVisibleColumns = nextColumns.filter((id) => + visibleColumnIds.has(id) + ); + setVisibleColumns(nextVisibleColumns); + }, + [setSortedColumns, setVisibleColumns, visibleColumnIds] + ); function onDragEnd({ source: { index: sourceIndex }, @@ -296,17 +302,20 @@ export const useDataGridColumnSelector = ( /** * Used for moving columns left/right, available in the headers actions menu */ - const switchColumnPos = (fromColId: string, toColId: string) => { - const moveFromIdx = sortedColumns.indexOf(fromColId); - const moveToIdx = sortedColumns.indexOf(toColId); - if (moveFromIdx === -1 || moveToIdx === -1) { - return; - } - const nextSortedColumns = [...sortedColumns]; - nextSortedColumns.splice(moveFromIdx, 1); - nextSortedColumns.splice(moveToIdx, 0, fromColId); - setColumns(nextSortedColumns); - }; + const switchColumnPos = useCallback( + (fromColId: string, toColId: string) => { + const moveFromIdx = sortedColumns.indexOf(fromColId); + const moveToIdx = sortedColumns.indexOf(toColId); + if (moveFromIdx === -1 || moveToIdx === -1) { + return; + } + const nextSortedColumns = [...sortedColumns]; + nextSortedColumns.splice(moveFromIdx, 1); + nextSortedColumns.splice(moveToIdx, 0, fromColId); + setColumns(nextSortedColumns); + }, + [setColumns, sortedColumns] + ); return [ columnSelector, diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index c75ecd7fd52..601bfce5b8a 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useState } from 'react'; import { mount, ReactWrapper, render } from 'enzyme'; -import { EuiDataGrid } from './'; +import { EuiDataGrid, EuiDataGridProps } from './'; import { findTestSubject, requiredProps, @@ -28,13 +28,12 @@ import { import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; import { keys } from '../../services'; import { act } from 'react-dom/test-utils'; -import cheerio from 'cheerio'; function getFocusableCell(component: ReactWrapper) { return findTestSubject(component, 'dataGridRowCell').find('[tabIndex=0]'); } -function extractGridData(datagrid: ReactWrapper) { +function extractGridData(datagrid: ReactWrapper) { const rows: string[][] = []; const headerCells = findTestSubject(datagrid, 'dataGridHeaderCell', '|='); @@ -46,15 +45,18 @@ function extractGridData(datagrid: ReactWrapper) { ); rows.push(headerRow); - const gridRows = findTestSubject(datagrid, 'dataGridRow'); - gridRows.forEach((row: any) => { + // reduce the virtualized grid of cells into rows + const columnCount = datagrid.prop('columnVisibility').visibleColumns.length; + const gridCells = findTestSubject(datagrid, 'dataGridRowCell'); + const visibleRowsCount = gridCells.length / columnCount; + for (let i = 0; i < visibleRowsCount; i++) { const rowContent: string[] = []; - const cells = findTestSubject(row, 'dataGridRowCell'); - cells.forEach((cell: any) => - rowContent.push(cell.find('[data-test-subj="cell-content"]').text()) - ); + for (let j = i * columnCount; j < (i + 1) * columnCount; j++) { + const cell = gridCells.at(j); + rowContent.push(cell.find('[data-test-subj="cell-content"]').text()); + } rows.push(rowContent); - }); + } return rows; } @@ -378,7 +380,7 @@ function setColumnVisibility( } function moveColumnToIndex( - datagrid: ReactWrapper, + datagrid: ReactWrapper, columnId: string, nextIndex: number ) { @@ -440,6 +442,16 @@ function moveColumnToIndex( describe('EuiDataGrid', () => { describe('rendering', () => { + const getBoundingClientRect = + window.Element.prototype.getBoundingClientRect; + beforeAll(() => { + window.Element.prototype.getBoundingClientRect = () => + ({ width: 100, height: 100 } as DOMRect); + }); + afterAll(() => { + window.Element.prototype.getBoundingClientRect = getBoundingClientRect; + }); + it('renders with common and div attributes', () => { const component = render( { expect(component).toMatchSnapshot(); }); - it('renders with appropriate role structure', () => { - const component = render( - {}, - }} - rowCount={3} - renderCellValue={({ rowIndex, columnId }) => - `${rowIndex}, ${columnId}` - } - /> - ); - - // purposefully not using data-test-subj attrs to test role semantics - const grid = component.find('[role="grid"]'); - const rows = grid.children('[role="row"]'); // technically, this test should also allow role=rowgroup but we don't currently use rowgroups - - expect(rows.length).not.toBe(0); - expect(grid.children().length).toBe(rows.length); - - rows.each((i, element) => { - const $element = cheerio(element); - const allCells = $element.children( - '[role="columnheader"], [role="rowheader"], [role="gridcell"]' - ); - expect($element.children().length).toBe(allCells.length); - }); - }); - it('renders and applies custom props', () => { const component = mount( { return props; }) ).toMatchInlineSnapshot(` -Array [ - Object { - "className": "euiDataGridRowCell customClass", - "data-test-subj": "dataGridRowCell", - "onBlur": [Function], - "onFocus": [Function], - "onKeyDown": [Function], - "role": "gridcell", - "style": Object { - "color": "red", - "width": "100px", - }, - "tabIndex": -1, - }, - Object { - "className": "euiDataGridRowCell customClass", - "data-test-subj": "dataGridRowCell", - "onBlur": [Function], - "onFocus": [Function], - "onKeyDown": [Function], - "role": "gridcell", - "style": Object { - "color": "blue", - "width": "100px", - }, - "tabIndex": -1, - }, - Object { - "className": "euiDataGridRowCell customClass", - "data-test-subj": "dataGridRowCell", - "onBlur": [Function], - "onFocus": [Function], - "onKeyDown": [Function], - "role": "gridcell", - "style": Object { - "color": "red", - "width": "100px", - }, - "tabIndex": -1, - }, - Object { - "className": "euiDataGridRowCell customClass", - "data-test-subj": "dataGridRowCell", - "onBlur": [Function], - "onFocus": [Function], - "onKeyDown": [Function], - "role": "gridcell", - "style": Object { - "color": "blue", - "width": "100px", - }, - "tabIndex": -1, - }, -] -`); + Array [ + Object { + "className": "euiDataGridRowCell euiDataGridRowCell--firstColumn customClass", + "data-test-subj": "dataGridRowCell", + "onBlur": [Function], + "onFocus": [Function], + "onKeyDown": [Function], + "onMouseEnter": [Function], + "role": "gridcell", + "style": Object { + "color": "red", + "height": 34, + "left": 0, + "position": "absolute", + "top": "100px", + "width": 100, + }, + "tabIndex": -1, + }, + Object { + "className": "euiDataGridRowCell euiDataGridRowCell--lastColumn customClass", + "data-test-subj": "dataGridRowCell", + "onBlur": [Function], + "onFocus": [Function], + "onKeyDown": [Function], + "onMouseEnter": [Function], + "role": "gridcell", + "style": Object { + "color": "blue", + "height": 34, + "left": 100, + "position": "absolute", + "top": "100px", + "width": 100, + }, + "tabIndex": -1, + }, + Object { + "className": "euiDataGridRowCell euiDataGridRowCell--stripe euiDataGridRowCell--firstColumn customClass", + "data-test-subj": "dataGridRowCell", + "onBlur": [Function], + "onFocus": [Function], + "onKeyDown": [Function], + "onMouseEnter": [Function], + "role": "gridcell", + "style": Object { + "color": "red", + "height": 34, + "left": 0, + "position": "absolute", + "top": "134px", + "width": 100, + }, + "tabIndex": -1, + }, + Object { + "className": "euiDataGridRowCell euiDataGridRowCell--stripe euiDataGridRowCell--lastColumn customClass", + "data-test-subj": "dataGridRowCell", + "onBlur": [Function], + "onFocus": [Function], + "onKeyDown": [Function], + "onMouseEnter": [Function], + "role": "gridcell", + "style": Object { + "color": "blue", + "height": 34, + "left": 100, + "position": "absolute", + "top": "134px", + "width": 100, + }, + "tabIndex": -1, + }, + ] + `); }); it('renders correct aria attributes on column headers', () => { @@ -753,6 +753,7 @@ Array [ ).toBe(0); // style selector + component.debug(); expect( findTestSubject(component, 'dataGridStyleSelectorButton').length ).toBe(1); @@ -763,7 +764,7 @@ Array [ ).toBe(1); }); - describe('schema schema classnames', () => { + describe('schema classnames', () => { it('applies classnames from explicit schemas', () => { const component = mount( x.props().className); expect(gridCellClassNames).toMatchInlineSnapshot(` -Array [ - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--customFormatName", - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--customFormatName", - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--customFormatName", -] -`); + Array [ + "euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--firstColumn", + "euiDataGridRowCell--lastColumn", + "euiDataGridRowCell euiDataGridRowCell--customFormatName euiDataGridRowCell--lastColumn", + "euiDataGridRowCell--stripe euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--stripe euiDataGridRowCell--firstColumn", + "euiDataGridRowCell--stripe euiDataGridRowCell--lastColumn", + "euiDataGridRowCell euiDataGridRowCell--customFormatName euiDataGridRowCell--stripe euiDataGridRowCell--lastColumn", + "euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--firstColumn", + "euiDataGridRowCell--lastColumn", + "euiDataGridRowCell euiDataGridRowCell--customFormatName euiDataGridRowCell--lastColumn", + ] + `); }); it('automatically detects column types and applies classnames', () => { @@ -825,15 +832,15 @@ Array [ .find('[className~="euiDataGridRowCell"]') .map((x) => x.props().className); expect(gridCellClassNames).toMatchInlineSnapshot(` -Array [ - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--boolean", - "euiDataGridRowCell", - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--boolean", - "euiDataGridRowCell", -] -`); + Array [ + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--boolean", + "euiDataGridRowCell euiDataGridRowCell--lastColumn", + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--stripe euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--boolean euiDataGridRowCell--stripe", + "euiDataGridRowCell euiDataGridRowCell--stripe euiDataGridRowCell--lastColumn", + ] + `); }); it('overrides automatically detected column types with supplied schema', () => { @@ -857,13 +864,13 @@ Array [ .find('[className~="euiDataGridRowCell"]') .map((x) => x.props().className); expect(gridCellClassNames).toMatchInlineSnapshot(` -Array [ - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--alphanumeric", - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--alphanumeric", -] -`); + Array [ + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--alphanumeric euiDataGridRowCell--lastColumn", + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--stripe euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--alphanumeric euiDataGridRowCell--stripe euiDataGridRowCell--lastColumn", + ] + `); }); it('detects all of the supported types', () => { @@ -894,16 +901,15 @@ Array [ .find('[className~="euiDataGridRowCell"]') .map((x) => x.props().className); expect(gridCellClassNames).toMatchInlineSnapshot(` -Array [ - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--boolean", - "euiDataGridRowCell euiDataGridRowCell--currency", - "euiDataGridRowCell euiDataGridRowCell--datetime", - "euiDataGridRowCell euiDataGridRowCell--datetime", - "euiDataGridRowCell euiDataGridRowCell--datetime", - "euiDataGridRowCell euiDataGridRowCell--datetime", -] -`); + Array [ + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--boolean", + "euiDataGridRowCell euiDataGridRowCell--currency", + "euiDataGridRowCell euiDataGridRowCell--datetime", + "euiDataGridRowCell euiDataGridRowCell--datetime", + "euiDataGridRowCell euiDataGridRowCell--datetime", + ] + `); }); it('accepts extra detectors', () => { @@ -943,11 +949,11 @@ Array [ .find('[className~="euiDataGridRowCell"]') .map((x) => x.props().className); expect(gridCellClassNames).toMatchInlineSnapshot(` -Array [ - "euiDataGridRowCell euiDataGridRowCell--numeric", - "euiDataGridRowCell euiDataGridRowCell--ipaddress", -] -`); + Array [ + "euiDataGridRowCell euiDataGridRowCell--numeric euiDataGridRowCell--firstColumn", + "euiDataGridRowCell euiDataGridRowCell--ipaddress euiDataGridRowCell--lastColumn", + ] + `); }); }); }); @@ -970,21 +976,21 @@ Array [ /> ); expect(extractGridData(component)).toMatchInlineSnapshot(` -Array [ - Array [ - "Column 1", - "Column 2", - ], - Array [ - "Hello, Row 0-Column 1!", - "Hello, Row 0-Column 2!", - ], - Array [ - "Hello, Row 1-Column 1!", - "Hello, Row 1-Column 2!", - ], -] -`); + Array [ + Array [ + "Column 1", + "Column 2", + ], + Array [ + "Hello, Row 0-Column 1!", + "Hello, Row 0-Column 2!", + ], + Array [ + "Hello, Row 1-Column 1!", + "Hello, Row 1-Column 2!", + ], + ] + `); }); }); @@ -1994,7 +2000,7 @@ Array [ }); describe('render column cell actions', () => { - it('renders various column cell actions configurations', () => { + it('renders various column cell actions configurations after cell gets hovered', async () => { const alertFn = jest.fn(); const happyFn = jest.fn(); const component = mount( @@ -2049,9 +2055,19 @@ Array [ /> ); - findTestSubject(component, 'alertAction').at(1).simulate('click'); + // cell buttons should not get rendered for unfocused, unhovered cell + expect(findTestSubject(component, 'alertAction').exists()).toBe(false); + expect(findTestSubject(component, 'happyAction').exists()).toBe(false); + + findTestSubject(component, 'dataGridRowCell').at(1).prop('onMouseEnter')!( + {} as React.MouseEvent + ); + + component.update(); + + findTestSubject(component, 'alertAction').at(0).simulate('click'); expect(alertFn).toHaveBeenCalledWith(1, 'A'); - findTestSubject(component, 'happyAction').at(1).simulate('click'); + findTestSubject(component, 'happyAction').at(0).simulate('click'); expect(happyFn).toHaveBeenCalledWith(1, 'A'); alertFn.mockReset(); happyFn.mockReset(); @@ -2069,7 +2085,7 @@ Array [ }); describe('keyboard controls', () => { - it('supports simple arrow navigation', () => { + it('supports simple arrow navigation', async () => { let pagination = { pageIndex: 0, pageSize: 3, @@ -2163,9 +2179,9 @@ Array [ ).toEqual('0, A'); // move down and to the end of the row - focusableCell - .simulate('keydown', { key: keys.ARROW_DOWN }) - .simulate('keydown', { key: keys.END }); + focusableCell.simulate('keydown', { key: keys.ARROW_DOWN }); + focusableCell = getFocusableCell(component); + focusableCell.simulate('keydown', { key: keys.END }); focusableCell = getFocusableCell(component); expect( focusableCell.find('[data-test-subj="cell-content"]').text() diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index 11f533b916b..89261e070e3 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -28,13 +28,12 @@ import React, { ReactChild, useMemo, useRef, - Dispatch, - SetStateAction, + MutableRefObject, + CSSProperties, } from 'react'; import classNames from 'classnames'; import tabbable from 'tabbable'; import { EuiI18n } from '../i18n'; -import { EuiDataGridHeaderRow } from './data_grid_header_row'; import { CommonProps, OneOf } from '../common'; import { EuiDataGridColumn, @@ -65,10 +64,7 @@ import { useDataGridColumnSelector } from './column_selector'; import { useDataGridStyleSelector, startingStyles } from './style_selector'; import { EuiTablePagination } from '../table/table_pagination'; import { EuiFocusTrap } from '../focus_trap'; -import { - EuiResizeObserver, - useResizeObserver, -} from '../observer/resize_observer'; +import { useResizeObserver } from '../observer/resize_observer'; import { EuiDataGridInMemoryRenderer } from './data_grid_inmemory_renderer'; import { useMergedSchema, @@ -76,9 +72,12 @@ import { useDetectSchema, schemaDetectors as providedSchemaDetectors, } from './data_grid_schema'; +import { + DataGridFocusContext, + DataGridFocusContextShape, + DataGridSortingContext, +} from './data_grid_context'; import { useDataGridColumnSorting } from './column_sorting'; -import { EuiMutationObserver } from '../observer/mutation_observer'; -import { DataGridContext } from './data_grid_context'; // Used to short-circuit some async browser behaviour that is difficult to account for in tests const IS_JEST_ENVIRONMENT = global.hasOwnProperty('_isJest'); @@ -156,6 +155,14 @@ type CommonGridProps = CommonProps & * Defines a minimum width for the grid to show all controls in its header. */ minSizeForControls?: number; + /** + * Sets the grid's height, forcing it to overflow in a scrollable container with cell virtualization + */ + height?: CSSProperties['height']; + /** + * Sets the grid's width, forcing it to overflow in a scrollable container with cell virtualization + */ + width?: CSSProperties['width']; }; // Force either aria-label or aria-labelledby to be defined @@ -291,14 +298,11 @@ function renderPagination(props: EuiDataGridProps, controls: string) { } function useDefaultColumnWidth( - container: HTMLElement | null, + gridWidth: number, leadingControlColumns: EuiDataGridControlColumn[], trailingControlColumns: EuiDataGridControlColumn[], columns: EuiDataGridProps['columns'] ): number | null { - const containerSize = useResizeObserver(container, 'width'); - const gridWidth = containerSize.width; - const computeDefaultWidth = useCallback((): number | null => { if (IS_JEST_ENVIRONMENT) return 100; if (gridWidth === 0) return null; // we can't tell what size to compute yet @@ -379,28 +383,18 @@ function useColumnWidths( setColumnWidths(computeColumnWidths()); }, [computeColumnWidths]); - const setColumnWidth = (columnId: string, width: number) => { - setColumnWidths({ ...columnWidths, [columnId]: width }); + const setColumnWidth = useCallback( + (columnId: string, width: number) => { + setColumnWidths({ ...columnWidths, [columnId]: width }); - if (onColumnResize) { - onColumnResize({ columnId, width }); - } - }; - - return [columnWidths, setColumnWidth]; -} - -function useOnResize( - setHasRoomForGridControls: (hasRoomForGridControls: boolean) => void, - isFullScreen: boolean, - minSizeForControls: number -) { - return useCallback( - ({ width }: { width: number }) => { - setHasRoomForGridControls(width > minSizeForControls || isFullScreen); + if (onColumnResize) { + onColumnResize({ columnId, width }); + } }, - [setHasRoomForGridControls, isFullScreen, minSizeForControls] + [columnWidths, onColumnResize] ); + + return [columnWidths, setColumnWidth]; } function useInMemoryValues( @@ -460,8 +454,7 @@ function createKeyDownHandler( trailingControlColumns: EuiDataGridControlColumn[], focusedCell: EuiDataGridFocusedCell | undefined, headerIsInteractive: boolean, - setFocusedCell: (focusedCell: EuiDataGridFocusedCell) => void, - updateFocus: Function + setFocusedCell: (focusedCell: EuiDataGridFocusedCell) => void ) { return (event: KeyboardEvent) => { if (focusedCell == null) return; @@ -507,7 +500,6 @@ function createKeyDownHandler( props.pagination.onChangePage(pageIndex + 1); } setFocusedCell([focusedCell[0], 0]); - updateFocus(); } } else if (key === keys.PAGE_UP) { if (props.pagination) { @@ -517,7 +509,6 @@ function createKeyDownHandler( props.pagination.onChangePage(pageIndex - 1); } setFocusedCell([focusedCell[0], props.pagination.pageSize - 1]); - updateFocus(); } } else if (key === (ctrlKey && keys.END)) { event.preventDefault(); @@ -535,44 +526,35 @@ function createKeyDownHandler( }; } -function useAfterRender(fn: Function): Function { - const [isSubscribed, setIsSubscribed] = useState(false); - const [needsExecution, setNeedsExecution] = useState(false); - - // first useEffect waits for the parent & children to render & flush to dom - useEffect(() => { - if (isSubscribed) { - setIsSubscribed(false); - setNeedsExecution(true); - } - }, [isSubscribed, setIsSubscribed, setNeedsExecution]); - - // second useEffect allows for a new `fn` to have been created - // with any state updates before being called - useEffect(() => { - if (needsExecution) { - setNeedsExecution(false); - fn(); - } - }, [needsExecution, setNeedsExecution, fn]); - - return () => { - setIsSubscribed(true); - }; -} - type FocusProps = Pick, 'tabIndex' | 'onFocus'>; const useFocus = ( - headerIsInteractive: boolean + headerIsInteractive: boolean, + cellsUpdateFocus: MutableRefObject> ): [ FocusProps, EuiDataGridFocusedCell | undefined, - Dispatch> + (focusedCell: EuiDataGridFocusedCell) => void ] => { const [focusedCell, setFocusedCell] = useState< EuiDataGridFocusedCell | undefined >(undefined); + const previousCell = useRef(undefined); + useEffect(() => { + if (previousCell.current) { + notifyCellOfFocusState( + cellsUpdateFocus.current, + previousCell.current, + false + ); + } + previousCell.current = focusedCell; + + if (focusedCell) { + notifyCellOfFocusState(cellsUpdateFocus.current, focusedCell, true); + } + }, [cellsUpdateFocus, focusedCell]); + const hasHadFocus = useMemo(() => focusedCell != null, [focusedCell]); const focusProps = useMemo( @@ -625,22 +607,65 @@ function checkOrDefaultToolBarDiplayOptions< } } +function notifyCellOfFocusState( + cellsUpdateFocus: Map, + cell: EuiDataGridFocusedCell, + isFocused: boolean +) { + const key = `${cell[0]}-${cell[1]}`; + const onFocus = cellsUpdateFocus.get(key); + if (onFocus) { + onFocus(isFocused); + } +} + const emptyArrayDefault: EuiDataGridControlColumn[] = []; export const EuiDataGrid: FunctionComponent = (props) => { + const { + leadingControlColumns = emptyArrayDefault, + trailingControlColumns = emptyArrayDefault, + columns, + columnVisibility, + schemaDetectors, + rowCount, + renderCellValue, + renderFooterCellValue, + className, + gridStyle, + toolbarVisibility = true, + pagination, + sorting, + inMemory, + popoverContents, + onColumnResize, + minSizeForControls = MINIMUM_WIDTH_FOR_GRID_CONTROLS, + height, + width, + ...rest + } = props; + const [isFullScreen, setIsFullScreen] = useState(false); - const [hasRoomForGridControls, setHasRoomForGridControls] = useState(true); - const [containerRef, _setContainerRef] = useState( - null - ); + const [gridWidth, setGridWidth] = useState(0); + const [interactiveCellId] = useState(htmlIdGenerator()()); const [headerIsInteractive, setHeaderIsInteractive] = useState(false); - const setContainerRef = useCallback((ref) => _setContainerRef(ref), []); + const cellsUpdateFocus = useRef>(new Map()); const [wrappingDivFocusProps, focusedCell, setFocusedCell] = useFocus( - headerIsInteractive + headerIsInteractive, + cellsUpdateFocus ); + // maintain a statically-referenced copy of `focusedCell` + // so it can be looked up when needed without causing a re-render + const focusedCellReference = useRef< + EuiDataGridFocusedCell | null | undefined + >(focusedCell); + useEffect(() => { + focusedCellReference.current = focusedCell; + }, [focusedCell]); + const handleHeaderChange = useCallback<(headerRow: HTMLElement) => void>( (headerRow) => { const tabbables = tabbable(headerRow); @@ -651,13 +676,15 @@ export const EuiDataGrid: FunctionComponent = (props) => { // if the focus is on the header, and the header is no longer interactive // move the focus down to the first row + const focusedCell = focusedCellReference.current; if (hasInteractives === false && focusedCell && focusedCell[1] === -1) { setFocusedCell([focusedCell[0], 0]); } } }, - [headerIsInteractive, setHeaderIsInteractive, focusedCell, setFocusedCell] + [headerIsInteractive, setHeaderIsInteractive, setFocusedCell] ); + const handleHeaderMutation = useCallback( (records) => { const [{ target }] = records; @@ -689,37 +716,21 @@ export const EuiDataGrid: FunctionComponent = (props) => { } }; - const { - leadingControlColumns = emptyArrayDefault, - trailingControlColumns = emptyArrayDefault, - columns, - columnVisibility, - schemaDetectors, - rowCount, - renderCellValue, - renderFooterCellValue, - className, - gridStyle, - toolbarVisibility = true, - pagination, - sorting, - inMemory, - popoverContents, - onColumnResize, - minSizeForControls = MINIMUM_WIDTH_FOR_GRID_CONTROLS, - ...rest - } = props; - // enables/disables grid controls based on available width - const onResize = useOnResize( - (nextHasRoomForGridControls) => { - if (nextHasRoomForGridControls !== hasRoomForGridControls) { - setHasRoomForGridControls(nextHasRoomForGridControls); - } - }, - isFullScreen, - minSizeForControls - ); + const [resizeRef, setResizeRef] = useState(null); + const gridDimensions = useResizeObserver(resizeRef, 'width'); + useEffect(() => { + if (resizeRef) { + const { width } = gridDimensions; + setGridWidth(width); + } else { + setGridWidth(0); + } + }, [resizeRef, gridDimensions]); + + const hasRoomForGridControls = IS_JEST_ENVIRONMENT + ? true + : gridWidth > minSizeForControls || isFullScreen; const [columnWidths, setColumnWidth] = useColumnWidths( columns, @@ -788,7 +799,7 @@ export const EuiDataGrid: FunctionComponent = (props) => { // compute the default column width from the container's clientWidth and count of visible columns const defaultColumnWidth = useDefaultColumnWidth( - containerRef, + gridDimensions.width, leadingControlColumns, trailingControlColumns, orderedVisibleColumns @@ -907,48 +918,27 @@ export const EuiDataGrid: FunctionComponent = (props) => { ); - const cellsUpdateFocus = useRef>(new Map()); - - const focusAfterRender = useAfterRender(() => { - if (focusedCell) { - const key = `${focusedCell[0]}-${focusedCell[1]}`; - - if (cellsUpdateFocus.current.has(key)) { - cellsUpdateFocus.current.get(key)!(); - } - } - }); - - const datagridContext = useMemo( - () => ({ - onFocusUpdate: (cell: EuiDataGridFocusedCell, updateFocus: Function) => { - const key = `${cell[0]}-${cell[1]}`; - cellsUpdateFocus.current.set(key, updateFocus); - - return () => { - cellsUpdateFocus.current.delete(key); - }; - }, - }), + const onFocusUpdate = useCallback( + (cell: EuiDataGridFocusedCell, updateFocus: Function) => { + const key = `${cell[0]}-${cell[1]}`; + cellsUpdateFocus.current.set(key, updateFocus); + return () => { + cellsUpdateFocus.current.delete(key); + }; + }, [] ); + const datagridFocusContext = useMemo(() => { + return { + setFocusedCell, + onFocusUpdate, + }; + }, [setFocusedCell, onFocusUpdate]); const gridIds = htmlIdGenerator(); const gridId = gridIds(); const ariaLabelledById = gridIds(); - const commonGridProps = { - columns: orderedVisibleColumns, - columnWidths, - defaultColumnWidth, - focusedCell, - leadingControlColumns, - onCellFocus: setFocusedCell, - schema: mergedSchema, - sorting, - trailingControlColumns, - }; - return ( = (props) => { } return ( - - -
- {(IS_JEST_ENVIRONMENT || defaultColumnWidth) && ( - <> - {showToolbar ? ( -
- {hasRoomForGridControls ? gridControls : null} - {checkOrDefaultToolBarDiplayOptions( - toolbarVisibility, - 'showFullScreenSelector' - ) - ? fullScreenSelector - : null} -
- ) : null} - - {(resizeRef) => ( + + + +
+ {(IS_JEST_ENVIRONMENT || defaultColumnWidth) && ( + <> + {showToolbar ? (
-
- {inMemory ? ( - - ) : null} -
- - {(ref) => ( - - )} - - -
+ className="euiDataGrid__controls" + data-test-sub="dataGridControls"> + {hasRoomForGridControls ? gridControls : null} + {checkOrDefaultToolBarDiplayOptions( + toolbarVisibility, + 'showFullScreenSelector' + ) + ? fullScreenSelector + : null} +
+ ) : null} +
+
+ {inMemory ? ( + + ) : null} +
+
+
+ {props.pagination && props['aria-labelledby'] && ( + )} - - {props.pagination && props['aria-labelledby'] && ( - - )} - {renderPagination(props, gridId)} - - - )} -
- - + + )} +
+
+
+
); }} diff --git a/src/components/datagrid/data_grid_body.tsx b/src/components/datagrid/data_grid_body.tsx index 36321d11e36..1d1da6da5b5 100644 --- a/src/components/datagrid/data_grid_body.tsx +++ b/src/components/datagrid/data_grid_body.tsx @@ -17,7 +17,23 @@ * under the License. */ -import React, { Fragment, FunctionComponent, useMemo } from 'react'; +import React, { + forwardRef, + FunctionComponent, + useEffect, + useMemo, + useRef, + useCallback, + useContext, + useState, +} from 'react'; +import classNames from 'classnames'; +import tabbable from 'tabbable'; +import { + GridChildComponentProps, + VariableSizeGrid as Grid, + VariableSizeGridProps, +} from 'react-window'; import { EuiCodeBlock } from '../code'; import { EuiDataGridControlColumn, @@ -27,21 +43,31 @@ import { EuiDataGridInMemory, EuiDataGridInMemoryValues, EuiDataGridPaginationProps, - EuiDataGridSorting, - EuiDataGridFocusedCell, + EuiDataGridPopoverContent, } from './data_grid_types'; -import { EuiDataGridCellProps } from './data_grid_cell'; -import { - EuiDataGridDataRow, - EuiDataGridDataRowProps, -} from './data_grid_data_row'; +import { EuiDataGridCell, EuiDataGridCellProps } from './data_grid_cell'; import { EuiDataGridSchema, EuiDataGridSchemaDetector, } from './data_grid_schema'; import { EuiDataGridFooterRow } from './data_grid_footer_row'; +import { + EuiDataGridHeaderRow, + EuiDataGridHeaderRowProps, +} from './data_grid_header_row'; +import { + EuiMutationObserver, + useMutationObserver, +} from '../observer/mutation_observer'; +import { EuiText } from '../text'; +import { + DataGridSortingContext, + DataGridWrapperRowsContext, +} from './data_grid_context'; +import { useResizeObserver } from '../observer/resize_observer'; export interface EuiDataGridBodyProps { + isFullScreen: boolean; columnWidths: EuiDataGridColumnWidths; defaultColumnWidth?: number | null; leadingControlColumns?: EuiDataGridControlColumn[]; @@ -50,8 +76,6 @@ export interface EuiDataGridBodyProps { schema: EuiDataGridSchema; schemaDetectors: EuiDataGridSchemaDetector[]; popoverContents?: EuiDataGridPopoverContents; - focusedCell?: EuiDataGridFocusedCell; - onCellFocus: EuiDataGridDataRowProps['onCellFocus']; rowCount: number; renderCellValue: EuiDataGridCellProps['renderCellValue']; renderFooterCellValue?: EuiDataGridCellProps['renderCellValue']; @@ -59,7 +83,11 @@ export interface EuiDataGridBodyProps { inMemoryValues: EuiDataGridInMemoryValues; interactiveCellId: EuiDataGridCellProps['interactiveCellId']; pagination?: EuiDataGridPaginationProps; - sorting?: EuiDataGridSorting; + setColumnWidth: (columnId: string, width: number) => void; + headerIsInteractive: boolean; + handleHeaderMutation: MutationCallback; + setVisibleColumns: EuiDataGridHeaderRowProps['setVisibleColumns']; + switchColumnPos: EuiDataGridHeaderRowProps['switchColumnPos']; } const defaultComparator: NonNullable< @@ -91,10 +119,184 @@ const providedPopoverContents: EuiDataGridPopoverContents = { }, }; +const DefaultColumnFormatter: EuiDataGridPopoverContent = ({ children }) => { + return {children}; +}; + +const Cell: FunctionComponent = ({ + columnIndex, + rowIndex: visibleRowIndex, + style, + data, +}) => { + const { + rowMap, + rowOffset, + leadingControlColumns, + trailingControlColumns, + columns, + schema, + popoverContents, + columnWidths, + defaultColumnWidth, + renderCellValue, + interactiveCellId, + setRowHeight, + } = data; + + const { headerRowHeight } = useContext(DataGridWrapperRowsContext); + + const offsetRowIndex = visibleRowIndex + rowOffset; + const rowIndex = rowMap.hasOwnProperty(offsetRowIndex) + ? rowMap[offsetRowIndex] + : offsetRowIndex; + + let cellContent; + + const isFirstColumn = columnIndex === 0; + const isLastColumn = + columnIndex === + columns.length + + leadingControlColumns.length + + trailingControlColumns.length - + 1; + const isStripableRow = rowIndex % 2 !== 0; + + const isLeadingControlColumn = columnIndex < leadingControlColumns.length; + const isTrailingControlColumn = + columnIndex >= leadingControlColumns.length + columns.length; + + const classes = classNames({ + 'euiDataGridRowCell--stripe': isStripableRow, + 'euiDataGridRowCell--firstColumn': isFirstColumn, + 'euiDataGridRowCell--lastColumn': isLastColumn, + 'euiDataGridRowCell--controlColumn': + isLeadingControlColumn || isTrailingControlColumn, + }); + + if (isLeadingControlColumn) { + const leadingColumn = leadingControlColumns[columnIndex]; + const { id, rowCellRender } = leadingColumn; + + cellContent = ( + + ); + } else if (isTrailingControlColumn) { + const columnOffset = columns.length + leadingControlColumns.length; + const trailingColumnIndex = columnIndex - columnOffset; + const trailingColumn = trailingControlColumns[trailingColumnIndex]; + const { id, rowCellRender } = trailingColumn; + + cellContent = ( + + ); + } else { + // this is a normal data cell + + // offset the column index by the leading control columns + const dataColumnIndex = columnIndex - leadingControlColumns.length; + const column = columns[dataColumnIndex]; + const columnId = column.id; + const columnType = schema[columnId] ? schema[columnId].columnType : null; + + const isExpandable = + column.isExpandable !== undefined ? column.isExpandable : true; + + const popoverContent = + popoverContents[columnType as string] || DefaultColumnFormatter; + + const width = columnWidths[columnId] || defaultColumnWidth; + + cellContent = ( + + ); + } + + return cellContent; +}; + +const InnerElement: VariableSizeGridProps['innerElementType'] = forwardRef< + HTMLDivElement, + { style: { height: number } } +>(({ children, style, ...rest }, ref) => { + const { headerRowHeight, headerRow, footerRow } = useContext( + DataGridWrapperRowsContext + ); + return ( + <> +
+ {headerRow} + {children} +
+ {footerRow} + + ); +}); +InnerElement.displayName = 'EuiDataGridInnerElement'; + +const INITIAL_ROW_HEIGHT = 34; +const SCROLLBAR_HEIGHT = 15; +const IS_JEST_ENVIRONMENT = global.hasOwnProperty('_isJest'); + export const EuiDataGridBody: FunctionComponent = ( props ) => { const { + isFullScreen, columnWidths, defaultColumnWidth, leadingControlColumns = [], @@ -103,8 +305,6 @@ export const EuiDataGridBody: FunctionComponent = ( schema, schemaDetectors, popoverContents, - focusedCell, - onCellFocus, rowCount, renderCellValue, renderFooterCellValue, @@ -112,9 +312,23 @@ export const EuiDataGridBody: FunctionComponent = ( inMemoryValues, interactiveCellId, pagination, - sorting, + setColumnWidth, + headerIsInteractive, + handleHeaderMutation, + setVisibleColumns, + switchColumnPos, } = props; + const [headerRowRef, setHeaderRowRef] = useState(null); + const [footerRowRef, setFooterRowRef] = useState(null); + + useMutationObserver(headerRowRef, handleHeaderMutation, { + subtree: true, + childList: true, + }); + const { height: headerRowHeight } = useResizeObserver(headerRowRef, 'height'); + const { height: footerRowHeight } = useResizeObserver(footerRowRef, 'height'); + const startRow = pagination ? pagination.pageIndex * pagination.pageSize : 0; let endRow = pagination ? (pagination.pageIndex + 1) * pagination.pageSize @@ -129,6 +343,8 @@ export const EuiDataGridBody: FunctionComponent = ( return visibleRowIndices; }, [startRow, endRow]); + const sorting = useContext(DataGridSortingContext); + const rowMap = useMemo(() => { const rowMap: { [key: number]: number } = {}; @@ -182,7 +398,7 @@ export const EuiDataGridBody: FunctionComponent = ( } return rowMap; - }, [sorting, inMemory, inMemoryValues, schema, schemaDetectors]); + }, [sorting, inMemoryValues, schema, schemaDetectors, inMemory?.level]); const mergedPopoverContents = useMemo( () => ({ @@ -192,73 +408,216 @@ export const EuiDataGridBody: FunctionComponent = ( [popoverContents] ); - const rows = useMemo(() => { - const rowsToRender = visibleRowIndices.map((rowIndex, i) => { - rowIndex = rowMap.hasOwnProperty(rowIndex) ? rowMap[rowIndex] : rowIndex; - return ( - - ); - }); - - if (renderFooterCellValue) { - rowsToRender.push( - - ); - } - - return rowsToRender; + const headerRow = useMemo(() => { + return ( + + ); }, [ - visibleRowIndices, - rowMap, + switchColumnPos, + setVisibleColumns, leadingControlColumns, trailingControlColumns, columns, + columnWidths, + defaultColumnWidth, + setColumnWidth, schema, - mergedPopoverContents, + schemaDetectors, + headerIsInteractive, + ]); + + const footerRow = useMemo(() => { + if (renderFooterCellValue == null) return null; + return ( + + ); + }, [ columnWidths, + columns, defaultColumnWidth, - focusedCell, - onCellFocus, - renderCellValue, - renderFooterCellValue, interactiveCellId, + leadingControlColumns, + mergedPopoverContents, + renderFooterCellValue, + schema, + trailingControlColumns, + visibleRowIndices.length, ]); - return {rows}; + const gridRef = useRef(null); + useEffect(() => { + if (gridRef.current) { + gridRef.current.resetAfterColumnIndex(0); + } + }, [columns, columnWidths, defaultColumnWidth]); + + const getWidth = useCallback( + (index: number) => { + if (index < leadingControlColumns.length) { + // this is a leading control column + return leadingControlColumns[index].width; + } else if (index >= leadingControlColumns.length + columns.length) { + // this is a trailing control column + return trailingControlColumns[ + index - leadingControlColumns.length - columns.length + ].width; + } + // normal data column + return ( + columnWidths[columns[index - leadingControlColumns.length].id] || + defaultColumnWidth || + 100 + ); + }, + [ + leadingControlColumns, + columns, + columnWidths, + defaultColumnWidth, + trailingControlColumns, + ] + ); + + const [rowHeight, setRowHeight] = useState(INITIAL_ROW_HEIGHT); + const getRowHeight = useCallback(() => rowHeight, [rowHeight]); + useEffect(() => { + if (gridRef.current) gridRef.current.resetAfterRowIndex(0); + }, [getRowHeight]); + + const unconstrainedHeight = + rowHeight * visibleRowIndices.length + + SCROLLBAR_HEIGHT + + headerRowHeight + + footerRowHeight; + + // unable to determine this until the container's size is known anyway + const unconstrainedWidth = 0; + + const [height, setHeight] = useState(undefined); + const [width, setWidth] = useState(undefined); + + // reset height constraint when rowCount changes + useEffect(() => { + setHeight(undefined); + }, [rowCount]); + + const wrapperRef = useRef(null); + const wrapperDimensions = useResizeObserver(wrapperRef.current); + + useEffect(() => { + const boundingRect = wrapperRef.current!.getBoundingClientRect(); + + if (boundingRect.height !== unconstrainedHeight) { + setHeight(boundingRect.height); + } + if (boundingRect.width !== unconstrainedWidth) { + setWidth(boundingRect.width); + } + }, [unconstrainedHeight, wrapperDimensions]); + + const preventTabbing = useCallback(() => { + if (wrapperRef.current) { + const tabbables = tabbable(wrapperRef.current); + for (let i = 0; i < tabbables.length; i++) { + const element = tabbables[i]; + if ( + element.getAttribute('role') !== 'gridcell' && + !element.dataset['euigrid-tab-managed'] + ) { + element.setAttribute('tabIndex', '-1'); + element.setAttribute('data-datagrid-interactable', 'true'); + } + } + } + }, [wrapperRef]); + + let finalHeight = IS_JEST_ENVIRONMENT ? 500 : height || unconstrainedHeight; + let finalWidth = IS_JEST_ENVIRONMENT ? 500 : width || unconstrainedWidth; + if (isFullScreen) { + finalHeight = window.innerHeight; + finalWidth = window.innerWidth; + } + + return ( + + {(mutationRef) => ( +
{ + wrapperRef.current = el; + mutationRef(el); + }}> + {(IS_JEST_ENVIRONMENT || finalWidth > 0) && ( + + 0 + ? visibleRowIndices.length + : 0 + }> + {Cell} + + + )} +
+ )} +
+ ); }; diff --git a/src/components/datagrid/data_grid_cell.tsx b/src/components/datagrid/data_grid_cell.tsx index aaf3a41a50f..6376d60784e 100644 --- a/src/components/datagrid/data_grid_cell.tsx +++ b/src/components/datagrid/data_grid_cell.tsx @@ -39,8 +39,7 @@ import { EuiDataGridColumn, EuiDataGridPopoverContent, } from './data_grid_types'; -import { EuiMutationObserver } from '../observer/mutation_observer'; -import { DataGridContext } from './data_grid_context'; +import { DataGridFocusContext } from './data_grid_context'; import { EuiFocusTrap } from '../focus_trap'; import { keys } from '../../services'; import { EuiDataGridCellButtons } from './data_grid_cell_buttons'; @@ -84,8 +83,6 @@ export interface EuiDataGridCellProps { columnId: string; columnType?: string | null; width?: number; - isFocused: boolean; - onCellFocus: Function; interactiveCellId: string; isExpandable: boolean; className?: string; @@ -93,18 +90,23 @@ export interface EuiDataGridCellProps { renderCellValue: | JSXElementConstructor | ((props: EuiDataGridCellValueElementProps) => ReactNode); + setRowHeight?: (height: number) => void; + style?: React.CSSProperties; } interface EuiDataGridCellState { cellProps: CommonProps & HTMLAttributes; popoverIsOpen: boolean; // is expansion popover open + renderPopoverOpen: boolean; // wait one render cycle to actually render the popover as open + isFocused: boolean; // tracks if this cell has focus or not, used to enable tabIndex on the cell isEntered: boolean; // enables focus trap for non-expandable cells with multiple interactive elements + enableInteractions: boolean; // cell got hovered at least once, so cell button and popover interactions are rendered disableCellTabIndex: boolean; // disables tabIndex on the wrapping cell, used for focus management of a single interactive child } export type EuiDataGridCellValueProps = Omit< EuiDataGridCellProps, - 'width' | 'isFocused' | 'interactiveCellId' | 'onCellFocus' | 'popoverContent' + 'width' | 'interactiveCellId' | 'popoverContent' >; const EuiDataGridCellContent: FunctionComponent< @@ -125,22 +127,56 @@ const EuiDataGridCellContent: FunctionComponent< ); }); +// TODO: TypeScript has added types for ResizeObserver but not yet released +// https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/948 +// for now, marking ResizeObserver usage as `any` +// and when EUI upgrades to a version of TS with ResizeObserver +// the anys can be removed +const hasResizeObserver = + typeof window !== 'undefined' && + typeof (window as any).ResizeObserver !== 'undefined'; + export class EuiDataGridCell extends Component< EuiDataGridCellProps, EuiDataGridCellState > { - cellRef = createRef(); + cellRef = createRef() as MutableRefObject; + observer!: any; // ResizeObserver popoverPanelRef: MutableRefObject = createRef(); cellContentsRef: HTMLDivElement | null = null; state: EuiDataGridCellState = { cellProps: {}, popoverIsOpen: false, + renderPopoverOpen: false, + isFocused: false, isEntered: false, + enableInteractions: false, disableCellTabIndex: false, }; unsubscribeCell?: Function = () => {}; + style = null; + + setCellRef = (ref: HTMLDivElement | null) => { + this.cellRef.current = ref; + + // watch the first cell for size changes and use that to re-compute row heights + if (this.props.colIndex === 0 && this.props.visibleRowIndex === 0) { + if (ref && hasResizeObserver) { + this.observer = new (window as any).ResizeObserver(() => { + const rowHeight = this.cellRef.current!.getBoundingClientRect() + .height; + if (this.props.setRowHeight) { + this.props.setRowHeight(rowHeight); + } + }); + this.observer.observe(ref); + } else if (this.observer) { + this.observer.disconnect(); + } + } + }; - static contextType = DataGridContext; + static contextType = DataGridFocusContext; getInteractables = () => { const tabbingRef = this.cellContentsRef; @@ -154,11 +190,10 @@ export class EuiDataGridCell extends Component< return []; }; - updateFocus = () => { + takeFocus = () => { const cell = this.cellRef.current; - const { isFocused } = this.props; - if (cell && isFocused) { + if (cell) { // only update focus if we are not already focused on something in this cell let element: Element | null = document.activeElement; while (element != null && element !== cell) { @@ -181,24 +216,24 @@ export class EuiDataGridCell extends Component< componentDidMount() { this.unsubscribeCell = this.context.onFocusUpdate( [this.props.colIndex, this.props.visibleRowIndex], - this.updateFocus + this.onFocusUpdate ); } + onFocusUpdate = (isFocused: boolean) => { + this.setState({ isFocused }, () => { + if (isFocused) { + this.takeFocus(); + } + }); + }; + componentWillUnmount() { if (this.unsubscribeCell) { this.unsubscribeCell(); } } - componentDidUpdate(prevProps: EuiDataGridCellProps) { - const didFocusChange = prevProps.isFocused !== this.props.isFocused; - - if (didFocusChange) { - this.updateFocus(); - } - } - shouldComponentUpdate( nextProps: EuiDataGridCellProps, nextState: EuiDataGridCellState @@ -210,15 +245,27 @@ export class EuiDataGridCell extends Component< if (nextProps.columnType !== this.props.columnType) return true; if (nextProps.width !== this.props.width) return true; if (nextProps.renderCellValue !== this.props.renderCellValue) return true; - if (nextProps.onCellFocus !== this.props.onCellFocus) return true; - if (nextProps.isFocused !== this.props.isFocused) return true; if (nextProps.interactiveCellId !== this.props.interactiveCellId) return true; if (nextProps.popoverContent !== this.props.popoverContent) return true; + // respond to adjusted top/left + if (nextProps.style) { + if (!this.props.style) return true; + if (nextProps.style.top !== this.props.style.top) { + return true; + } + if (nextProps.style.left !== this.props.style.left) return true; + } + if (nextState.cellProps !== this.state.cellProps) return true; if (nextState.popoverIsOpen !== this.state.popoverIsOpen) return true; + if (nextState.renderPopoverOpen !== this.state.renderPopoverOpen) + return true; if (nextState.isEntered !== this.state.isEntered) return true; + if (nextState.isFocused !== this.state.isFocused) return true; + if (nextState.enableInteractions !== this.state.enableInteractions) + return true; if (nextState.disableCellTabIndex !== this.state.disableCellTabIndex) return true; @@ -242,13 +289,8 @@ export class EuiDataGridCell extends Component< // * if the cell children include portalled content React will bubble the focus // event up, which can trigger the focus() call below, causing focus lock fighting if (this.cellRef.current === e.target) { - const { - onCellFocus, - colIndex, - visibleRowIndex, - isExpandable, - } = this.props; - onCellFocus([colIndex, visibleRowIndex]); + const { colIndex, visibleRowIndex, isExpandable } = this.props; + this.context.setFocusedCell([colIndex, visibleRowIndex]); const interactables = this.getInteractables(); if (interactables.length === 1 && isExpandable === false) { @@ -283,25 +325,41 @@ export class EuiDataGridCell extends Component< } }; + closePopover = () => { + // due to a bug, popovers don't close properly wenn unmounted in open state. + // This "double set" makes sure to unmount the component in closed state. + this.setState({ popoverIsOpen: false }, () => + this.setState(() => ({ + renderPopoverOpen: false, + })) + ); + }; + render() { const { width, - isFocused, isExpandable, popoverContent: PopoverContent, interactiveCellId, columnType, - onCellFocus, className, column, + style, ...rest } = this.props; const { colIndex, rowIndex } = rest; + const showCellButtons = + this.state.isFocused || + this.state.isEntered || + this.state.enableInteractions || + this.state.popoverIsOpen; + const cellClasses = classNames( 'euiDataGridRowCell', { [`euiDataGridRowCell--${columnType}`]: columnType, + ['euiDataGridRowCell--open']: this.state.popoverIsOpen, }, className ); @@ -315,12 +373,7 @@ export class EuiDataGridCell extends Component< className: classNames(cellClasses, this.state.cellProps.className), }; - const widthStyle = width != null ? { width: `${width}px` } : {}; - if (cellProps.hasOwnProperty('style')) { - cellProps.style = { ...cellProps.style, ...widthStyle }; - } else { - cellProps.style = widthStyle; - } + cellProps.style = { ...style, width, ...cellProps.style }; const handleCellKeyDown = (event: KeyboardEvent) => { if (isExpandable) { @@ -331,7 +384,14 @@ export class EuiDataGridCell extends Component< case keys.ENTER: case keys.F2: event.preventDefault(); - this.setState({ popoverIsOpen: true }); + // due to a bug, popovers become unclosable if they are directly rendered + // with isOpen set to true. This "double set" makes sure to mount the component + // in closed state before opening it + this.setState({ popoverIsOpen: true }, () => + this.setState(({ popoverIsOpen }) => ({ + renderPopoverOpen: popoverIsOpen, + })) + ); break; } } else { @@ -396,9 +456,9 @@ export class EuiDataGridCell extends Component< tokens={['euiDataGridCell.row', 'euiDataGridCell.column']} defaults={['Row', 'Column']}> {([row, column]: ReactChild[]) => ( - + <> {row}: {rowIndex + 1}, {column}: {colIndex + 1}: - + )}

@@ -414,94 +474,112 @@ export class EuiDataGridCell extends Component< }} clickOutsideDisables={true}>
- - {(mutationRef) => { - return ( -
- {screenReaderPosition} -
- -
-
- ); - }} -
+
+ {screenReaderPosition} +
+ +
+
); if (isExpandable || (column && column.cellActions)) { - anchorContent = ( -
- - {(mutationRef) => { - return ( -
- {screenReaderPosition} -
- -
-
- ); - }} -
- this.setState({ popoverIsOpen: false })} - onExpandClick={() => { - this.setState(({ popoverIsOpen }) => ({ - popoverIsOpen: !popoverIsOpen, - })); - }} - /> -
- ); + if (showCellButtons) { + anchorContent = ( +
+
+
+ +
+ {screenReaderPosition} +
+ {showCellButtons && ( + { + // due to a bug, popovers become unclosable if they are directly rendered + // with isOpen set to true. This "double set" makes sure to mount the component + // in closed state before opening it + this.setState( + ({ popoverIsOpen }) => ({ + popoverIsOpen: !popoverIsOpen, + }), + () => + this.setState(({ popoverIsOpen }) => ({ + renderPopoverOpen: popoverIsOpen, + })) + ); + }} + /> + )} +
+ ); + } else { + anchorContent = ( +
+ + {screenReaderPosition} +
+ ); + } } let innerContent = anchorContent; if (isExpandable || (column && column.cellActions)) { - innerContent = ( -
- this.setState({ popoverIsOpen: false })} - column={column} - panelRefFn={(ref) => (this.popoverPanelRef.current = ref)} - popoverIsOpen={this.state.popoverIsOpen} - rowIndex={rowIndex} - updateFocus={this.updateFocus} - renderCellValue={rest.renderCellValue} - popoverContent={PopoverContent} - /> -
- ); + if (this.state.popoverIsOpen) { + innerContent = ( +
+ { + // due to a bug, popovers don't close properly wenn unmounted in open state. + // This "double set" makes sure to unmount the component in closed state. + this.setState({ popoverIsOpen: false }, () => + this.setState(() => ({ + renderPopoverOpen: false, + })) + ); + }} + column={column} + panelRefFn={(ref) => (this.popoverPanelRef.current = ref)} + popoverIsOpen={this.state.renderPopoverOpen} + rowIndex={rowIndex} + renderCellValue={rest.renderCellValue} + popoverContent={PopoverContent} + /> +
+ ); + } else { + innerContent = anchorContent; + } } return (
{ + this.setState({ enableInteractions: true }); + }} onBlur={this.onBlur}> {innerContent}
diff --git a/src/components/datagrid/data_grid_cell_buttons.tsx b/src/components/datagrid/data_grid_cell_buttons.tsx index ee01e8fe6a9..5e3d69844d4 100644 --- a/src/components/datagrid/data_grid_cell_buttons.tsx +++ b/src/components/datagrid/data_grid_cell_buttons.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { JSXElementConstructor } from 'react'; +import React, { JSXElementConstructor, useMemo } from 'react'; import { EuiDataGridColumn, EuiDataGridColumnCellAction, @@ -63,16 +63,16 @@ export const EuiDataGridCellButtons = ({ )} ); - const ButtonComponent = (props: EuiButtonIconProps) => ( - - ); - const additionalButtons = - column && Array.isArray(column.cellActions) + const additionalButtons = useMemo(() => { + const ButtonComponent = (props: EuiButtonIconProps) => ( + + ); + return column && Array.isArray(column.cellActions) ? column.cellActions.map( (Action: EuiDataGridColumnCellAction, idx: number) => { // React is more permissible than the TS types indicate @@ -92,6 +92,8 @@ export const EuiDataGridCellButtons = ({ } ) : []; + }, [column, rowIndex, closePopover]); + return (
{[...additionalButtons, expandButton]}
); diff --git a/src/components/datagrid/data_grid_cell_popover.tsx b/src/components/datagrid/data_grid_cell_popover.tsx index 5f421a5e08b..60724d4627e 100644 --- a/src/components/datagrid/data_grid_cell_popover.tsx +++ b/src/components/datagrid/data_grid_cell_popover.tsx @@ -42,7 +42,6 @@ interface EuiDataGridCellPopoverProps { | JSXElementConstructor | ((props: EuiDataGridCellValueElementProps) => ReactNode); rowIndex: number; - updateFocus: () => void; } export function EuiDataGridCellPopover({ @@ -56,7 +55,6 @@ export function EuiDataGridCellPopover({ popoverIsOpen, renderCellValue, rowIndex, - updateFocus, }: EuiDataGridCellPopoverProps) { const CellElement = renderCellValue as JSXElementConstructor< EuiDataGridCellValueElementProps @@ -80,8 +78,7 @@ export function EuiDataGridCellPopover({ event.stopPropagation(); closePopover(); } - }} - onTrapDeactivation={updateFocus}> + }}> {popoverIsOpen ? ( <> diff --git a/src/components/datagrid/data_grid_context.tsx b/src/components/datagrid/data_grid_context.tsx index f1cd28f3f6d..07f91351286 100644 --- a/src/components/datagrid/data_grid_context.tsx +++ b/src/components/datagrid/data_grid_context.tsx @@ -17,9 +17,33 @@ * under the License. */ -import React from 'react'; -import { EuiDataGridFocusedCell } from './data_grid_types'; +import React, { ReactElement } from 'react'; +import { EuiDataGridFocusedCell, EuiDataGridSorting } from './data_grid_types'; -export const DataGridContext = React.createContext({ - onFocusUpdate: (_cell: EuiDataGridFocusedCell, _updateFocus: Function) => {}, +export interface DataGridFocusContextShape { + setFocusedCell: (cell: EuiDataGridFocusedCell) => void; + onFocusUpdate: ( + cell: EuiDataGridFocusedCell, + updateFocus: Function + ) => () => void; +} + +export const DataGridFocusContext = React.createContext< + DataGridFocusContextShape +>({ + setFocusedCell: () => {}, + onFocusUpdate: () => () => {}, }); + +export const DataGridSortingContext = React.createContext< + EuiDataGridSorting | undefined +>(undefined); + +export interface DataGridWrapperRowsContentsShape { + headerRowHeight: number; + headerRow: ReactElement; + footerRow: ReactElement | null; +} +export const DataGridWrapperRowsContext = React.createContext< + DataGridWrapperRowsContentsShape +>({ headerRow:
, headerRowHeight: 0, footerRow: null }); diff --git a/src/components/datagrid/data_grid_control_header_cell.tsx b/src/components/datagrid/data_grid_control_header_cell.tsx index d8af548fda8..f469cb702f9 100644 --- a/src/components/datagrid/data_grid_control_header_cell.tsx +++ b/src/components/datagrid/data_grid_control_header_cell.tsx @@ -17,21 +17,22 @@ * under the License. */ -import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; +import React, { + FunctionComponent, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import classnames from 'classnames'; import { keys } from '../../services'; import tabbable from 'tabbable'; -import { - EuiDataGridControlColumn, - EuiDataGridFocusedCell, -} from './data_grid_types'; -import { EuiDataGridDataRowProps } from './data_grid_data_row'; +import { EuiDataGridControlColumn } from './data_grid_types'; +import { DataGridFocusContext } from './data_grid_context'; export interface EuiDataGridControlHeaderRowProps { index: number; controlColumn: EuiDataGridControlColumn; - focusedCell?: EuiDataGridFocusedCell; - setFocusedCell: EuiDataGridDataRowProps['onCellFocus']; headerIsInteractive: boolean; className?: string; } @@ -39,22 +40,22 @@ export interface EuiDataGridControlHeaderRowProps { export const EuiDataGridControlHeaderCell: FunctionComponent = ( props ) => { - const { - controlColumn, - index, - focusedCell, - setFocusedCell, - headerIsInteractive, - className, - } = props; + const { controlColumn, index, headerIsInteractive, className } = props; + + const { setFocusedCell, onFocusUpdate } = useContext(DataGridFocusContext); const { headerCellRender: HeaderCellRender, width, id } = controlColumn; const classes = classnames('euiDataGridHeaderCell', className); + const [isFocused, setIsFocused] = useState(false); + useEffect(() => { + onFocusUpdate([index, -1], (isFocused: boolean) => { + setIsFocused(isFocused); + }); + }, [index, onFocusUpdate]); + const headerRef = useRef(null); - const isFocused = - focusedCell != null && focusedCell[0] === index && focusedCell[1] === -1; const [isCellEntered, setIsCellEntered] = useState(false); useEffect(() => { @@ -175,7 +176,7 @@ export const EuiDataGridControlHeaderCell: FunctionComponent & { - rowIndex: number; - leadingControlColumns: EuiDataGridControlColumn[]; - trailingControlColumns: EuiDataGridControlColumn[]; - columns: EuiDataGridColumn[]; - schema: EuiDataGridSchema; - popoverContents: EuiDataGridPopoverContents; - columnWidths: EuiDataGridColumnWidths; - defaultColumnWidth?: number | null; - focusedCellPositionInTheRow?: number | null; - renderCellValue: EuiDataGridCellProps['renderCellValue']; - onCellFocus: Function; - interactiveCellId: EuiDataGridCellProps['interactiveCellId']; - visibleRowIndex: number; - }; - -const DefaultColumnFormatter: EuiDataGridPopoverContent = ({ children }) => { - return {children}; -}; - -const EuiDataGridDataRow: FunctionComponent = memo( - (props) => { - const { - leadingControlColumns, - trailingControlColumns, - columns, - schema, - popoverContents, - columnWidths, - defaultColumnWidth, - className, - renderCellValue, - rowIndex, - focusedCellPositionInTheRow, - onCellFocus, - interactiveCellId, - 'data-test-subj': _dataTestSubj, - visibleRowIndex, - ...rest - } = props; - - const classes = classnames('euiDataGridRow', className); - const dataTestSubj = classnames('dataGridRow', _dataTestSubj); - - return ( -
- {leadingControlColumns.map((leadingColumn, i) => { - const { id, rowCellRender } = leadingColumn; - - return ( - - ); - })} - {columns.map((props, i) => { - const { id } = props; - const columnType = schema[id] ? schema[id].columnType : null; - - const isExpandable = - props.isExpandable !== undefined ? props.isExpandable : true; - const popoverContent = - popoverContents[columnType as string] || DefaultColumnFormatter; - - const width = columnWidths[id] || defaultColumnWidth; - const columnPosition = i + leadingControlColumns.length; - - return ( - - ); - })} - {trailingControlColumns.map((trailingColumn, i) => { - const { id, rowCellRender } = trailingColumn; - const colIndex = i + columns.length + leadingControlColumns.length; - - return ( - - ); - })} -
- ); - } -); - -export { EuiDataGridDataRow }; diff --git a/src/components/datagrid/data_grid_footer_row.tsx b/src/components/datagrid/data_grid_footer_row.tsx index 299d3c93d83..9a7b79bb8f0 100644 --- a/src/components/datagrid/data_grid_footer_row.tsx +++ b/src/components/datagrid/data_grid_footer_row.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { FunctionComponent, HTMLAttributes, memo } from 'react'; +import React, { forwardRef, HTMLAttributes, memo } from 'react'; import classnames from 'classnames'; import { EuiDataGridControlColumn, @@ -42,9 +42,7 @@ export type EuiDataGridFooterRowProps = CommonProps & popoverContents: EuiDataGridPopoverContents; columnWidths: EuiDataGridColumnWidths; defaultColumnWidth?: number | null; - focusedCellPositionInTheRow?: number | null; renderCellValue: EuiDataGridCellProps['renderCellValue']; - onCellFocus: Function; interactiveCellId: EuiDataGridCellProps['interactiveCellId']; visibleRowIndex?: number; }; @@ -53,107 +51,107 @@ const DefaultColumnFormatter: EuiDataGridPopoverContent = ({ children }) => { return {children}; }; -const EuiDataGridFooterRow: FunctionComponent = memo( - ({ - leadingControlColumns, - trailingControlColumns, - columns, - schema, - popoverContents, - columnWidths, - defaultColumnWidth, - className, - renderCellValue, - rowIndex, - focusedCellPositionInTheRow, - onCellFocus, - interactiveCellId, - 'data-test-subj': _dataTestSubj, - visibleRowIndex = rowIndex, - ...rest - }) => { - const classes = classnames( - 'euiDataGridRow', - 'euiDataGridFooter', - className - ); - const dataTestSubj = classnames('dataGridRow', _dataTestSubj); +const EuiDataGridFooterRow = memo( + forwardRef( + ( + { + leadingControlColumns, + trailingControlColumns, + columns, + schema, + popoverContents, + columnWidths, + defaultColumnWidth, + className, + renderCellValue, + rowIndex, + interactiveCellId, + 'data-test-subj': _dataTestSubj, + visibleRowIndex = rowIndex, + ...rest + }, + ref + ) => { + const classes = classnames( + 'euiDataGridRow', + 'euiDataGridFooter', + className + ); + const dataTestSubj = classnames('dataGridRow', _dataTestSubj); - return ( -
- {leadingControlColumns.map(({ id, width }, i) => ( - null} - onCellFocus={onCellFocus} - isFocused={focusedCellPositionInTheRow === i} - interactiveCellId={interactiveCellId} - isExpandable={true} - className="euiDataGridFooterCell euiDataGridRowCell--controlColumn" - /> - ))} - {columns.map(({ id }, i) => { - const columnType = schema[id] ? schema[id].columnType : null; - const popoverContent = - (columnType && popoverContents[columnType]) || - DefaultColumnFormatter; - - const width = columnWidths[id] || defaultColumnWidth; - const columnPosition = i + leadingControlColumns.length; - - return ( + return ( +
+ {leadingControlColumns.map(({ id, width }, i) => ( - ); - })} - {trailingControlColumns.map(({ id, width }, i) => { - const colIndex = i + columns.length + leadingControlColumns.length; - - return ( - null} - onCellFocus={onCellFocus} - isFocused={focusedCellPositionInTheRow === colIndex} interactiveCellId={interactiveCellId} isExpandable={true} className="euiDataGridFooterCell euiDataGridRowCell--controlColumn" /> - ); - })} -
- ); - } + ))} + {columns.map(({ id }, i) => { + const columnType = schema[id] ? schema[id].columnType : null; + const popoverContent = + (columnType && popoverContents[columnType]) || + DefaultColumnFormatter; + + const width = columnWidths[id] || defaultColumnWidth; + const columnPosition = i + leadingControlColumns.length; + + return ( + + ); + })} + {trailingControlColumns.map(({ id, width }, i) => { + const colIndex = i + columns.length + leadingControlColumns.length; + + return ( + null} + interactiveCellId={interactiveCellId} + isExpandable={true} + className="euiDataGridFooterCell euiDataGridRowCell--controlColumn" + /> + ); + })} +
+ ); + } + ) ); +EuiDataGridFooterRow.displayName = 'EuiDataGridFooterRow'; + export { EuiDataGridFooterRow }; diff --git a/src/components/datagrid/data_grid_header_cell.tsx b/src/components/datagrid/data_grid_header_cell.tsx index 608d7f4cbe4..35aec8b1d8c 100644 --- a/src/components/datagrid/data_grid_header_cell.tsx +++ b/src/components/datagrid/data_grid_header_cell.tsx @@ -22,6 +22,7 @@ import React, { FunctionComponent, HTMLAttributes, useCallback, + useContext, useEffect, useRef, useState, @@ -39,6 +40,10 @@ import { EuiDataGridColumn } from './data_grid_types'; import { getColumnActions } from './column_actions'; import { useEuiI18n } from '../i18n'; import { EuiIcon } from '../icon'; +import { + DataGridFocusContext, + DataGridSortingContext, +} from './data_grid_context'; export interface EuiDataGridHeaderCellProps extends Omit< @@ -64,9 +69,6 @@ export const EuiDataGridHeaderCell: FunctionComponent id)); @@ -118,9 +123,14 @@ export const EuiDataGridHeaderCell: FunctionComponent { + return onFocusUpdate([index, -1], (isFocused: boolean) => { + setIsFocused(isFocused); + }); + }, [index, onFocusUpdate]); + const headerRef = useRef(null); - const isFocused = - focusedCell != null && focusedCell[0] === index && focusedCell[1] === -1; const [isCellEntered, setIsCellEntered] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -189,11 +199,7 @@ export const EuiDataGridHeaderCell: FunctionComponent setIsPopoverOpen(true)}> + onClick={() => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen)}> {sortingArrow}
{display || displayAsText || id} @@ -335,6 +340,7 @@ export const EuiDataGridHeaderCell: FunctionComponent } isOpen={isPopoverOpen} - closePopover={() => setIsPopoverOpen(false)} - ownFocus={isFocused}> + closePopover={() => setIsPopoverOpen(false)}>
void; setVisibleColumns: (columnId: string[]) => void; switchColumnPos: (colFromId: string, colToId: string) => void; - sorting?: EuiDataGridSorting; - focusedCell?: EuiDataGridFocusedCell; - onCellFocus: EuiDataGridDataRowProps['onCellFocus']; headerIsInteractive: boolean; } @@ -72,9 +66,6 @@ const EuiDataGridHeaderRow = forwardRef< setColumnWidth, setVisibleColumns, switchColumnPos, - sorting, - focusedCell, - onCellFocus: setFocusedCell, headerIsInteractive, 'data-test-subj': _dataTestSubj, ...rest @@ -95,8 +86,6 @@ const EuiDataGridHeaderRow = forwardRef< key={controlColumn.id} index={index} controlColumn={controlColumn} - focusedCell={focusedCell} - setFocusedCell={setFocusedCell} headerIsInteractive={headerIsInteractive} className="euiDataGridHeaderCell--controlColumn" /> @@ -108,15 +97,12 @@ const EuiDataGridHeaderRow = forwardRef< columns={columns} index={index + leadingControlColumns.length} columnWidths={columnWidths} - focusedCell={focusedCell} - onCellFocus={setFocusedCell} schema={schema} schemaDetectors={schemaDetectors} setColumnWidth={setColumnWidth} setVisibleColumns={setVisibleColumns} switchColumnPos={switchColumnPos} defaultColumnWidth={defaultColumnWidth} - sorting={sorting} headerIsInteractive={headerIsInteractive} /> ))} @@ -125,8 +111,6 @@ const EuiDataGridHeaderRow = forwardRef< key={controlColumn.id} index={index + leadingControlColumns.length + columns.length} controlColumn={controlColumn} - focusedCell={focusedCell} - setFocusedCell={setFocusedCell} headerIsInteractive={headerIsInteractive} className="euiDataGridHeaderCell--controlColumn" /> diff --git a/src/components/datagrid/data_grid_schema.tsx b/src/components/datagrid/data_grid_schema.tsx index beec874e110..c25e232d879 100644 --- a/src/components/datagrid/data_grid_schema.tsx +++ b/src/components/datagrid/data_grid_schema.tsx @@ -288,6 +288,7 @@ function scoreValueBySchemaType( // represents lowest score a type detector can have to be considered valid const MINIMUM_SCORE_MATCH = 0.5; +const emptyArray: unknown = []; // for in-memory object permanence export function useDetectSchema( inMemory: EuiDataGridInMemory | undefined, inMemoryValues: EuiDataGridInMemoryValues, @@ -295,6 +296,10 @@ export function useDetectSchema( definedColumnSchemas: { [key: string]: string }, autoDetectSchema: boolean ) { + const inMemorySkipColumns = + inMemory?.skipColumns ?? + (emptyArray as NonNullable); + const schema = useMemo(() => { const schema: EuiDataGridSchema = {}; if (autoDetectSchema === false) { @@ -309,7 +314,7 @@ export function useDetectSchema( const rowIndices = Object.keys(inMemoryValues); const columnIdsWithDefinedSchemas = new Set([ - ...((inMemory && inMemory.skipColumns) || []), + ...inMemorySkipColumns, ...Object.keys(definedColumnSchemas), ]); @@ -404,7 +409,7 @@ export function useDetectSchema( }, [ autoDetectSchema, definedColumnSchemas, - inMemory, + inMemorySkipColumns, inMemoryValues, schemaDetectors, ]); diff --git a/src/components/datagrid/index.ts b/src/components/datagrid/index.ts index 2ceffd67b49..8f09f373cd8 100644 --- a/src/components/datagrid/index.ts +++ b/src/components/datagrid/index.ts @@ -26,7 +26,6 @@ export { EuiDataGridCellValueElementProps, } from './data_grid_cell'; export { EuiDataGridColumnResizerProps } from './data_grid_column_resizer'; -export { EuiDataGridDataRowProps } from './data_grid_data_row'; export { EuiDataGridHeaderRowProps } from './data_grid_header_row'; export { EuiDataGridHeaderCellProps } from './data_grid_header_cell'; export { EuiDataGridControlHeaderRowProps } from './data_grid_control_header_cell'; diff --git a/src/components/observer/mutation_observer/index.ts b/src/components/observer/mutation_observer/index.ts index 007d8bd2c05..9522e7bc401 100644 --- a/src/components/observer/mutation_observer/index.ts +++ b/src/components/observer/mutation_observer/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { EuiMutationObserver } from './mutation_observer'; +export { EuiMutationObserver, useMutationObserver } from './mutation_observer'; diff --git a/src/components/observer/mutation_observer/mutation_observer.test.tsx b/src/components/observer/mutation_observer/mutation_observer.test.tsx index 1805bd89ec9..71f55788bb3 100644 --- a/src/components/observer/mutation_observer/mutation_observer.test.tsx +++ b/src/components/observer/mutation_observer/mutation_observer.test.tsx @@ -17,9 +17,9 @@ * under the License. */ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { mount } from 'enzyme'; -import { EuiMutationObserver } from './mutation_observer'; +import { EuiMutationObserver, useMutationObserver } from './mutation_observer'; import { sleep } from '../../../test'; export async function waitforMutationObserver(period = 30) { @@ -55,3 +55,36 @@ describe('EuiMutationObserver', () => { expect(onMutation).toHaveBeenCalledTimes(1); }); }); + +describe('useMutationObserver', () => { + it('watches changing content', async () => { + expect.assertions(2); + + const mutationCallback = jest.fn(); + const Wrapper: FunctionComponent<{}> = jest.fn(({ children }) => { + const [ref, setRef] = useState(null); + useMutationObserver(ref, mutationCallback, { + childList: true, + subtree: true, + }); + return
{children}
; + }); + + const component = mount(Hello World
} />); + + await waitforMutationObserver(); + expect(mutationCallback).toHaveBeenCalledTimes(0); + + component.setProps({ + children: ( +
+
Hello World
+
Hello Again
+
+ ), + }); + + await waitforMutationObserver(); + expect(mutationCallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/observer/mutation_observer/mutation_observer.ts b/src/components/observer/mutation_observer/mutation_observer.ts index 4db7fe6050f..458fd8ea6e9 100644 --- a/src/components/observer/mutation_observer/mutation_observer.ts +++ b/src/components/observer/mutation_observer/mutation_observer.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ReactNode } from 'react'; +import { ReactNode, useEffect } from 'react'; import { EuiObserver } from '../observer'; @@ -40,22 +40,59 @@ export class EuiMutationObserver extends EuiObserver { }; beginObserve = () => { - // IE11 and the MutationObserver polyfill used in Kibana (for Jest) implement - // an older spec in which specifying `attributeOldValue` or `attributeFilter` - // without specifying `attributes` results in a `SyntaxError`. - // The following logic patches the newer spec in which `attributes: true` can be - // implied when appropriate (`attributeOldValue` or `attributeFilter` is specified). - const observerOptions: MutationObserverInit = { - ...this.props.observerOptions, - }; - const needsAttributes = - observerOptions.hasOwnProperty('attributeOldValue') || - observerOptions.hasOwnProperty('attributeFilter'); - if (needsAttributes && !observerOptions.hasOwnProperty('attributes')) { - observerOptions.attributes = true; - } - - this.observer = new MutationObserver(this.onMutation); - this.observer.observe(this.childNode!, observerOptions); + const childNode = this.childNode!; + this.observer = makeMutationObserver( + childNode, + this.props.observerOptions, + this.onMutation + ); }; } + +const makeMutationObserver = ( + node: Element, + _observerOptions: MutationObserverInit | undefined, + callback: MutationCallback +) => { + // IE11 and the MutationObserver polyfill used in Kibana (for Jest) implement + // an older spec in which specifying `attributeOldValue` or `attributeFilter` + // without specifying `attributes` results in a `SyntaxError`. + // The following logic patches the newer spec in which `attributes: true` can be + // implied when appropriate (`attributeOldValue` or `attributeFilter` is specified). + const observerOptions: MutationObserverInit = { + ..._observerOptions, + }; + const needsAttributes = + observerOptions.hasOwnProperty('attributeOldValue') || + observerOptions.hasOwnProperty('attributeFilter'); + if (needsAttributes && !observerOptions.hasOwnProperty('attributes')) { + observerOptions.attributes = true; + } + + const observer = new MutationObserver(callback); + observer.observe(node, observerOptions); + + return observer; +}; + +export const useMutationObserver = ( + container: Element | null, + callback: MutationCallback, + observerOptions?: MutationObserverInit +) => { + useEffect( + () => { + if (container != null) { + const observer = makeMutationObserver( + container, + observerOptions, + callback + ); + return () => observer.disconnect(); + } + }, + // ignore changing observerOptions + // eslint-disable-next-line + [container, callback] + ); +}; diff --git a/src/components/observer/resize_observer/resize_observer.tsx b/src/components/observer/resize_observer/resize_observer.tsx index d3b598ecf86..f5ca3696d0c 100644 --- a/src/components/observer/resize_observer/resize_observer.tsx +++ b/src/components/observer/resize_observer/resize_observer.tsx @@ -30,7 +30,8 @@ interface Props { // IE11 and Safari don't support the `ResizeObserver` API at the time of writing const hasResizeObserver = - typeof window !== 'undefined' && typeof window.ResizeObserver !== 'undefined'; + typeof window !== 'undefined' && + typeof (window as any).ResizeObserver !== 'undefined'; const mutationObserverOptions = { // [MutationObserverInit](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit) @@ -69,7 +70,7 @@ export class EuiResizeObserver extends EuiObserver { // The superclass checks that childNode is not null before invoking // beginObserve() const childNode = this.childNode!; - this.observer = makeResizeObserver(childNode, this.onResize); + this.observer = makeResizeObserver(childNode, this.onResize)!; }; } @@ -91,8 +92,8 @@ const makeCompatibleObserver = (node: Element, callback: () => void) => { const makeResizeObserver = (node: Element, callback: () => void) => { let observer: Observer | undefined; if (hasResizeObserver) { - observer = new window.ResizeObserver(callback); - observer.observe(node); + observer = new (window as any).ResizeObserver(callback); + observer!.observe(node); } else { observer = makeCompatibleObserver(node, callback); requestAnimationFrame(callback); // Mimic ResizeObserver behavior of triggering a resize event on init @@ -142,7 +143,7 @@ export const useResizeObserver = ( width: boundingRect.width, height: boundingRect.height, }); - }); + })!; return () => observer.disconnect(); } else { diff --git a/yarn.lock b/yarn.lock index d41ce280d89..41aa3302e32 100755 --- a/yarn.lock +++ b/yarn.lock @@ -1802,11 +1802,6 @@ "@types/prop-types" "*" csstype "^2.2.0" -"@types/resize-observer-browser@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.3.tgz#5cca2445e6fc34a380760bd6ef8c492863469c47" - integrity sha512-3tGjLIDH8L57fWOfC7NVn/BbGQD7pXwbkk2+8Z4hK/S7kOIv1MUN4nkKjfx0qg4ctkukjzp3Bgr/Z+Hq5ZQZTQ== - "@types/responselike@*": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29"