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={() =>