From a11501ddde716c35e1816dbff8417821c0e0d1af Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Thu, 9 Mar 2023 09:19:46 -0800 Subject: [PATCH] [EuiDataGrid] Add `renderCustomGridBody` API (#6624) * [Setup] Split up `RowHeightUtils` into virtualized/non-virtualized classes - base class has no virtualization dependencies and will be used by the custom renderer - The extended `RowHeightVirtualizationUtils` class will be used by the virtualized body renderer[Setup] Split up `RowHeightUtils` into virtualized/non-virtualized classes * Add `RowHeightUtilsType` + update downstream type references * Update `RowHeightUtils` test mocks * [optional tech debt] Convert row heights tests to RTL `renderHook` * [Setup] Add `renderCustomGridBody` API to top-level data grid * Add documentation example * [EuiDataGridBody] Split up into custom and virtualized body renderers Split up parent body into a basic ternary, and move tests accordingly Custom renderer logic will be in a separate commit (to hopefully make DRYing things out easier to follow) * Set up header & footer render * DRY out shared `Cell` wrapper component - between custom & virtualized data grid bodies they each still need another curried _Cell wrapper on top of that to handle individual style or prop transmogs, but this DRYes out a significant amount of reused logic (and allows for clearer unit testing) * Write tests for custom renderer * Fix incredibly bizarre rerender/unmount bug - the empty control columns array fallbacks were otherwise causing unnecessary rerenders in the custom renderer's memoized `visibleColumns`, which was causing cell popovers to break due to cells unmounting * Fix various row/cell-related CSS - the current CSS takes for granted that the virtualization library is setting position+widths which is no longer always the case - our CSS should now provide sane fallbacks for custom rendering * [misc] Remove unnecessary `top: 0` from non-virtualized cells * changelog * Improve copy on props docs * Fix failing Cypress test - forgot to run locally with new compiled CSS, which was causing a different outer vs scroll height computation * [PR feedback] Add new `setCustomGridBodyProps` callback param to `renderCustomGridBody` + improve docs slightly - note: I skipped adding documentation for `setCustomGridBodyProps` to snippets as this is technically an optional prop to use (unlike the others) - hopefully demo+prop table documentation are sufficient * Add explicit ref typing + tests --- src-docs/src/views/datagrid/_snippets.tsx | 5 + .../datagrid/advanced/custom_renderer.tsx | 302 ++++++++++ .../advanced/datagrid_advanced_example.js | 70 ++- src-docs/src/views/datagrid/basics/_props.tsx | 3 + src/components/datagrid/_data_grid.scss | 7 + .../datagrid/_data_grid_data_row.scss | 1 + .../data_grid_body_custom.test.tsx.snap | 262 +++++++++ ... data_grid_body_virtualized.test.tsx.snap} | 2 +- .../data_grid_cell.test.tsx.snap | 1 - .../datagrid/body/data_grid_body.test.tsx | 183 ++----- .../datagrid/body/data_grid_body.tsx | 461 +--------------- .../body/data_grid_body_custom.spec.tsx | 247 +++++++++ .../body/data_grid_body_custom.test.tsx | 119 ++++ .../datagrid/body/data_grid_body_custom.tsx | 182 ++++++ .../body/data_grid_body_virtualized.test.tsx | 81 +++ .../body/data_grid_body_virtualized.tsx | 337 ++++++++++++ .../datagrid/body/data_grid_cell.test.tsx | 7 +- .../datagrid/body/data_grid_cell.tsx | 11 +- .../body/data_grid_cell_wrapper.test.tsx | 115 ++++ .../datagrid/body/data_grid_cell_wrapper.tsx | 181 ++++++ .../body/footer/_data_grid_footer_row.scss | 1 + .../body/header/_data_grid_header_row.scss | 1 + .../body/header/data_grid_header_row.tsx | 9 +- src/components/datagrid/data_grid.tsx | 7 +- src/components/datagrid/data_grid_types.ts | 63 ++- .../datagrid/utils/__mocks__/row_heights.ts | 65 +-- .../datagrid/utils/grid_height_width.ts | 4 +- .../datagrid/utils/row_heights.test.ts | 518 ++++++++++-------- src/components/datagrid/utils/row_heights.ts | 123 +++-- upcoming_changelogs/6624.md | 1 + 30 files changed, 2469 insertions(+), 900 deletions(-) create mode 100644 src-docs/src/views/datagrid/advanced/custom_renderer.tsx create mode 100644 src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap rename src/components/datagrid/body/__snapshots__/{data_grid_body.test.tsx.snap => data_grid_body_virtualized.test.tsx.snap} (99%) create mode 100644 src/components/datagrid/body/data_grid_body_custom.spec.tsx create mode 100644 src/components/datagrid/body/data_grid_body_custom.test.tsx create mode 100644 src/components/datagrid/body/data_grid_body_custom.tsx create mode 100644 src/components/datagrid/body/data_grid_body_virtualized.test.tsx create mode 100644 src/components/datagrid/body/data_grid_body_virtualized.tsx create mode 100644 src/components/datagrid/body/data_grid_cell_wrapper.test.tsx create mode 100644 src/components/datagrid/body/data_grid_cell_wrapper.tsx create mode 100644 upcoming_changelogs/6624.md diff --git a/src-docs/src/views/datagrid/_snippets.tsx b/src-docs/src/views/datagrid/_snippets.tsx index 672e1417364..85a620d83c2 100644 --- a/src-docs/src/views/datagrid/_snippets.tsx +++ b/src-docs/src/views/datagrid/_snippets.tsx @@ -62,6 +62,11 @@ inMemory={{ level: 'sorting' }}`, )}`, renderFooterCellValue: 'renderFooterCellValue={({ rowIndex, columnId }) => {}}', + renderCustomGridBody: `// Optional; advanced usage only. This render function is an escape hatch for consumers who need to opt out of virtualization or otherwise need total custom control over how data grid cells are rendered. + +renderCustomDataGridBody={({ visibleColumns, visibleRowData, Cell }) => ( + +)}`, pagination: `pagination={{ pageIndex: 1, pageSize: 100, diff --git a/src-docs/src/views/datagrid/advanced/custom_renderer.tsx b/src-docs/src/views/datagrid/advanced/custom_renderer.tsx new file mode 100644 index 00000000000..64a04678e73 --- /dev/null +++ b/src-docs/src/views/datagrid/advanced/custom_renderer.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useCallback, useState, useRef } from 'react'; +import { css } from '@emotion/react'; +import { faker } from '@faker-js/faker'; + +import { + EuiDataGrid, + EuiDataGridProps, + EuiDataGridCustomBodyProps, + EuiDataGridColumnCellActionProps, + EuiScreenReaderOnly, + EuiCheckbox, + EuiButtonIcon, + EuiIcon, + EuiFlexGroup, + EuiSwitch, + EuiSpacer, + useEuiTheme, + logicalCSS, +} from '../../../../../src'; + +const raw_data: Array<{ [key: string]: string }> = []; +for (let i = 1; i < 100; i++) { + raw_data.push({ + name: `${faker.name.lastName()}, ${faker.name.firstName()}`, + email: faker.internet.email(), + location: `${faker.address.city()}, ${faker.address.country()}`, + date: `${faker.date.past()}`, + amount: faker.commerce.price(1, 1000, 2, '$'), + }); +} + +const columns = [ + { + id: 'name', + displayAsText: 'Name', + cellActions: [ + ({ Component }: EuiDataGridColumnCellActionProps) => ( + alert('action')} + iconType="faceHappy" + aria-label="Some action" + > + Some action + + ), + ], + }, + { + id: 'email', + displayAsText: 'Email address', + initialWidth: 130, + }, + { + id: 'location', + displayAsText: 'Location', + }, + { + id: 'date', + displayAsText: 'Date', + }, + { + id: 'amount', + displayAsText: 'Amount', + }, +]; + +const leadingControlColumns: EuiDataGridProps['leadingControlColumns'] = [ + { + id: 'selection', + width: 32, + headerCellRender: () => ( + {}} + /> + ), + rowCellRender: ({ rowIndex }) => ( + {}} + /> + ), + }, +]; + +const trailingControlColumns: EuiDataGridProps['trailingControlColumns'] = [ + { + id: 'actions', + width: 40, + headerCellRender: () => ( + + Actions + + ), + rowCellRender: () => ( + + ), + }, +]; + +// The custom row details is actually a trailing control column cell with +// a hidden header. This is important for accessibility and markup reasons +// @see https://fuschia-stretch.glitch.me/ for more +const rowDetails: EuiDataGridProps['trailingControlColumns'] = [ + { + id: 'row-details', + + // The header cell should be visually hidden, but available to screen readers + width: 0, + headerCellRender: () => <>Row details, + headerCellProps: { className: 'euiScreenReaderOnly' }, + + // The footer cell can be hidden to both visual & SR users, as it does not contain meaningful information + footerCellProps: { style: { display: 'none' } }, + + // When rendering this custom cell, we'll want to override + // the automatic width/heights calculated by EuiDataGrid + rowCellRender: ({ setCellProps, rowIndex }) => { + setCellProps({ style: { width: '100%', height: 'auto' } }); + + const firstName = raw_data[rowIndex].name.split(', ')[1]; + const isGood = faker.datatype.boolean(); + return ( + <> + {firstName}'s account has {isGood ? 'no' : ''} outstanding fees.{' '} + + + ); + }, + }, +]; + +const footerCellValues: { [key: string]: string } = { + amount: `Total: ${raw_data + .reduce((acc, { amount }) => acc + Number(amount.split('$')[1]), 0) + .toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`, +}; + +const RenderFooterCellValue: EuiDataGridProps['renderFooterCellValue'] = ({ + columnId, + setCellProps, +}) => { + const value = footerCellValues[columnId]; + + useEffect(() => { + // Turn off the cell expansion button if the footer cell is empty + if (!value) setCellProps({ isExpandable: false }); + }, [value, setCellProps, columnId]); + + return value || null; +}; + +export default () => { + const [autoHeight, setAutoHeight] = useState(true); + const [showRowDetails, setShowRowDetails] = useState(false); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); + + // Pagination + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + 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); + }, []); + + const { euiTheme } = useEuiTheme(); + + // Custom grid body renderer + const RenderCustomGridBody = useCallback( + ({ + Cell, + visibleColumns, + visibleRowData, + setCustomGridBodyProps, + }: EuiDataGridCustomBodyProps) => { + // Ensure we're displaying correctly-paginated rows + const visibleRows = raw_data.slice( + visibleRowData.startRow, + visibleRowData.endRow + ); + + // Add styling needed for custom grid body rows + const styles = { + row: css` + ${logicalCSS('width', 'fit-content')}; + ${logicalCSS('border-bottom', euiTheme.border.thin)}; + background-color: ${euiTheme.colors.emptyShade}; + `, + rowCellsWrapper: css` + display: flex; + `, + rowDetailsWrapper: css` + text-align: center; + background-color: ${euiTheme.colors.body}; + `, + }; + + // Set custom props onto the grid body wrapper + const bodyRef = useRef(null); + useEffect(() => { + setCustomGridBodyProps({ + ref: bodyRef, + onScroll: () => + console.debug('scrollTop:', bodyRef.current?.scrollTop), + }); + }, [setCustomGridBodyProps]); + + return ( + <> + {visibleRows.map((row, rowIndex) => ( +
+
+ {visibleColumns.map((column, colIndex) => { + // Skip the row details cell - we'll render it manually outside of the flex wrapper + if (column.id !== 'row-details') { + return ( + + ); + } + })} +
+ {showRowDetails && ( +
+ +
+ )} +
+ ))} + + ); + }, + [showRowDetails, euiTheme] + ); + + return ( + <> + + setAutoHeight(!autoHeight)} + /> + setShowRowDetails(!showRowDetails)} + /> + + + + raw_data[rowIndex][columnId] + } + renderFooterCellValue={RenderFooterCellValue} + renderCustomGridBody={RenderCustomGridBody} + height={autoHeight ? undefined : 400} + gridStyle={{ border: 'none', header: 'underline' }} + /> + + ); +}; diff --git a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js index adf9825c347..ce49e6dda5a 100644 --- a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js +++ b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import { GuideSectionTypes } from '../../../components'; import { @@ -9,7 +10,10 @@ import { EuiLink, } from '../../../../../src/components'; -import { EuiDataGridRefProps } from '!!prop-loader!../../../../../src/components/datagrid/data_grid_types'; +import { + EuiDataGridRefProps, + EuiDataGridCustomBodyProps, +} from '!!prop-loader!../../../../../src/components/datagrid/data_grid_types'; import { DataGridMemoryExample } from './datagrid_memory_example'; @@ -31,6 +35,28 @@ dataGridRef.current.openCellPopover({ rowIndex, colIndex }); dataGridRef.current.closeCellPopover(); `; +import CustomRenderer from './custom_renderer'; +const customRendererSource = require('!!raw-loader!./custom_renderer'); +const customRendererSnippet = `const CustomGridBody = ({ visibleColumns, visibleRowData, Cell }) => { + const visibleRows = raw_data.slice( + visibleRowData.startRow, + visibleRowData.endRow + ); + return ( + <> + {visibleRows.map((row, rowIndex) => ( +
+ {visibleColumns.map((column, colIndex) => ( + + ))} +
+ ))} + + ); +}; + +`; + export const DataGridAdvancedExample = { title: 'Data grid advanced', sections: [ @@ -185,5 +211,47 @@ export const DataGridAdvancedExample = { props: { EuiDataGridRefProps }, }, ...DataGridMemoryExample.sections, + { + title: 'Custom body renderer', + source: [ + { + type: GuideSectionTypes.TSX, + code: customRendererSource, + }, + ], + text: ( + <> +

+ For extremely advanced use cases, the{' '} + renderCustomGridBody prop may be used to take + complete control over rendering the grid body. This may be useful + for scenarios where the default{' '} + + virtualized + {' '} + rendering is not desired, or where custom row layouts (e.g., the + conditional row details cell below) are required. +

+

+ Please note that this prop is meant to be an{' '} + escape hatch, and should only be used if you know + exactly what you are doing. Once a custom renderer is used, you are + in charge of ensuring the grid has all the correct semantic and aria + labels required by the{' '} + + data grid spec + + , and that keyboard focus and navigation still work in an accessible + manner. +

+ + ), + demo: , + snippet: customRendererSnippet, + props: { EuiDataGridCustomBodyProps }, + }, ], }; diff --git a/src-docs/src/views/datagrid/basics/_props.tsx b/src-docs/src/views/datagrid/basics/_props.tsx index e2791a649ea..33d578b0ca6 100644 --- a/src-docs/src/views/datagrid/basics/_props.tsx +++ b/src-docs/src/views/datagrid/basics/_props.tsx @@ -19,6 +19,8 @@ const gridLinks = { schemaDetectors: '/tabular-content/data-grid-schema-columns#schemas', toolbarVisibility: '/tabular-content/data-grid-toolbar#toolbar-visibility', ref: '/tabular-content/data-grid-advanced#ref-methods', + renderCustomGridBody: + '/tabular-content/data-grid-advanced#custom-body-renderer', }; export const DataGridTopProps = () => { @@ -27,6 +29,7 @@ export const DataGridTopProps = () => { component={EuiDataGrid} exclude={[ 'className', + 'css', 'data-test-subj', 'aria-label', 'width', diff --git a/src/components/datagrid/_data_grid.scss b/src/components/datagrid/_data_grid.scss index 3f341ae27ad..1f5406b08d3 100644 --- a/src/components/datagrid/_data_grid.scss +++ b/src/components/datagrid/_data_grid.scss @@ -35,6 +35,13 @@ font-feature-settings: 'tnum' 1; // Tabular numbers } +.euiDataGrid__customRenderBody { + @include euiScrollBar($euiColorDarkShade, $euiColorEmptyShade); + height: 100%; + width: 100%; + overflow: auto; +} + .euiDataGrid__pagination { z-index: 2; // Sits above the content above it padding-top: $euiSizeXS; diff --git a/src/components/datagrid/_data_grid_data_row.scss b/src/components/datagrid/_data_grid_data_row.scss index 68e363ca041..833e5efbab8 100644 --- a/src/components/datagrid/_data_grid_data_row.scss +++ b/src/components/datagrid/_data_grid_data_row.scss @@ -24,6 +24,7 @@ } &:focus { + position: relative; @include euiDataGridCellFocus; } diff --git a/src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap b/src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap new file mode 100644 index 00000000000..b000e5a671e --- /dev/null +++ b/src/components/datagrid/body/__snapshots__/data_grid_body_custom.test.tsx.snap @@ -0,0 +1,262 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiDataGridBodyCustomRender treats \`renderCustomGridBody\` as a render prop 1`] = ` +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ hello +
+

+ - + columnA, column 1, row 1 +

+
+
+
+
+
+
+
+ world +
+

+ - + columnB, column 2, row 1 +

+
+
+
+
+
+
+
+
+
+ lorem +
+

+ - + columnA, column 1, row 2 +

+
+
+
+
+
+
+
+ ipsum +
+

+ - + columnB, column 2, row 2 +

+
+
+
+
+
+`; diff --git a/src/components/datagrid/body/__snapshots__/data_grid_body.test.tsx.snap b/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap similarity index 99% rename from src/components/datagrid/body/__snapshots__/data_grid_body.test.tsx.snap rename to src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap index 647f32b7d4a..3cea24da5b7 100644 --- a/src/components/datagrid/body/__snapshots__/data_grid_body.test.tsx.snap +++ b/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`EuiDataGridBody renders 1`] = ` +exports[`EuiDataGridBodyVirtualized renders 1`] = `
{ - const gridRef = { - current: { - resetAfterColumnIndex: jest.fn(), - resetAfterRowIndex: jest.fn(), - } as any, - }; - const outerGridElementRef = { current: null }; - const gridItemsRendered = { current: null }; - const rerenderGridBodyRef = { current: null }; - const rowHeightUtils = new RowHeightUtils( - gridRef, - outerGridElementRef, - gridItemsRendered, - rerenderGridBodyRef - ); - - const requiredProps = { - headerIsInteractive: true, - rowCount: 1, - visibleRows: { startRow: 0, endRow: 1, visibleRowCount: 1 }, - columnWidths: { columnA: 20 }, - columns: [ - { id: 'columnA', schema: 'boolean' }, - { id: 'columnB', isExpandable: true }, - ], - leadingControlColumns: [], - trailingControlColumns: [], - visibleColCount: 2, - schema: { - columnA: { columnType: 'boolean' }, - columnB: { columnType: 'string' }, - }, - renderCellValue: () => cell content, - interactiveCellId: 'someId', - inMemory: { level: 'enhancements' as any }, - inMemoryValues: {}, - handleHeaderMutation: jest.fn(), - setVisibleColumns: jest.fn(), - switchColumnPos: jest.fn(), - schemaDetectors, - rowHeightUtils, - isFullScreen: false, - gridStyles: {}, - gridWidth: 300, - gridRef, - gridItemsRendered, - wrapperRef: { current: document.createElement('div') }, - }; +// Body props, reused by other body unit tests +export const dataGridBodyProps = { + headerIsInteractive: true, + rowCount: 1, + visibleRows: { startRow: 0, endRow: 1, visibleRowCount: 1 }, + columnWidths: { columnA: 100 }, + columns: [ + { id: 'columnA', schema: 'boolean' }, + { id: 'columnB', isExpandable: true }, + ], + leadingControlColumns: [], + trailingControlColumns: [], + visibleColCount: 2, + schema: { + columnA: { columnType: 'boolean' }, + columnB: { columnType: 'string' }, + }, + renderCellValue: () => cell content, + interactiveCellId: 'someId', + inMemory: { level: 'enhancements' as any }, + inMemoryValues: {}, + handleHeaderMutation: jest.fn(), + setVisibleColumns: jest.fn(), + switchColumnPos: jest.fn(), + schemaDetectors, + rowHeightUtils: new RowHeightUtils(), + isFullScreen: false, + gridStyles: {}, + gridWidth: 300, + gridRef: { current: null }, + gridItemsRendered: { current: null }, + wrapperRef: { current: document.createElement('div') }, +}; - beforeAll(() => { - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { - configurable: true, - value: 34, - }); - }); - - it('renders', () => { - // EuiDataGridBody should be `render`ed here over `mount` due to large - // snapshot memory issues - const component = render(); - expect(component).toMatchSnapshot(); - expect(component.find('[data-test-subj="dataGridRowCell"]')).toHaveLength( - 2 - ); - }); - - it('renders leading columns, trailing columns, and footer rows', () => { - const component = mount( -
, - rowCellRender: () =>
, - width: 30, - }, - ]} - trailingControlColumns={[ - { - id: 'someTrailingColumn', - headerCellRender: () =>
, - rowCellRender: () =>
, - width: 40, - }, - ]} - visibleColCount={4} - renderFooterCellValue={() =>