diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index 611c8fb11a7..070ac54aa45 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -1,4 +1,4 @@ -import React, { Component, Fragment } from 'react'; +import React, { Component, Fragment, useEffect } from 'react'; import { fake } from 'faker'; import { @@ -124,10 +124,25 @@ export default class DataGridContainer extends Component { return ( data[rowIndex][columnId]} + renderCellValue={({ rowIndex, columnId, setCellProps }) => { + useEffect(() => { + if (columnId === 'amount') { + const numeric = parseFloat( + data[rowIndex][columnId].match(/\d+\.\d+/)[0], + 10 + ); + setCellProps({ + style: { + backgroundColor: `rgba(0, ${(numeric / 1000) * 255}, 0, 0.2)`, + }, + }); + } + }, [rowIndex, columnId, setCellProps]); + return data[rowIndex][columnId]; + }} sorting={{ columns: sortingColumns, onSort: this.setSorting }} pagination={{ ...pagination, diff --git a/src-docs/src/views/datagrid/datagrid_example.js b/src-docs/src/views/datagrid/datagrid_example.js index 2735a39f96f..1709908d7d5 100644 --- a/src-docs/src/views/datagrid/datagrid_example.js +++ b/src-docs/src/views/datagrid/datagrid_example.js @@ -17,9 +17,13 @@ import DataGridStyling from './styling'; const dataGridStylingSource = require('!!raw-loader!./styling'); const dataGridStylingHtml = renderToHtml(DataGridStyling); +import DataGridSchema from './schema'; +const dataGridSchemaSource = require('!!raw-loader!./schema'); +const dataGridSchemaHtml = renderToHtml(DataGridSchema); + import InMemoryDataGrid from './in_memory'; const inMemoryDataGridSource = require('!!raw-loader!./in_memory'); -const inMemoryDataGridHtml = renderToHtml(DataGridStyling); +const inMemoryDataGridHtml = renderToHtml(InMemoryDataGrid); export const DataGridExample = { title: 'Data grid', @@ -88,6 +92,24 @@ export const DataGridExample = { demo: , props: { EuiDataGrid }, }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridSchemaSource, + }, + { + type: GuideSectionTypes.HTML, + code: dataGridSchemaHtml, + }, + ], + title: 'Schema', + text: ( +

Column type information can be included on the column definition.

+ ), + components: { DataGridSchema }, + demo: , + }, { source: [ { diff --git a/src-docs/src/views/datagrid/in_memory.js b/src-docs/src/views/datagrid/in_memory.js index e171e9d167d..6b68a9943e7 100644 --- a/src-docs/src/views/datagrid/in_memory.js +++ b/src-docs/src/views/datagrid/in_memory.js @@ -1,249 +1,62 @@ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; +import { fake } from 'faker'; -import { - EuiDataGrid, - EuiButtonGroup, - EuiSpacer, - EuiFormRow, - EuiPopover, - EuiButton, - EuiButtonIcon, - EuiLink, -} from '../../../../src/components/'; -import { iconTypes } from '../icon/icons'; +import { EuiDataGrid, EuiLink } from '../../../../src/components/'; const columns = [ { id: 'name', }, { - id: 'avatar_url', + id: 'email', }, { - id: 'url', + id: 'location', }, { - id: 'contributions', + id: 'account', }, { - id: 'actions', - }, -]; - -const data = [ - { - name: 'cjcenizal', - avatar_url: 'https://avatars2.githubusercontent.com/u/1238659?v=4', - url: 'https://api.github.com/users/cjcenizal', - contributions: 392, - }, - { - name: 'snide', - avatar_url: 'https://avatars3.githubusercontent.com/u/324519?v=4', - url: 'https://api.github.com/users/snide', - contributions: 361, - }, - { - name: 'chandlerprall', - avatar_url: 'https://avatars3.githubusercontent.com/u/313125?v=4', - url: 'https://api.github.com/users/chandlerprall', - contributions: 274, - }, - { - name: 'cchaos', - avatar_url: 'https://avatars3.githubusercontent.com/u/549577?v=4', - url: 'https://api.github.com/users/cchaos', - contributions: 156, - }, - { - name: 'bevacqua', - avatar_url: 'https://avatars3.githubusercontent.com/u/934293?v=4', - url: 'https://api.github.com/users/bevacqua', - contributions: 128, - }, - { - name: 'thompsongl', - avatar_url: 'https://avatars0.githubusercontent.com/u/2728212?v=4', - url: 'https://api.github.com/users/thompsongl', - contributions: 106, - }, - { - name: 'pugnascotia', - avatar_url: 'https://avatars1.githubusercontent.com/u/8696382?v=4', - url: 'https://api.github.com/users/pugnascotia', - contributions: 82, - }, - { - name: 'nreese', - avatar_url: 'https://avatars0.githubusercontent.com/u/373691?v=4', - url: 'https://api.github.com/users/nreese', - contributions: 58, - }, - { - name: 'dmeiss', - avatar_url: 'https://avatars3.githubusercontent.com/u/45879454?v=4', - url: 'https://api.github.com/users/dmeiss', - contributions: 52, - }, - { - name: 'ryankeairns', - avatar_url: 'https://avatars2.githubusercontent.com/u/446285?v=4', - url: 'https://api.github.com/users/ryankeairns', - contributions: 32, - }, - { - name: 'stacey-gammon', - avatar_url: 'https://avatars3.githubusercontent.com/u/16563603?v=4', - url: 'https://api.github.com/users/stacey-gammon', - contributions: 24, - }, - { - name: 'theodesp', - avatar_url: 'https://avatars0.githubusercontent.com/u/328805?v=4', - url: 'https://api.github.com/users/theodesp', - contributions: 22, - }, - { - name: 'uboness', - avatar_url: 'https://avatars3.githubusercontent.com/u/211019?v=4', - url: 'https://api.github.com/users/uboness', - contributions: 17, - }, - { - name: 'weltenwort', - avatar_url: 'https://avatars3.githubusercontent.com/u/973741?v=4', - url: 'https://api.github.com/users/weltenwort', - contributions: 16, - }, - { - name: 'jen-huang', - avatar_url: 'https://avatars0.githubusercontent.com/u/1965714?v=4', - url: 'https://api.github.com/users/jen-huang', - contributions: 13, + id: 'date', }, { - name: 'PopradiArpad', - avatar_url: 'https://avatars3.githubusercontent.com/u/4144816?v=4', - url: 'https://api.github.com/users/PopradiArpad', - contributions: 11, + id: 'amount', }, { - name: 'chrisronline', - avatar_url: 'https://avatars1.githubusercontent.com/u/56682?v=4', - url: 'https://api.github.com/users/chrisronline', - contributions: 10, + id: 'phone', }, { - name: 'timroes', - avatar_url: 'https://avatars0.githubusercontent.com/u/877229?v=4', - url: 'https://api.github.com/users/timroes', - contributions: 10, - }, - { - name: 'daveyholler', - avatar_url: 'https://avatars2.githubusercontent.com/u/739960?v=4', - url: 'https://api.github.com/users/daveyholler', - contributions: 9, - }, - { - name: 'sqren', - avatar_url: 'https://avatars3.githubusercontent.com/u/209966?v=4', - url: 'https://api.github.com/users/sqren', - contributions: 9, + id: 'version', }, ]; +const data = []; + +for (let i = 1; i < 100; i++) { + data.push({ + name: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + email: {fake('{{internet.email}}')}, + location: ( + + {`${fake('{{address.city}}')}, `} + + {fake('{{address.country}}')} + + + ), + date: fake('{{date.past}}'), + account: fake('{{finance.account}}'), + amount: fake('{{finance.currencySymbol}}{{finance.amount}}'), + phone: fake('{{phone.phoneNumber}}'), + version: fake('{{system.semver}}'), + }); +} + export default class InMemoryDataGrid extends Component { constructor(props) { super(props); - this.borderOptions = [ - { - id: 'all', - label: 'All', - }, - { - id: 'horizontal', - label: 'Horizontal only', - }, - { - id: 'none', - label: 'None', - }, - ]; - - this.fontSizeOptions = [ - { - id: 's', - label: 'Small', - }, - { - id: 'm', - label: 'Medium', - }, - { - id: 'l', - label: 'Large', - }, - ]; - - this.cellPaddingOptions = [ - { - id: 's', - label: 'Small', - }, - { - id: 'm', - label: 'Medium', - }, - { - id: 'l', - label: 'Large', - }, - ]; - - this.stripeOptions = [ - { - id: 'true', - label: 'Stripes on', - }, - { - id: 'false', - label: 'Stripes off', - }, - ]; - - this.rowHoverOptions = [ - { - id: 'none', - label: 'None', - }, - { - id: 'highlight', - label: 'Highlight', - }, - ]; - - this.headerOptions = [ - { - id: 'shade', - label: 'Shade', - }, - { - id: 'underline', - label: 'Underline', - }, - ]; this.state = { - borderSelected: 'all', - fontSizeSelected: 'm', - cellPaddingSelected: 'm', - stripes: false, - stripesSelected: 'false', - rowHoverSelected: 'highlight', - isPopoverOpen: false, - headerSelected: 'shade', - data, sortingColumns: [{ id: 'contributions', direction: 'asc' }], @@ -254,55 +67,6 @@ export default class InMemoryDataGrid extends Component { }; } - onBorderChange = optionId => { - this.setState({ - borderSelected: optionId, - }); - }; - - onFontSizeChange = optionId => { - this.setState({ - fontSizeSelected: optionId, - }); - }; - - onCellPaddingChange = optionId => { - this.setState({ - cellPaddingSelected: optionId, - }); - }; - - onStripesChange = optionId => { - this.setState({ - stripesSelected: optionId, - stripes: !this.state.stripes, - }); - }; - - onRowHoverChange = optionId => { - this.setState({ - rowHoverSelected: optionId, - }); - }; - - onHeaderChange = optionId => { - this.setState({ - headerSelected: optionId, - }); - }; - - onPopoverButtonClick() { - this.setState({ - isPopoverOpen: !this.state.isPopoverOpen, - }); - } - - closePopover() { - this.setState({ - isPopoverOpen: false, - }); - } - setSorting = sortingColumns => this.setState({ sortingColumns }); setPageIndex = pageIndex => @@ -315,141 +79,27 @@ export default class InMemoryDataGrid extends Component { pagination: { ...pagination, pageSize }, })); - dummyIcon = () => ( - - ); - render() { const { data, pagination, sortingColumns } = this.state; - const button = ( - - Table styling - - ); - return ( -
- -
- - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - { - const value = data[rowIndex][columnId]; - - if (columnId === 'actions') { - return ( - <> - {this.dummyIcon()} - {this.dummyIcon()} - - ); - } - - if (columnId === 'url') { - return {value}; - } - - if (columnId === 'avatar_url') { - return ( -

- Avatar: {value} -

- ); - } - - return value; - }} - inMemory="sorting" - sorting={{ columns: sortingColumns, onSort: this.setSorting }} - pagination={{ - ...pagination, - pageSizeOptions: [5, 10, 25], - onChangeItemsPerPage: this.setPageSize, - onChangePage: this.setPageIndex, - }} - /> -
+ { + const value = data[rowIndex][columnId]; + return value; + }} + inMemory="sorting" + sorting={{ columns: sortingColumns, onSort: this.setSorting }} + pagination={{ + ...pagination, + pageSizeOptions: [5, 10, 25], + onChangeItemsPerPage: this.setPageSize, + onChangePage: this.setPageIndex, + }} + /> ); } } diff --git a/src-docs/src/views/datagrid/schema.js b/src-docs/src/views/datagrid/schema.js new file mode 100644 index 00000000000..200170927c4 --- /dev/null +++ b/src-docs/src/views/datagrid/schema.js @@ -0,0 +1,118 @@ +import React, { Component, Fragment } from 'react'; +import { fake } from 'faker'; + +import { + EuiDataGrid, + EuiButtonIcon, + EuiLink, +} from '../../../../src/components/'; +import { iconTypes } from '../icon/icons'; + +const columns = [ + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'location', + }, + { + id: 'account', + dataType: 'numeric', + }, + { + id: 'date', + }, + { + id: 'amount', + dataType: 'currency', + }, + { + id: 'phone', + }, + { + id: 'version', + }, +]; + +const data = []; + +for (let i = 1; i < 5; i++) { + data.push({ + name: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + email: {fake('{{internet.email}}')}, + location: ( + + {`${fake('{{address.city}}')}, `} + + {fake('{{address.country}}')} + + + ), + date: fake('{{date.past}}'), + account: fake('{{finance.account}}'), + amount: fake('${{finance.amount}}'), + phone: fake('{{phone.phoneNumber}}'), + version: fake('{{system.semver}}'), + }); +} + +export default class InMemoryDataGrid extends Component { + constructor(props) { + super(props); + + this.state = { + data, + sortingColumns: [{ id: 'contributions', direction: 'asc' }], + + pagination: { + pageIndex: 0, + pageSize: 10, + }, + }; + } + + setSorting = sortingColumns => this.setState({ sortingColumns }); + + setPageIndex = pageIndex => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageIndex }, + })); + + setPageSize = pageSize => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageSize }, + })); + + dummyIcon = () => ( + + ); + + render() { + const { data, pagination, sortingColumns } = this.state; + + return ( + { + const value = data[rowIndex][columnId]; + return value; + }} + sorting={{ columns: sortingColumns, onSort: this.setSorting }} + pagination={{ + ...pagination, + pageSizeOptions: [5, 10, 25], + onChangeItemsPerPage: this.setPageSize, + onChangePage: this.setPageIndex, + }} + /> + ); + } +} diff --git a/src/components/datagrid/_data_grid.scss b/src/components/datagrid/_data_grid.scss index 6106a0470a5..51050e37984 100644 --- a/src/components/datagrid/_data_grid.scss +++ b/src/components/datagrid/_data_grid.scss @@ -25,8 +25,10 @@ .euiDataGrid__content { @include euiScrollBar; - @include euiYScrollWithShadows; + @include euiScrollBar; + height: 100%; + overflow-y: auto; font-feature-settings: 'tnum' 1; // Tabular numbers overflow-x: auto; scroll-padding: 0; diff --git a/src/components/datagrid/_data_grid_data_row.scss b/src/components/datagrid/_data_grid_data_row.scss index b97e97c46bc..cb6a3c6ceca 100644 --- a/src/components/datagrid/_data_grid_data_row.scss +++ b/src/components/datagrid/_data_grid_data_row.scss @@ -18,6 +18,7 @@ // Hack to allow for all the focus guard stuff > * { max-width: 100%; + width: 100%; } &:first-of-type { @@ -41,6 +42,14 @@ // Needed because the focus state adds a border, which needs to be subtracted from padding padding-left: $euiDataGridCellPaddingM - 1px; } + + &.euiDataGridRowCell--numeric { + text-align: right; + } + + &.euiDataGridRowCell--currency { + text-align: right; + } } .euiDataGridRowCell__content { @@ -72,7 +81,8 @@ // Border alternates @include euiDataGridStyles(bordersNone) { @include euiDataGridRowCell { - border-color: transparent; + // sass-lint:disable-block no-important + border-color: transparent !important; } } diff --git a/src/components/datagrid/_data_grid_header_row.scss b/src/components/datagrid/_data_grid_header_row.scss index 5c8269d6071..a329f73ea0a 100644 --- a/src/components/datagrid/_data_grid_header_row.scss +++ b/src/components/datagrid/_data_grid_header_row.scss @@ -14,6 +14,14 @@ .euiDataGridHeaderCell__content { @include euiTextTruncate; } + + &.euiDataGridHeaderCell--numeric { + text-align: right; + } + + &.euiDataGridHeaderCell--currency { + text-align: right; + } } // Header alternates @@ -94,4 +102,4 @@ @include euiDataGridHeaderCell { padding: $euiDataGridCellPaddingL; } -} \ No newline at end of file +} diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index 4ebe1346f4b..70646e046a5 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -307,6 +307,244 @@ describe('EuiDataGrid', () => { expect($element.children().length).toBe(allCells.length); }); }); + + it('renders and applies custom props', () => { + const component = mount( + { + setCellProps({ + className: 'customClass', + 'data-test-subj': `cell-${rowIndex}-${columnId}`, + style: { color: columnId === 'A' ? 'red' : 'blue' }, + }); + + return `${rowIndex}, ${columnId}`; + }} + /> + ); + + expect( + component.find('.euiDataGridRowCell').map(cell => { + const props = cell.props(); + delete props.children; + return props; + }) + ).toMatchInlineSnapshot(` +Array [ + Object { + "className": "euiDataGridRowCell customClass", + "data-test-subj": "dataGridRowCell", + "onFocus": [Function], + "role": "gridcell", + "style": Object { + "color": "red", + "width": "100px", + }, + "tabIndex": 0, + }, + Object { + "className": "euiDataGridRowCell customClass", + "data-test-subj": "dataGridRowCell", + "onFocus": [Function], + "role": "gridcell", + "style": Object { + "color": "blue", + "width": "100px", + }, + "tabIndex": -1, + }, + Object { + "className": "euiDataGridRowCell customClass", + "data-test-subj": "dataGridRowCell", + "onFocus": [Function], + "role": "gridcell", + "style": Object { + "color": "red", + "width": "100px", + }, + "tabIndex": -1, + }, + Object { + "className": "euiDataGridRowCell customClass", + "data-test-subj": "dataGridRowCell", + "onFocus": [Function], + "role": "gridcell", + "style": Object { + "color": "blue", + "width": "100px", + }, + "tabIndex": -1, + }, +] +`); + }); + + describe('schema datatype classnames', () => { + it('applies classnames from explicit datatypes', () => { + const component = mount( + + `${rowIndex}, ${columnId}` + } + /> + ); + + const gridCellClassNames = component + .find('[className*="euiDataGridRowCell--"]') + .map(x => 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", +] +`); + }); + + it('automatically detects column types and applies classnames', () => { + const component = mount( + { + if (columnId === 'A') { + return 5.5; + } else if (columnId === 'B') { + return 'true'; + } else { + return 'asdf'; + } + }} + /> + ); + + const gridCellClassNames = component + .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", +] +`); + }); + + it('overrides automatically detected column types with supplied schema', () => { + const component = mount( + + columnId === 'A' ? 5.5 : 'true' + } + /> + ); + + const gridCellClassNames = component + .find('[className~="euiDataGridRowCell"]') + .map(x => x.props().className); + expect(gridCellClassNames).toMatchInlineSnapshot(` +Array [ + "euiDataGridRowCell euiDataGridRowCell--numeric", + "euiDataGridRowCell euiDataGridRowCell--alphanumeric", + "euiDataGridRowCell euiDataGridRowCell--numeric", + "euiDataGridRowCell euiDataGridRowCell--alphanumeric", +] +`); + }); + + it('detects all of the supported types', () => { + const values: { [key: string]: string } = { + A: '-5.80', + B: 'false', + C: '$-5.80', + E: '2019-09-18T12:31:28', + F: '2019-09-18T12:31:28Z', + G: '2019-09-18T12:31:28.234', + H: '2019-09-18T12:31:28.234+0300', + }; + const component = mount( + ({ id }))} + inMemory="pagination" + rowCount={1} + renderCellValue={({ columnId }) => values[columnId]} + /> + ); + + const gridCellClassNames = component + .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", +] +`); + }); + + it('accepts extra detectors', () => { + const values: { [key: string]: string } = { + A: '-5.80', + B: '127.0.0.1', + }; + const component = mount( + ({ id }))} + schemaDetectors={[ + { + type: 'ipaddress', + detector(value: string) { + return value.match(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/) + ? 1 + : 0; + }, + }, + ]} + inMemory="pagination" + rowCount={1} + renderCellValue={({ columnId }) => values[columnId]} + /> + ); + + const gridCellClassNames = component + .find('[className~="euiDataGridRowCell"]') + .map(x => x.props().className); + expect(gridCellClassNames).toMatchInlineSnapshot(` +Array [ + "euiDataGridRowCell euiDataGridRowCell--numeric", + "euiDataGridRowCell euiDataGridRowCell--ipaddress", +] +`); + }); + }); }); describe('cell rendering', () => { diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index f44d163520b..e7ead4bfea6 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -40,6 +40,11 @@ import { EuiFocusTrap } from '../focus_trap'; import { EuiResizeObserver } from '../observer/resize_observer'; import { CELL_CONTENTS_ATTR } from './utils'; import { EuiDataGridInMemoryRenderer } from './data_grid_inmemory_renderer'; +import { + getMergedSchema, + SchemaDetector, + useDetectSchema, +} from './data_grid_schema'; // When below this number the grid only shows the full screen button const MINIMUM_WIDTH_FOR_GRID_CONTROLS = 479; @@ -47,6 +52,7 @@ const MINIMUM_WIDTH_FOR_GRID_CONTROLS = 479; type CommonGridProps = CommonProps & HTMLAttributes & { columns: EuiDataGridColumn[]; + schemaDetectors?: SchemaDetector[]; rowCount: number; renderCellValue: EuiDataGridCellProps['renderCellValue']; gridStyle?: EuiDataGridStyle; @@ -376,6 +382,7 @@ export const EuiDataGrid: FunctionComponent = props => { const { columns, + schemaDetectors, rowCount, renderCellValue, className, @@ -424,6 +431,13 @@ export const EuiDataGrid: FunctionComponent = props => { const [inMemoryValues, onCellRender] = useInMemoryValues(); + const detectedSchema = useDetectSchema( + inMemoryValues, + schemaDetectors, + inMemory !== false + ); + const mergedSchema = getMergedSchema(detectedSchema, columns); + // These grid controls will only show when there is room. Check the resize observer callback const gridControls = ( @@ -514,6 +528,7 @@ export const EuiDataGrid: FunctionComponent = props => { columnWidths={columnWidths} defaultColumnWidth={defaultColumnWidth} setColumnWidth={setColumnWidth} + schema={mergedSchema} /> = props => { inMemoryValues={inMemoryValues} inMemory={inMemory} columns={visibleColumns} + schema={mergedSchema} focusedCell={focusedCell} onCellFocus={setFocusedCell} pagination={pagination} diff --git a/src/components/datagrid/data_grid_body.tsx b/src/components/datagrid/data_grid_body.tsx index 2d0ff590c1a..6d5a1c04af0 100644 --- a/src/components/datagrid/data_grid_body.tsx +++ b/src/components/datagrid/data_grid_body.tsx @@ -17,11 +17,13 @@ import { EuiDataGridDataRow, EuiDataGridDataRowProps, } from './data_grid_data_row'; +import { EuiDataGridSchema } from './data_grid_schema'; interface EuiDataGridBodyProps { columnWidths: EuiDataGridColumnWidths; defaultColumnWidth?: number | null; columns: EuiDataGridColumn[]; + schema: EuiDataGridSchema; focusedCell: EuiDataGridDataRowProps['focusedCell']; onCellFocus: EuiDataGridDataRowProps['onCellFocus']; rowCount: number; @@ -41,6 +43,7 @@ export const EuiDataGridBody: FunctionComponent< columnWidths, defaultColumnWidth, columns, + schema, focusedCell, onCellFocus, rowCount, @@ -137,6 +140,7 @@ export const EuiDataGridBody: FunctionComponent< ) => void; } export interface EuiDataGridCellProps { rowIndex: number; colIndex: number; columnId: string; + columnType?: string | null; width?: number; isFocusable: boolean; onCellFocus: Function; @@ -31,7 +35,9 @@ export interface EuiDataGridCellProps { | ((props: CellValueElementProps) => ReactNode); } -interface EuiDataGridCellState {} +interface EuiDataGridCellState { + cellProps: CommonProps & HTMLAttributes; +} type EuiDataGridCellValueProps = Omit< EuiDataGridCellProps, @@ -39,7 +45,9 @@ type EuiDataGridCellValueProps = Omit< >; const EuiDataGridCellContent: FunctionComponent< - EuiDataGridCellValueProps + EuiDataGridCellValueProps & { + setCellProps: CellValueElementProps['setCellProps']; + } > = memo(props => { const { renderCellValue, ...rest } = props; @@ -59,6 +67,9 @@ export class EuiDataGridCell extends Component< > { cellRef = createRef(); cellContentsRef = createRef(); + state: EuiDataGridCellState = { + cellProps: {}, + }; isInteractiveCell() { const cellContents = this.cellContentsRef.current; @@ -173,12 +184,17 @@ export class EuiDataGridCell extends Component< return false; } + setCellProps = (cellProps: HTMLAttributes) => { + this.setState({ cellProps }); + }; + render() { const { width, isFocusable, isGridNavigationEnabled, interactiveCellId, + columnType, ...rest } = this.props; const { colIndex, rowIndex, onCellFocus } = rest; @@ -187,16 +203,35 @@ export class EuiDataGridCell extends Component< [CELL_CONTENTS_ATTR]: isInteractive, }; + const className = classnames('euiDataGridRowCell', { + [`euiDataGridRowCell--${columnType}`]: columnType, + }); + + const cellProps = { + ...this.state.cellProps, + 'data-test-subj': classnames( + 'dataGridRowCell', + this.state.cellProps['data-test-subj'] + ), + className: classnames(className, this.state.cellProps.className), + }; + + const widthStyle = width != null ? { width: `${width}px` } : {}; + if (cellProps.hasOwnProperty('style')) { + cellProps.style = { ...cellProps.style, ...widthStyle }; + } else { + cellProps.style = widthStyle; + } + return (
onCellFocus([colIndex, rowIndex])} - style={width != null ? { width: `${width}px` } : {}}> + onFocus={() => onCellFocus([colIndex, rowIndex])}> { @@ -213,7 +248,11 @@ export class EuiDataGridCell extends Component< {...isInteractiveCell} ref={this.cellContentsRef} className="euiDataGridRowCell__content"> - +
)} diff --git a/src/components/datagrid/data_grid_data_row.tsx b/src/components/datagrid/data_grid_data_row.tsx index 4a0aa1525de..c38d31f23dc 100644 --- a/src/components/datagrid/data_grid_data_row.tsx +++ b/src/components/datagrid/data_grid_data_row.tsx @@ -4,11 +4,13 @@ import { EuiDataGridColumn, EuiDataGridColumnWidths } from './data_grid_types'; import { CommonProps } from '../common'; import { EuiDataGridCell, EuiDataGridCellProps } from './data_grid_cell'; +import { EuiDataGridSchema } from './data_grid_schema'; export type EuiDataGridDataRowProps = CommonProps & HTMLAttributes & { rowIndex: number; columns: EuiDataGridColumn[]; + schema: EuiDataGridSchema; columnWidths: EuiDataGridColumnWidths; defaultColumnWidth?: number | null; focusedCell: [number, number]; @@ -24,6 +26,7 @@ const EuiDataGridDataRow: FunctionComponent< > = props => { const { columns, + schema, columnWidths, defaultColumnWidth, className, @@ -57,6 +60,7 @@ const EuiDataGridDataRow: FunctionComponent< rowIndex={rowIndex} colIndex={i} columnId={id} + columnType={schema[id] ? schema[id].columnType : null} width={width || undefined} renderCellValue={renderCellValue} onCellFocus={onCellFocus} diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index e415e7ca6d4..3538aefbfe5 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -9,11 +9,13 @@ import { CommonProps } from '../common'; import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; import { htmlIdGenerator } from '../../services/accessibility'; import { EuiScreenReaderOnly } from '../accessibility'; +import { EuiDataGridSchema } from './data_grid_schema'; type EuiDataGridHeaderRowProps = CommonProps & HTMLAttributes & { columns: EuiDataGridColumn[]; columnWidths: EuiDataGridColumnWidths; + schema: EuiDataGridSchema; defaultColumnWidth?: number | null; setColumnWidth: (columnId: string, width: number) => void; sorting?: EuiDataGridSorting; @@ -24,6 +26,7 @@ const EuiDataGridHeaderRow: FunctionComponent< > = props => { const { columns, + schema, columnWidths, defaultColumnWidth, className, @@ -77,12 +80,18 @@ const EuiDataGridHeaderRow: FunctionComponent< } } + const columnType = schema[id] ? schema[id].columnType : null; + + const classes = classnames('euiDataGridHeaderCell', { + [`euiDataGridHeaderCell--${columnType}`]: columnType, + }); + return (
{width ? ( diff --git a/src/components/datagrid/data_grid_inmemory_renderer.tsx b/src/components/datagrid/data_grid_inmemory_renderer.tsx index a666c7747a3..64b8cf1e400 100644 --- a/src/components/datagrid/data_grid_inmemory_renderer.tsx +++ b/src/components/datagrid/data_grid_inmemory_renderer.tsx @@ -22,6 +22,8 @@ interface EuiDataGridInMemoryRendererProps { ) => void; } +function noop() {} + export const EuiDataGridInMemoryRenderer: FunctionComponent< EuiDataGridInMemoryRendererProps > = ({ columns, rowCount, renderCellValue, onCellRender }) => { @@ -48,7 +50,11 @@ export const EuiDataGridInMemoryRenderer: FunctionComponent< }, [text]); return (
- +
); }} diff --git a/src/components/datagrid/data_grid_schema.ts b/src/components/datagrid/data_grid_schema.ts new file mode 100644 index 00000000000..9dcc26f1b2e --- /dev/null +++ b/src/components/datagrid/data_grid_schema.ts @@ -0,0 +1,225 @@ +import { useMemo } from 'react'; +import { + EuiDataGridColumn, + EuiDataGridInMemoryValues, +} from './data_grid_types'; + +export interface SchemaDetector { + type: string; + detector: (value: string) => number; +} + +const schemaDetectors: SchemaDetector[] = [ + { + type: 'boolean', + detector(value: string) { + return value === 'true' || value === 'false' ? 1 : 0; + }, + }, + { + type: 'currency', + detector(value: string) { + const matchLength = (value.match( + // currency prefers starting with 1-3 characters for the currency symbol + // then it matches against numerical data + $ + /(^[^-(]{1,3})?[$-(]*[\d,]+(\.\d*)?[$)]*/ + ) || [''])[0].length; + + // if there is no currency symbol then reduce the score + const hasCurrency = value.indexOf('$') !== -1; + const confidenceAdjustment = hasCurrency ? 1 : 0.95; + + return (matchLength / value.length) * confidenceAdjustment || 0; + }, + }, + { + type: 'datetime', + detector(value: string) { + // matches the most common forms of ISO-8601 + const isoTimestampMatch = value.match( + // 2019 - 09 - 17 T 12 : 18 : 32 .853 Z or -0600 + /^\d{2,4}-\d{1,2}-\d{1,2}(T?\d{1,2}:\d{1,2}:\d{1,2}(\.\d{3})?(Z|[+-]\d{4})?)?/ + ); + + // matches 9 digits (seconds) or 13 digits (milliseconds) since unix epoch + const unixTimestampMatch = value.match(/^(\d{9}|\d{13})$/); + + const isoMatchLength = isoTimestampMatch + ? isoTimestampMatch[0].length + : 0; + + // reduce the confidence of a unix timestamp match to 75% + // (a column of all unix timestamps should be numeric instead) + const unixMatchLength = unixTimestampMatch + ? unixTimestampMatch[0].length * 0.75 + : 0; + + return Math.max(isoMatchLength, unixMatchLength) / value.length || 0; + }, + }, + { + type: 'numeric', + detector(value: string) { + const matchLength = (value.match(/[%-(]*[\d,]+(\.\d*)?[%)]*/) || [''])[0] + .length; + return matchLength / value.length || 0; + }, + }, +]; + +export interface EuiDataGridSchema { + [columnId: string]: { columnType: string | null }; +} + +interface SchemaTypeScore { + type: string; + score: number; +} + +function scoreValueBySchemaType( + value: string, + extraSchemaDetectors: SchemaDetector[] = [] +) { + const scores: SchemaTypeScore[] = []; + const detectors = [...schemaDetectors, ...extraSchemaDetectors]; + + for (let i = 0; i < detectors.length; i++) { + const { type, detector } = detectors[i]; + const score = detector(value); + scores.push({ type, score }); + } + + return scores; +} + +// completely arbitrary minimum match I came up with +// represents lowest score a type detector can have to be considered valid +const MINIMUM_SCORE_MATCH = 0.5; + +export function useDetectSchema( + inMemoryValues: EuiDataGridInMemoryValues, + schemaDetectors: SchemaDetector[] | undefined, + autoDetectSchema: boolean +) { + const schema = useMemo(() => { + const schema: EuiDataGridSchema = {}; + if (autoDetectSchema === false) { + return schema; + } + + const columnSchemas: { + [columnId: string]: { [type: string]: number[] }; + } = {}; + + // for each row, score each value by each detector and put the results on `columnSchemas` + const rowIndices = Object.keys(inMemoryValues); + for (let i = 0; i < rowIndices.length; i++) { + const rowIndex = rowIndices[i]; + const rowData = inMemoryValues[rowIndex]; + const columnIds = Object.keys(rowData); + + for (let j = 0; j < columnIds.length; j++) { + const columnId = columnIds[j]; + + const schemaColumn = (columnSchemas[columnId] = + columnSchemas[columnId] || {}); + + const columnValue = rowData[columnId].trim(); + const valueScores = scoreValueBySchemaType( + columnValue, + schemaDetectors + ); + + for (let k = 0; k < valueScores.length; k++) { + const valueScore = valueScores[k]; + if (schemaColumn.hasOwnProperty(valueScore.type)) { + const existingScore = schemaColumn[valueScore.type]; + existingScore.push(valueScore.score); + } else { + // first entry for this column + schemaColumn[valueScore.type] = [valueScore.score]; + } + } + } + } + + // for each column, reduce each detector type's score to a single value and find the best fit + return Object.keys(columnSchemas).reduce( + (schema, columnId) => { + const columnScores = columnSchemas[columnId]; + const typeIds = Object.keys(columnScores); + + const typeSummaries: { + [type: string]: { + mean: number; + sd: number; + }; + } = {}; + + let bestType = null; + let bestScore = 0; + + for (let i = 0; i < typeIds.length; i++) { + const typeId = typeIds[i]; + + const typeScores = columnScores[typeId]; + + // find the mean + let totalScore = 0; + for (let j = 0; j < typeScores.length; j++) { + const score = typeScores[j]; + totalScore += score; + } + const mean = totalScore / typeScores.length; + + // compute standard deviation + let sdSum = 0; + for (let j = 0; j < typeScores.length; j++) { + const score = typeScores[j]; + sdSum += (score - mean) * (score - mean); + } + const sd = Math.sqrt(sdSum / typeScores.length); + + const summary = { mean, sd }; + + // the mean-standard_deviation calculation is fairly arbitrary but fits the patterns I've thrown at it + // it is meant to represent the scores' average and distribution + const score = summary.mean - summary.sd; + if (score > MINIMUM_SCORE_MATCH) { + if (bestType == null || score > bestScore) { + bestType = typeId; + bestScore = score; + } + } + + typeSummaries[typeId] = summary; + } + schema[columnId] = { columnType: bestType }; + + return schema; + }, + {} + ); + }, [inMemoryValues]); + return schema; +} + +export function getMergedSchema( + detectedSchema: EuiDataGridSchema, + columns: EuiDataGridColumn[] +) { + const mergedSchema = { ...detectedSchema }; + + for (let i = 0; i < columns.length; i++) { + const { id, dataType } = columns[i]; + if (dataType != null) { + if (detectedSchema.hasOwnProperty(id)) { + mergedSchema[id] = { ...detectedSchema[id], columnType: dataType }; + } else { + mergedSchema[id] = { columnType: dataType }; + } + } + } + + return mergedSchema; +} diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index d52a213d609..5691e1bf472 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -1,5 +1,9 @@ +import { EuiDataGridSchema } from './data_grid_schema'; + export interface EuiDataGridColumn { id: string; + // allow devs to pass arbitrary dataType strings, but internally keep the code matching against the known types + dataType?: EuiDataGridSchema['*']['columnType']; } export interface EuiDataGridColumnWidths { @@ -50,7 +54,7 @@ service / in-memory boundary can be used. Thus there are four states for in-memo * "filtering" - all operations are performed in-memory, no service calls */ export type EuiDataGridInMemory = - | false + | boolean | 'pagination' | 'sorting' | 'filtering';