From b64d503e95837084417fc9005e663c7d8b145525 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 24 Oct 2019 08:42:18 -0600 Subject: [PATCH] [WIP] EuiDataGrid (#2165) * initial datagrid * protect against re-rendering content on column width change * Added unit initial tests for EuiDataGrid * [EuiDataGrid] Base layer Sass (#2171) * Initial css fixes * works in IE, addresses feedback * remove user select * Adds basic aria roles and grid navigation (#2187) * Adds basic aria roles and grid navigation Co-Authored-By: Chandler Prall * [Data Grid] Grid style options (#2176) Adds basic styling to the data grid. * Add pagination to EuiDataGrid (#2188) * Added pagination to to EuiDataGrid * Move EuiDataGrid row rendering to a sub-component to clean up state management * EuiDataGrid pagination unit tests * fix data grid pagination * revert colors * EuiDataGrid column visibility & ordering (#2207) * Show/hide and re-order datagrid columns * Column visability & ordering tests * column styling * column sizing and bars * blergh * tests * feedback * Fix linting * Adds more complex focus control for DataGrid (#2222) * [Data Grid] - Styling built into data grid, full screen mode (#2230) Styling built into data grid, full screen mode * EuiDataGrid add column sorting (#2278) * API interface for providing column sort order, callback to allow external data sorting * EuiDataGrid renders content into memory, sorts on it * Added tests for EuiDataGrid sorting * Added aria-sort value to a singly-sorted column header * small cleanup * add tests back in, though they are still broken * Clean up some keyboard navigation issues * Fix column sorting & update snapshots * EuiDataGrid hooks cleanup (#2331) * Refactored EuiDataGrid's hooks * Fix datagrid to react to gridStyle changes * [EuiDataGrid] Automatic column schema detection (#2351) * Automatically detect data schema for in-memory datagrid * Merge in described schema for field formatting * Better column type detection * Tests for euidatagrid schema / column type * refactor datagrid schema code, add datetime type detection * some comments * Allow extra type detectors for EuiDataGrid * cleanup of docs and type formatting * Fix datagrid unit test * Update currency detector * Allow EuiDataGrid's inMemory prop to be {true} * Added ability to provide extra props for the containing cell div * Added test for cell props * [EuiDataGrid] InMemory Performance (#2374) * Automatically detect data schema for in-memory datagrid * Merge in described schema for field formatting * Better column type detection * Tests for euidatagrid schema / column type * refactor datagrid schema code, add datetime type detection * some comments * Allow extra type detectors for EuiDataGrid * cleanup of docs and type formatting * Fix datagrid unit test * Update currency detector * Allow EuiDataGrid's inMemory prop to be {true} * Added ability to provide extra props for the containing cell div * Added test for cell props * Performance cleanups * Clean up datagrid doc's inMemory selection * Merged in feature branch * EuiDataGrid in-memory options * Performance refactor for in-memory values * added a comment * Fix sorting on in-memory and schema datagrid docs * [EuiDataGrid] Feature/euidatagrid menu ux (#2392) Moved the sorting mechanism to the top toolbar. * Export useRenderToText to top-level package (#2412) * [DATA GRID] Expand cells (#2418) Data grid cells now can expand and can render individually based upon their schema. * [EuiDataGrid] use schema information when sorting (#2419) * cell expansion working mostly * fix double import * add search to field selector * euitext * cell epansion is now optional through a config * keydown event for cells * remove tabbables * Clean up some code & tests * Remove unused line of code * Center popover against cell * Update euidatagridcell popover placement, trigger, dom structure, and auto focusing * Restore focus to grid cell when popover was in response to mouse click * Allow grid column selection to be searchable * Refactor expansion popover formatting, allow custom ones * schema-based sort comparators * reverse boolean sort to be true-false * adds json schema sorting, fixes issue with popover * Weaken the currency type detector when values have a period in their first few characters, and fix test * Move column order and visibility to be managed externally (#2422) * fix tests * [EuiDataGrid] Custom column headers (#2421) * Allow custom ReactNode for column header display * Allow navigation into grid headers if any are interactive * Properly wrap cell focus and use [enter], [f2] to interact * Corrected header cell focus-state on blurring, [escape]. and single interactives * Corrected header cell focus-state on blurring, [escape]. and single interactives * When datagrid header is interactive, default its tabstop to the first header cell * EuiDataGridHeaderCell warns about multiple interactive elements * fix focus, example and screenreader stuffs, looks like tests pass * simplifying screen reader read out * [DATA GRID] EuiGridToolBar toolbar is now configurable through props (#2443) * EuiGridToolBar toolbar is now configurable through props * better tests * small test typp * Update src/components/datagrid/data_grid_types.ts Co-Authored-By: Greg Thompson * feedback * [EuiDataGrid] Docs and autodocs (#2449) * Render out EuiDataGrid proptypes * Add pagination props to docs * Fill out all datagrid autodoc sections * remove debugger statement * Update src/components/datagrid/data_grid_types.ts Co-Authored-By: Greg Thompson * words * docs start * datatype renamed to schema, update docs * docs, fix typo for fullscreen buton * core concepts * better in memory explanation * custom schema example * provide a nice, documented snippet * typos * don't show pagination when only one page * clean up styling, better docs for formatters * more docs cleanup * IE fix * IE fix again * small cleanup of docs * describe how to disable expansion popovers * dark mode tweaks * Fix custom datatype sorting * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * Update src-docs/src/views/datagrid/datagrid_example.js Co-Authored-By: Michail Yasonik * PR feedback * typo * feedback to break up docs * better cross linking and summary * fix custom schema display * Update src-docs/src/views/datagrid/datagrid_memory_example.js Co-Authored-By: Greg Thompson * Update src-docs/src/views/datagrid/datagrid_memory_example.js Co-Authored-By: Greg Thompson * Update src-docs/src/views/datagrid/datagrid_memory_example.js Co-Authored-By: Greg Thompson * Update src-docs/src/views/datagrid/datagrid_schema_example.js Co-Authored-By: Greg Thompson * Update src-docs/src/views/datagrid/datagrid_memory_example.js Co-Authored-By: Greg Thompson * Update src-docs/src/views/datagrid/datagrid_memory_example.js Co-Authored-By: Greg Thompson * Update src-docs/src/views/datagrid/datagrid_memory_example.js Co-Authored-By: Greg Thompson * Updated some datagrid docs * main dg example page feedback * Eui prefix all the things to be consistant. Adjust the data grid docs to match * rewrite intro based on feedback * more tweaking of words * rename toolbarDisplay->toolbarVisibility * in memory docs reworked to four examples * clean up core example * data grid styling snippets * fix prop list * Minor grammar edits * Added isDetails prop to renderCellValue, reducing the use case for expansionFormatters. Speaking of those, expansionFormatter(s) has been renamed to popoverContent(s) and now recieve the rendered cell div in addition to the renderCellValue ReactElement * fix docs renaming, fix css * last docs edit seems fitting * somewhat decent attempt at putting classnames on schemas * Revert "somewhat decent attempt at putting classnames on schemas" This reverts commit 26542d7a9156f138c12b3c1b9db58e9d8443447f. * changelog --- .eslintrc.js | 6 +- CHANGELOG.md | 19 +- package.json | 4 +- .../babel/proptypes-from-ts-props/index.js | 150 +- .../proptypes-from-ts-props/index.test.js | 49 + src-docs/src/components/guide_components.scss | 8 + .../guide_section/_guide_section.scss | 10 - .../components/guide_section/guide_section.js | 31 +- src-docs/src/routes.js | 18 +- src-docs/src/views/datagrid/container.js | 95 + src-docs/src/views/datagrid/datagrid.js | 160 ++ .../src/views/datagrid/datagrid_example.js | 365 ++++ .../views/datagrid/datagrid_memory_example.js | 249 +++ .../views/datagrid/datagrid_schema_example.js | 101 + .../datagrid/datagrid_styling_example.js | 112 ++ src-docs/src/views/datagrid/in_memory.js | 135 ++ .../views/datagrid/in_memory_enhancements.js | 138 ++ .../views/datagrid/in_memory_pagination.js | 120 ++ .../src/views/datagrid/in_memory_sorting.js | 106 + src-docs/src/views/datagrid/props.tsx | 51 + src-docs/src/views/datagrid/schema.js | 226 +++ src-docs/src/views/datagrid/styling.js | 576 ++++++ src-docs/src/views/icon/icons.js | 6 +- .../pagination/customizable_pagination.js | 1 - .../tables/expanding_rows/expanding_rows.js | 2 +- src-docs/src/views/tables/sorting/sorting.js | 4 +- src-docs/webpack.config.js | 6 +- .../in_memory_table.test.js.snap | 5 +- .../button/button_group/button_group.tsx | 4 +- .../button/button_icon/button_icon.tsx | 7 +- .../button/button_toggle/button_toggle.tsx | 12 +- .../__snapshots__/data_grid.test.tsx.snap | 1266 ++++++++++++ src/components/datagrid/_data_grid.scss | 104 + .../datagrid/_data_grid_column_resizer.scss | 48 + .../datagrid/_data_grid_column_selector.scss | 26 + .../datagrid/_data_grid_column_sorting.scss | 56 + .../datagrid/_data_grid_data_row.scss | 207 ++ .../datagrid/_data_grid_header_row.scss | 128 ++ src/components/datagrid/_index.scss | 8 + src/components/datagrid/_mixins.scss | 53 + src/components/datagrid/_variables.scss | 6 + src/components/datagrid/column_selector.tsx | 209 ++ src/components/datagrid/column_sorting.tsx | 274 +++ .../datagrid/column_sorting_draggable.tsx | 180 ++ src/components/datagrid/data_grid.test.tsx | 1716 +++++++++++++++++ src/components/datagrid/data_grid.tsx | 665 +++++++ src/components/datagrid/data_grid_body.tsx | 238 +++ src/components/datagrid/data_grid_cell.tsx | 369 ++++ .../datagrid/data_grid_column_resizer.tsx | 72 + .../datagrid/data_grid_data_row.tsx | 94 + .../datagrid/data_grid_header_row.tsx | 308 +++ .../datagrid/data_grid_inmemory_renderer.tsx | 154 ++ src/components/datagrid/data_grid_schema.tsx | 398 ++++ src/components/datagrid/data_grid_types.ts | 164 ++ src/components/datagrid/index.ts | 1 + src/components/datagrid/style_selector.tsx | 128 ++ src/components/drag_and_drop/draggable.tsx | 6 +- src/components/drag_and_drop/services.ts | 8 +- src/components/focus_trap/focus_trap.tsx | 15 +- src/components/form/_variables.scss | 4 + .../__snapshots__/super_select.test.js.snap | 27 +- .../form/super_select/super_select.test.js | 5 + src/components/form/switch/_switch.scss | 49 + src/components/i18n/i18n.tsx | 27 +- .../icon/__snapshots__/icon.test.tsx.snap | 61 + src/components/icon/assets/expandMini.js | 17 + src/components/icon/assets/expandMini.svg | 3 + .../icon/assets/table_density_compact.js | 14 + .../icon/assets/table_density_compact.svg | 3 + .../icon/assets/table_density_expanded.js | 14 + .../icon/assets/table_density_expanded.svg | 3 + .../icon/assets/table_density_normal.js | 14 + .../icon/assets/table_density_normal.svg | 3 + src/components/icon/icon.tsx | 4 + src/components/index.js | 4 +- src/components/index.scss | 3 +- src/components/inner_text/index.ts | 1 + src/components/inner_text/inner_text.tsx | 4 +- .../inner_text/render_to_text.test.tsx | 36 + src/components/inner_text/render_to_text.tsx | 21 + .../mutation_observer/mutation_observer.ts | 8 +- .../__snapshots__/popover.test.tsx.snap | 27 +- src/components/popover/_popover_footer.scss | 2 +- src/components/popover/popover.test.tsx | 5 + src/components/popover/popover.tsx | 59 +- .../selectable_list.test.tsx.snap | 28 +- .../selectable_list/selectable_list.test.tsx | 3 +- .../table_pagination.test.tsx.snap | 2 +- .../table_pagination/table_pagination.tsx | 2 +- src/global_styling/mixins/_states.scss | 7 +- src/global_styling/variables/_colors.scss | 2 +- src/services/key_codes.ts | 2 + src/test/find_test_subject.ts | 3 +- src/themes/eui/eui_colors_dark.scss | 2 +- yarn.lock | 18 +- 95 files changed, 10040 insertions(+), 124 deletions(-) create mode 100644 src-docs/src/views/datagrid/container.js create mode 100644 src-docs/src/views/datagrid/datagrid.js create mode 100644 src-docs/src/views/datagrid/datagrid_example.js create mode 100644 src-docs/src/views/datagrid/datagrid_memory_example.js create mode 100644 src-docs/src/views/datagrid/datagrid_schema_example.js create mode 100644 src-docs/src/views/datagrid/datagrid_styling_example.js create mode 100644 src-docs/src/views/datagrid/in_memory.js create mode 100644 src-docs/src/views/datagrid/in_memory_enhancements.js create mode 100644 src-docs/src/views/datagrid/in_memory_pagination.js create mode 100644 src-docs/src/views/datagrid/in_memory_sorting.js create mode 100644 src-docs/src/views/datagrid/props.tsx create mode 100644 src-docs/src/views/datagrid/schema.js create mode 100644 src-docs/src/views/datagrid/styling.js create mode 100644 src/components/datagrid/__snapshots__/data_grid.test.tsx.snap create mode 100644 src/components/datagrid/_data_grid.scss create mode 100644 src/components/datagrid/_data_grid_column_resizer.scss create mode 100644 src/components/datagrid/_data_grid_column_selector.scss create mode 100644 src/components/datagrid/_data_grid_column_sorting.scss create mode 100644 src/components/datagrid/_data_grid_data_row.scss create mode 100644 src/components/datagrid/_data_grid_header_row.scss create mode 100644 src/components/datagrid/_index.scss create mode 100644 src/components/datagrid/_mixins.scss create mode 100644 src/components/datagrid/_variables.scss create mode 100644 src/components/datagrid/column_selector.tsx create mode 100644 src/components/datagrid/column_sorting.tsx create mode 100644 src/components/datagrid/column_sorting_draggable.tsx create mode 100644 src/components/datagrid/data_grid.test.tsx create mode 100644 src/components/datagrid/data_grid.tsx create mode 100644 src/components/datagrid/data_grid_body.tsx create mode 100644 src/components/datagrid/data_grid_cell.tsx create mode 100644 src/components/datagrid/data_grid_column_resizer.tsx create mode 100644 src/components/datagrid/data_grid_data_row.tsx create mode 100644 src/components/datagrid/data_grid_header_row.tsx create mode 100644 src/components/datagrid/data_grid_inmemory_renderer.tsx create mode 100644 src/components/datagrid/data_grid_schema.tsx create mode 100644 src/components/datagrid/data_grid_types.ts create mode 100644 src/components/datagrid/index.ts create mode 100644 src/components/datagrid/style_selector.tsx create mode 100644 src/components/icon/assets/expandMini.js create mode 100644 src/components/icon/assets/expandMini.svg create mode 100644 src/components/icon/assets/table_density_compact.js create mode 100644 src/components/icon/assets/table_density_compact.svg create mode 100644 src/components/icon/assets/table_density_expanded.js create mode 100644 src/components/icon/assets/table_density_expanded.svg create mode 100644 src/components/icon/assets/table_density_normal.js create mode 100644 src/components/icon/assets/table_density_normal.svg create mode 100644 src/components/inner_text/render_to_text.test.tsx create mode 100644 src/components/inner_text/render_to_text.tsx diff --git a/.eslintrc.js b/.eslintrc.js index f599c2968bd..2f24a0c65ef 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,7 +30,8 @@ module.exports = { plugins: [ "jsx-a11y", "prettier", - "local" + "local", + "react-hooks" ], rules: { "prefer-template": "error", @@ -64,6 +65,9 @@ module.exports = { "jsx-a11y/tabindex-no-positive": "error", "jsx-a11y/label-has-associated-control": "error", + // "react-hooks/rules-of-hooks": "error", + // "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/array-type": ["error", "array-simple"], "@typescript-eslint/camelcase": "off", "@typescript-eslint/class-name-casing": "off", diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fb9011740..98a4f575efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ ## [`master`](https://github.com/elastic/eui/tree/master) -No public interface changes since `14.7.0`. +* `EuiButtonGroup` and `EuiButtonToggle` now accept `ReactNode` for their label prop instead of string ([#2392](https://github.com/elastic/eui/pull/2392)) +* Added `useRenderToText` to `inner_text` service suite to render `ReactNode`s into label text ([#2392](https://github.com/elastic/eui/pull/2392)) +* Added icons `tableDensityExpanded`, `tableDensityCompact`, `tableDensityNormal` to `EuiIcon` ([#2230](https://github.com/elastic/eui/pull/2230)) +* Added `!important` to the animation of `EuiFocusRing` animation to make sure it is always used ([#2230](https://github.com/elastic/eui/pull/2230)) +* Added `expandMini` icon to `EuiIcon` ([#2207](https://github.com/elastic/eui/pull/2366)) +* Changed `EuiPopover` to use `role="dialog"` for better screen-reader announcements ([#2207](https://github.com/elastic/eui/pull/2366)) +* Added function callback `onTrapDeactivation` to `EuiPopover` for when a focus trap is deactivated ([#2366](https://github.com/elastic/eui/pull/2366)) +* Added logic for rendering of focus around `EuiPopover` to counteract a race condition ([#2366](https://github.com/elastic/eui/pull/2366)) +* Added `EuiDataGrid` ([#2165](https://github.com/elastic/eui/pull/2165)) + +**Bug fixes** + +* Corrected `lockProps` passdown in `EuiFocusTrap`, specifically to allows `style` to be passed down. ([#2230](https://github.com/elastic/eui/pull/2230)) +* Changed `children` property on `I18nTokensShape` type from a single `ReactChild` to now accept an `array` ([#2230](https://github.com/elastic/eui/pull/2230)) +* Adjusted the color of `$euiColorHighlight` in dark mode ([#2176](https://github.com/elastic/eui/pull/2176)) +* Changed `EuiPopoverFooter` padding to uniformly adjust with the size of the popover ([#2207](https://github.com/elastic/eui/pull/2207)) +* Fixed `isDragDisabled` prop usage in `EuiDraggable` ([#2207](https://github.com/elastic/eui/pull/2366)) +* Fixed `EuiMutationObserver`'s handling of`onMutation` when that prop's value changes ([#2421](https://github.com/elastic/eui/pull/2421)) ## [`14.7.0`](https://github.com/elastic/eui/tree/v14.7.0) diff --git a/package.json b/package.json index a0a27eb7715..9bb2358c94b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "react-is": "~16.3.0", "react-virtualized": "^9.18.5", "resize-observer-polyfill": "^1.5.0", - "tabbable": "^1.1.0", + "tabbable": "^3.0.0", "uuid": "^3.1.0" }, "devDependencies": { @@ -130,6 +130,8 @@ "eslint-plugin-prefer-object-spread": "^1.2.1", "eslint-plugin-prettier": "^3.1.0", "eslint-plugin-react": "^7.13.0", + "eslint-plugin-react-hooks": "^2.0.1", + "faker": "^4.1.0", "file-loader": "^1.1.11", "findup": "^0.1.5", "fork-ts-checker-webpack-plugin": "^0.4.4", diff --git a/scripts/babel/proptypes-from-ts-props/index.js b/scripts/babel/proptypes-from-ts-props/index.js index 58597bc94ad..713988f359d 100644 --- a/scripts/babel/proptypes-from-ts-props/index.js +++ b/scripts/babel/proptypes-from-ts-props/index.js @@ -74,6 +74,66 @@ function resolveArrayToPropTypes(node, state) { } } +function stripDoubleQuotes(value) { + return value.replace(/^"?(.*?)"?$/, '$1'); +} + +/** + * Converts an Omit type to resolveIdentifierToPropTypes(X) with Y removed + * @param node + * @param state + * @returns AST node representing matching proptypes + */ +function resolveOmitToPropTypes(node, state) { + const types = state.get('types'); + + const { typeParameters } = node; + + if (typeParameters == null) return null; + + const { + params: [sourceType, toRemove], + } = typeParameters; + + const sourcePropTypes = getPropTypesForNode(sourceType, true, state); + // validate that this resulted in a shape, otherwise we don't know how to extract/merge the values + if ( + !types.isCallExpression(sourcePropTypes) || + !types.isMemberExpression(sourcePropTypes.callee) || + sourcePropTypes.callee.object.name !== 'PropTypes' || + sourcePropTypes.callee.property.name !== 'shape' + ) { + return null; + } + + const toRemovePropTypes = getPropTypesForNode(toRemove, true, state); + // validate that this resulted in a oneOf, otherwise we don't know how to use the values + if ( + !types.isCallExpression(toRemovePropTypes) || + !types.isMemberExpression(toRemovePropTypes.callee) || + toRemovePropTypes.callee.object.name !== 'PropTypes' || + toRemovePropTypes.callee.property.name !== 'oneOf' + ) { + return null; + } + + // extract the string values of keys to remove + const keysToRemove = new Set( + toRemovePropTypes.arguments[0].elements + .map(keyToRemove => + types.isStringLiteral(keyToRemove) ? keyToRemove.value : null + ) + .filter(x => x !== null) + ); + + // filter out omitted properties + sourcePropTypes.arguments[0].properties = sourcePropTypes.arguments[0].properties.filter( + ({ key: { name } }) => keysToRemove.has(stripDoubleQuotes(name)) === false + ); + + return sourcePropTypes; +} + /** * Converts an X[] type to PropTypes.arrayOf(X) * @param node @@ -160,9 +220,16 @@ function resolveIdentifierToPropTypes(node, state) { types.identifier('PropTypes'), types.identifier('node') ); + + case 'JSXElementConstructor': + return types.memberExpression( + types.identifier('PropTypes'), + types.identifier('func') // this is more accurately `elementType` but our peerDependency version of prop-types doesn't have it + ); } if (identifier.name === 'Array') return resolveArrayToPropTypes(node, state); + if (identifier.name === 'Omit') return resolveOmitToPropTypes(node, state); if (identifier.name === 'MouseEventHandler') return buildPropTypePrimitiveExpression(types, 'func'); if (identifier.name === 'Function') @@ -356,10 +423,39 @@ function getPropTypesForNode(node, optional, state) { propType = getPropTypesForNode(node.typeAnnotation, true, state); break; + // Foo['bar'] + case 'TSIndexedAccessType': + // verify the type of index access + if (types.isTSLiteralType(node.indexType) === false) break; + + const indexedName = node.indexType.literal.value; + const objectPropType = getPropTypesForNode(node.objectType, true, state); + + // verify this came out as a PropTypes.shape(), which we can pick the indexed property off of + if ( + types.isCallExpression(objectPropType) && + types.isMemberExpression(objectPropType.callee) && + types.isIdentifier(objectPropType.callee.object) && + objectPropType.callee.object.name === 'PropTypes' && + types.isIdentifier(objectPropType.callee.property) && + objectPropType.callee.property.name === 'shape' + ) { + const shapeProps = objectPropType.arguments[0].properties; + for (let i = 0; i < shapeProps.length; i++) { + const prop = shapeProps[i]; + if (prop.key.name === indexedName) { + propType = makePropTypeOptional(types, prop.value); + break; + } + } + } + + break; + // translates intersections (Foo & Bar & Baz) to a shape with the types' members (Foo, Bar, Baz) merged together case 'TSIntersectionType': - const usableNodes = node.types.filter(node => { - const nodePropTypes = getPropTypesForNode(node, true, state); + const usableNodes = [...node.types].filter(node => { + let nodePropTypes = getPropTypesForNode(node, true, state); if ( types.isMemberExpression(nodePropTypes) && @@ -369,12 +465,12 @@ function getPropTypesForNode(node, optional, state) { return false; } - // validate that this resulted in a shape, otherwise we don't know how to extract/merge the values + // validate that this resulted in a shape or oneOfType, otherwise we don't know how to extract/merge the values if ( !types.isCallExpression(nodePropTypes) || !types.isMemberExpression(nodePropTypes.callee) || nodePropTypes.callee.object.name !== 'PropTypes' || - nodePropTypes.callee.property.name !== 'shape' + (nodePropTypes.callee.property.name !== 'shape' && nodePropTypes.callee.property.name !== 'oneOfType') ) { return false; } @@ -384,7 +480,49 @@ function getPropTypesForNode(node, optional, state) { // merge the resolved proptypes for each intersection member into one object, mergedProperties const mergedProperties = usableNodes.reduce((mergedProperties, node) => { - const nodePropTypes = getPropTypesForNode(node, true, state); + let nodePropTypes = getPropTypesForNode(node, true, state); + + // if this is a `oneOfType` extract those properties into a `shape` + if ( + types.isCallExpression(nodePropTypes) && + types.isMemberExpression(nodePropTypes.callee) && + nodePropTypes.callee.object.name === 'PropTypes' && + nodePropTypes.callee.property.name === 'oneOfType' + ) { + const properties = nodePropTypes.arguments[0].elements + .map(propType => { + // This exists on a oneOfType which must be expressed as an optional proptype + propType = makePropTypeOptional(types, propType); + + // validate we're working with a shape, otherwise we can't merge properties + if ( + !types.isCallExpression(propType) || + !types.isMemberExpression(propType.callee) || + propType.callee.object.name !== 'PropTypes' || + propType.callee.property.name !== 'shape' + ) { + return null; + } + + // extract all of the properties from this group and make them optional + return propType.arguments[0].properties.map(property => { + property.value = makePropTypeOptional(types, property.value); + return property; + }); + }) + .filter(x => x !== null) + .reduce((allProperties, properties) => { + return [...allProperties, ...properties]; + }, []); + + nodePropTypes = types.callExpression( + types.memberExpression( + types.identifier('PropTypes'), + types.identifier('shape') + ), + [types.objectExpression(properties)] + ); + } // iterate over this type's members, adding them (and their comments) to `mergedProperties` const typeProperties = nodePropTypes.arguments[0].properties; // properties on the ObjectExpression passed to PropTypes.shape() @@ -1062,7 +1200,7 @@ function getPropTypesNodeFromAST(node, types) { const buildPropTypes = babelTemplate('COMPONENT_NAME.propTypes = PROP_TYPES'); /** - * Called with a type definition and a React component node path, `processComponentDeclaration` translates that definiton + * Called with a type definition and a React component node path, `processComponentDeclaration` translates that definition * to an AST of PropType.* calls and attaches those prop types to the component. * @param typeDefinition * @param path diff --git a/scripts/babel/proptypes-from-ts-props/index.test.js b/scripts/babel/proptypes-from-ts-props/index.test.js index 16c20fe7c5a..dea012cc783 100644 --- a/scripts/babel/proptypes-from-ts-props/index.test.js +++ b/scripts/babel/proptypes-from-ts-props/index.test.js @@ -849,6 +849,28 @@ FooComponent.propTypes = { };`); }); + it('understands JSXElementConstructor', () => { + const result = transform( + ` +import React from 'react'; +const FooComponent: React.SFC<{foo: JSXElementConstructor}> = () => { + return (
Hello World
); +}`, + babelOptions + ); + + expect(result.code).toBe(`import React from 'react'; +import PropTypes from "prop-types"; + +const FooComponent = () => { + return
Hello World
; +}; + +FooComponent.propTypes = { + foo: PropTypes.func.isRequired +};`); + }); + }); describe('node propType', () => { @@ -2051,6 +2073,33 @@ FooComponent.propTypes = { }); + describe('indexed property access', () => { + it('follows indexed properties', () => { + const result = transform( + ` +import React from 'react'; +interface Foo { + foo: () => {}; +} +const FooComponent: React.SFC<{bar: Foo['foo']}> = () => { + return (
Hello World
); +}`, + babelOptions + ); + + expect(result.code).toBe(`import React from 'react'; +import PropTypes from "prop-types"; + +const FooComponent = () => { + return
Hello World
; +}; + +FooComponent.propTypes = { + bar: PropTypes.func.isRequired +};`); + }); + }); + describe('supported component declarations', () => { it('annotates React.SFC components', () => { diff --git a/src-docs/src/components/guide_components.scss b/src-docs/src/components/guide_components.scss index bd2e43f221f..26c46a4453e 100644 --- a/src-docs/src/components/guide_components.scss +++ b/src-docs/src/components/guide_components.scss @@ -247,3 +247,11 @@ $guideZLevelHighest: $euiZLevel9 + 1000; } } } + +.euiDataGridRowCell--favoriteFranchise { + background: transparentize($color: #800080, $amount: .95) !important; +} + +.euiDataGridHeaderCell--favoriteFranchise { + background: transparentize($color: #800080, $amount: .9) !important; +} diff --git a/src-docs/src/components/guide_section/_guide_section.scss b/src-docs/src/components/guide_section/_guide_section.scss index 537fa5ab0a2..b408a51918e 100644 --- a/src-docs/src/components/guide_section/_guide_section.scss +++ b/src-docs/src/components/guide_section/_guide_section.scss @@ -7,13 +7,3 @@ .guideSection__space { height: $euiSizeL; } - -.guideSectionPropsTable { - width: auto; - min-width: 50%; - - th, - td { - max-width: none; - } -} diff --git a/src-docs/src/components/guide_section/guide_section.js b/src-docs/src/components/guide_section/guide_section.js index 49474960600..10d34c7753a 100644 --- a/src-docs/src/components/guide_section/guide_section.js +++ b/src-docs/src/components/guide_section/guide_section.js @@ -200,7 +200,7 @@ export class GuideSection extends Component { const docgenInfo = Array.isArray(component.__docgenInfo) ? component.__docgenInfo[0] : component.__docgenInfo; - const { _euiObjectType, description, props } = docgenInfo; + const { description, props } = docgenInfo; if (!props && !description) { return; @@ -271,12 +271,7 @@ export class GuideSection extends Component { return {cells}; }); - const title = - _euiObjectType === 'type' ? ( - {componentName} - ) : ( - {componentName} - ); + const title = {componentName}; let descriptionElement; @@ -295,18 +290,23 @@ export class GuideSection extends Component { if (rows.length) { table = ( - + - Prop + + Prop + - Type + + Type + - Default + + Default + - Note + + Note + {rows} @@ -422,6 +422,7 @@ export class GuideSection extends Component {
{chrome} {this.renderContent()} + {this.props.extraContent}
); } diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 3b0f5e54e9b..d3fc1452ebb 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -67,6 +67,11 @@ import { ContextMenuExample } from './views/context_menu/context_menu_example'; import { CopyExample } from './views/copy/copy_example'; +import { DataGridExample } from './views/datagrid/datagrid_example'; +import { DataGridMemoryExample } from './views/datagrid/datagrid_memory_example'; +import { DataGridSchemaExample } from './views/datagrid/datagrid_schema_example'; +import { DataGridStylingExample } from './views/datagrid/datagrid_styling_example'; + import { DatePickerExample } from './views/date_picker/date_picker_example'; import { DelayHideExample } from './views/delay_hide/delay_hide_example'; @@ -310,6 +315,16 @@ const navigation = [ TabsExample, ].map(example => createExample(example)), }, + { + name: 'Tabular content', + items: [ + DataGridExample, + DataGridMemoryExample, + DataGridSchemaExample, + DataGridStylingExample, + TableExample, + ].map(example => createExample(example)), + }, { name: 'Display', items: [ @@ -328,7 +343,6 @@ const navigation = [ LoadingExample, ProgressExample, StatExample, - TableExample, TextExample, TitleExample, ToastExample, @@ -411,7 +425,7 @@ const allRoutes = navigation.reduce((accummulatedRoutes, section) => { }, []); export default { - history: useRouterHistory(createHashHistory)(), + history: useRouterHistory(createHashHistory)(), // eslint-disable-line react-hooks/rules-of-hooks navigation, getRouteForPath: path => { diff --git a/src-docs/src/views/datagrid/container.js b/src-docs/src/views/datagrid/container.js new file mode 100644 index 00000000000..b9ba38a59e8 --- /dev/null +++ b/src-docs/src/views/datagrid/container.js @@ -0,0 +1,95 @@ +import React, { Component } from 'react'; +import { fake } from 'faker'; + +import { EuiDataGrid, EuiPanel, EuiLink } from '../../../../src/components/'; + +const columns = [ + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'city', + }, + { + id: 'country', + }, + { + id: 'account', + }, +]; + +const data = []; + +for (let i = 1; i < 20; i++) { + data.push({ + name: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + email: fake('{{internet.email}}'), + city: ( + {fake('{{address.city}}')} + ), + country: fake('{{address.country}}'), + account: fake('{{finance.account}}'), + }); +} + +export default class DataGridContainer extends Component { + constructor(props) { + super(props); + + this.state = { + pagination: { + pageIndex: 0, + pageSize: 10, + }, + visibleColumns: columns.map(({ id }) => id), + }; + } + + setPageIndex = pageIndex => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageIndex }, + })); + + setPageSize = pageSize => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageSize }, + })); + + setVisibleColumns = visibleColumns => this.setState({ visibleColumns }); + + render() { + const { pagination } = this.state; + + return ( + +
+ + data[rowIndex][columnId] + } + pagination={{ + ...pagination, + pageSizeOptions: [5, 10, 25], + onChangeItemsPerPage: this.setPageSize, + onChangePage: this.setPageIndex, + }} + /> +
+
+ ); + } +} diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js new file mode 100644 index 00000000000..1070fd3f1f3 --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid.js @@ -0,0 +1,160 @@ +import React, { + Fragment, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { fake } from 'faker'; + +import { + EuiDataGrid, + EuiLink, + EuiFlexGroup, + EuiFlexItem, +} from '../../../../src/components/'; +import { EuiButtonIcon } from '../../../../src/components/button/button_icon'; + +const columns = [ + { + id: 'name', + }, + { + id: 'email', + display: ( + // This is an example of an icon next to a title that still respects text truncate + + +
email
+
+ + alert('Email Icon Clicked!')} + /> + +
+ ), + }, + { + id: 'location', + }, + { + id: 'account', + }, + { + id: 'date', + }, + { + id: 'amount', + }, + { + id: 'phone', + }, + { + id: 'version', + }, +]; + +const raw_data = []; + +for (let i = 1; i < 100; i++) { + raw_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('${{commerce.price}}'), + phone: fake('{{phone.phoneNumber}}'), + version: fake('{{system.semver}}'), + }); +} + +export default () => { + // ** Pagination config + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const onChangeItemsPerPage = useCallback( + pageSize => setPagination(pagination => ({ ...pagination, pageSize })), + [setPagination] + ); + const onChangePage = useCallback( + pageIndex => setPagination(pagination => ({ ...pagination, pageIndex })), + [setPagination] + ); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback( + sortingColumns => { + setSortingColumns(sortingColumns); + }, + [setSortingColumns] + ); + + // Sort data + const data = useMemo(() => { + // the grid itself is responsible for sorting if inMemory is `sorting` + return raw_data; + }, [raw_data, sortingColumns]); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); // initialize to the full set of columns + + const renderCellValue = useMemo(() => { + return ({ rowIndex, columnId, setCellProps }) => { + let adjustedRowIndex = rowIndex; + + adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + useEffect(() => { + if (columnId === 'amount') { + if (data.hasOwnProperty(adjustedRowIndex)) { + const numeric = parseFloat( + data[adjustedRowIndex][columnId].match(/\d+\.\d+/)[0], + 10 + ); + setCellProps({ + style: { + backgroundColor: `rgba(0, 255, 0, ${numeric * 0.0002})`, + }, + }); + } + } + }, [adjustedRowIndex, columnId, setCellProps]); + + return data.hasOwnProperty(adjustedRowIndex) + ? data[adjustedRowIndex][columnId] + : null; + }; + }, [data]); + + return ( + + ); +}; diff --git a/src-docs/src/views/datagrid/datagrid_example.js b/src-docs/src/views/datagrid/datagrid_example.js new file mode 100644 index 00000000000..9b47384f6ee --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid_example.js @@ -0,0 +1,365 @@ +import React, { Fragment } from 'react'; + +import { renderToHtml } from '../../services'; + +import { GuideSectionTypes } from '../../components'; +import { + EuiDataGrid, + EuiCode, + EuiDescriptionList, + EuiCodeBlock, + EuiText, + EuiSpacer, +} from '../../../../src/components'; + +import { Link } from 'react-router'; + +import DataGrid from './datagrid'; +const dataGridSource = require('!!raw-loader!./datagrid'); +const dataGridHtml = renderToHtml(DataGrid); + +import { + DataGridColumn, + DataGridPagination, + DataGridSorting, + DataGridInMemory, + DataGridStyle, + DataGridCellValueElement, + DataGridSchemaDetector, + DataGridToolbarVisibilityOptions, + DataGridColumnVisibility, + DataGridPopoverContent, +} from './props'; + +const gridSnippet = ` + {}, + }} + // Optional. Customize the content inside the cell. The current example outputs the row and column position. + // Often used in combination with useEffect() to dynamically change the render. + renderCellValue={({ rowIndex, columnId }) => + \`\${rowIndex}, \${columnId}\` + } + // Optional. Add pagination. + pagination={{ + pageIndex: 1, + pageSize: 100, + pageSizeOptions: [50, 100, 200], + onChangePage: () => {}, + onChangeItemsPerPage: () => {}, + }} + // Optional, but required when inMemory is set. Provides the sort and gives a callback for when it changes in the grid. + sorting={{ + columns: [{ id: 'C', direction: 'asc' }], + onSort: () => {}, + }} + // Optional. Allows you to configure what features the toolbar shows. + // The prop also accepts a boolean if you want to toggle the entire toolbar on/off. + toolbarVisibility={{ + showColumnSelector: false + showStyleSelector: false + showSortSelector: false + showFullScreenSelector: false + }} + // Optional. Change the initial style of the grid. + gridStyle={{ + border: 'all', + fontSize: 'm', + cellPadding: 'm', + stripes: true, + rowHover: 'highlight', + header: 'shade', + }} + // Optional. Provide additional schemas to use in the grid. + // This schema 'franchise' essentially acts like a boolean, looking for Star Wars or Star Trek in a column. + schemaDetectors={[ + { + type: 'franchise', + // Try to detect if column data is this schema. A value of 1 is the highest possible. A (mean_average - standard_deviation) of .5 will be good enough for the autodetector to assign. + detector(value) { + return value.toLowerCase() === 'star wars' || + value.toLowerCase() === 'star trek' + ? 1 + : 0; + }, + // How we should sort data matching this schema. Again, a value of 1 is the highest value. + comparator(a, b, direction) { + const aValue = a.toLowerCase() === 'star wars'; + const bValue = b.toLowerCase() === 'star wars'; + if (aValue < bValue) return direction === 'asc' ? 1 : -1; + if (aValue > bValue) return direction === 'asc' ? -1 : 1; + return 0; + }, + // Text for what the ASC sort does. + sortTextAsc: 'Star Wars-Star Trek', + // Text for what the DESC sort does. + sortTextDesc: 'Star Trek-Star Wars', + // EuiIcon to signify this schema. + icon: 'star', + // The color to use for the icon. + color: '#000000', + }, + ]} + // Optional. Mapped against the schema, provide custom layout and/or content for the popover. + popoverContents={{ + numeric: ({ children, cellContentsElement }) => { + // \`children\` is the datagrid's \`renderCellValue\` as a ReactElement and should be used when you are only wrapping the contents + // \`cellContentsElement\` is the cell's existing DOM element and can be used to extract the text value for processing, as below + + // want to process the already-rendered cell value + const stringContents = cellContentsElement.textContent; + + // extract the groups-of-three digits that are right-aligned + return stringContents.replace(/((\\d{3})+)$/, match => + // then replace each group of xyz digits with ,xyz + match.replace(/(\\d{3})/g, ',$1') + ); + }, + }} + /> +`; + +const gridConcepts = [ + { + title: 'columns', + description: ( + + An array of EuiDataGridColumn objects. Lists the + columns available and the schema and settings tied to it. + + ), + }, + { + title: 'inMemory', + description: ( + + A EuiDataGridInMemory object to define the level of + high order schema-detection and sorting logic to use on your data.{' '} + Try to set it when possible. If omitted, disables all + enhancements and assumes content is flat strings. + + ), + }, + { + title: 'columnVisibility', + description: ( + + An array of EuiDataGridColumnVisibility objects. + Defines which columns are visible in the grid and the order they are + displayed. + + ), + }, + { + title: 'schemaDetectors', + description: ( + + An array of custom EuiDataGridSchemaDetector objects. + You can inject custom schemas to the grid to define the classnames + applied. + + ), + }, + { + title: 'popoverContents', + description: ( + + An object mapping EuiDataGridColumn schemas to a + custom popover render. This dictates the content of the popovers when + you click into each cell. + + ), + }, + { + title: 'rowCount', + description: + 'The total number of rows in the dataset (used by e.g. pagination to know how many pages to list).', + }, + { + title: 'gridStyle', + description: ( + + Defines the look of the grid. Accepts a partial{' '} + EuiDataGridStyle object. Settings provided may be + overwritten or merged with user defined preferences if{' '} + toolbarVisibility.showStyleSelector is set to true + (which is the default). + + ), + }, + { + title: 'toolbarVisibility', + description: ( + + Accepts either a boolean or{' '} + EuiDataGridTooBarVisibilityOptions object. When used + as a boolean, defines the visibility of entire toolbar. When passed an + object allows you to turn off individual controls within the toolbar. + + ), + }, + { + title: 'renderCellValue', + description: ( + + A function called to render a cell's value. Behind the scenes it is + treated as a React component allowing hooks, context, and other React + concepts to be used. The function receives a{' '} + EuiDataGridCellValueElement as its only argument. + + ), + }, + { + title: 'pagination', + description: ( + + A EuiDataGridPagination object. Omit to disable + pagination completely. + + ), + }, + { + title: 'sorting', + description: ( + + A EuiDataGridSorting object that provides the sorted + columns along with their direction. Omit to disable, but you'll + likely want to also turn off the user sorting controls through the{' '} + toolbarVisibility prop. + + ), + }, +]; + +export const DataGridExample = { + title: 'Data grid', + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridSource, + }, + { + type: GuideSectionTypes.HTML, + code: dataGridHtml, + }, + ], + text: ( + +

+ EuiDataGrid is for displaying large amounts of + tabular data. It is a better choice over{' '} + EUI tables when there are + many columns, the data in those columns is fairly uniform, and when + schemas and sorting are important for comparison. Although it is + similar to traditional spreedsheet software, EuiDataGrid's + current strengths are in rendering rather than creating content.{' '} +

+

Core concepts

+
    +
  • + The grid allows you to optionally define an{' '} + + in memory level + {' '} + to have the grid automatically handle updating your columns. + Depending upon the level choosen you may need to manage the + content order separate from the grid. +
  • +
  • + + Schemas + {' '} + allow you to tailor the render and sort methods for each column. + The component ships with a few automatic schema detection and + types, but you can also pass in custom ones. +
  • +
  • + Unlike tables, the data grid forces truncation. + To display more content your can customize{' '} + + popovers + {' '} + to display more content and actions into popovers. +
  • +
  • + Grid styling{' '} + can be controlled by the engineer, but augmented by user + preference depending upon the features you enable. +
  • +
+
+ ), + components: { DataGrid }, + props: { + EuiDataGrid, + EuiDataGridColumn: DataGridColumn, + EuiDataGridColumnVisibility: DataGridColumnVisibility, + EuiDataGridInMemory: DataGridInMemory, + EuiDataGridPagination: DataGridPagination, + EuiDataGridSorting: DataGridSorting, + EuiDataGridCellValueElement: DataGridCellValueElement, + EuiDataGridSchemaDetector: DataGridSchemaDetector, + EuiDataGridStyle: DataGridStyle, + EuiDataGridToolbarVisibilityOptions: DataGridToolbarVisibilityOptions, + EuiDataGridPopoverContent: DataGridPopoverContent, + }, + demo: ( + + + + ), + extraContent: ( + + + +

Snippet with every feature in use

+

+ Here is a complicated data grid example meant to give you an idea + of the data structure and callbacks you'll need to provide if + you were utilizing all the features. +

+
+ + + {gridSnippet} + + + +

General props explanation

+

+ Please check the props tab in the example above for more + explanation on the lower level object types. The majority of the + types are defined in the{' '} + + /data_grid/data_grid_types.ts + {' '} + file. +

+
+ + +
+ ), + }, + ], +}; diff --git a/src-docs/src/views/datagrid/datagrid_memory_example.js b/src-docs/src/views/datagrid/datagrid_memory_example.js new file mode 100644 index 00000000000..639444516d5 --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid_memory_example.js @@ -0,0 +1,249 @@ +import React, { Fragment } from 'react'; + +import { renderToHtml } from '../../services'; + +import { GuideSectionTypes } from '../../components'; +import { + EuiDataGrid, + EuiCallOut, + EuiCode, + EuiText, + EuiSpacer, +} from '../../../../src/components'; + +import InMemoryDataGrid from './in_memory'; +const inMemoryDataGridSource = require('!!raw-loader!./in_memory'); +const inMemoryDataGridHtml = renderToHtml(InMemoryDataGrid); + +import InMemoryEnhancementsDataGrid from './in_memory_enhancements'; +const inMemoryEnhancementsDataGridSource = require('!!raw-loader!./in_memory_enhancements'); +const inMemoryEnhancementsDataGridHtml = renderToHtml( + InMemoryEnhancementsDataGrid +); + +import InMemoryPaginationDataGrid from './in_memory_pagination'; +const inMemoryPaginationDataGridSource = require('!!raw-loader!./in_memory_pagination'); +const inMemoryPaginationDataGridHtml = renderToHtml(InMemoryPaginationDataGrid); + +import InMemorySortingDataGrid from './in_memory_sorting'; +const inMemorySortingDataGridSource = require('!!raw-loader!./in_memory_sorting'); +const inMemorySortingDataGridHtml = renderToHtml(InMemorySortingDataGrid); + +import { + DataGridColumn, + DataGridPagination, + DataGridSorting, + DataGridInMemory, + DataGridStyle, + DataGridCellValueElement, + DataGridSchemaDetector, + DataGridToolbarVisibilityOptions, + DataGridColumnVisibility, +} from './props'; + +export const DataGridMemoryExample = { + title: 'Data grid in-memory settings', + intro: ( + + +

+ These examples show the same grid built with the four available{' '} + inMemory settings. While they may look the same, + look at the source to see how they require different levels of data + management in regards to sorting and pagination. +

+
+ + +

+ The grid has levels of in-memory settings that can be + set. It is in the consuming application's best interest to put as + much of the data grid in memory as performance allows. Try to use the + highest level inMemory="sorting" whenever + possible. The following values are available. +

+
    +
  • + undefined (default): When not in use the grid will + not autodetect schemas. The sorting and pagination is the + responsibility of the consuming application. +
  • +
  • + enhancements: Provides no in-memory operations. If + set, the grid will try to autodetect schemas only based on the + content currently available (the current page of data). +
  • +
  • + pagination: Schema detection works as above and + pagination is performed in-memory. The pagination callbacks are + still triggered on user interactions, but the row updates are + performed by the grid. +
  • +
  • + sorting (suggested): Schema detection and + pagination are performed as above, and sorting is applied in-memory + too. The onSort callback is still called and the application must + own the column sort state, but data sorting is done by the grid + based on the defined and/or detected schemas. +
  • +
+

+ When enabled, in-memory renders cell data off-screen + and uses those values to detect schemas and perform sorting. This + detaches the user experience from the raw data; the data grid never + has access to the backing data, only what is returned by{' '} + renderCellValue. +

+
+ +
+ ), + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: inMemoryDataGridSource, + }, + { + type: GuideSectionTypes.HTML, + code: inMemoryDataGridHtml, + }, + ], + title: 'When in-memory is not used', + text: ( +

+ When inMemory is not in use the grid will not + autodetect schemas. In the below example only the{' '} + amount column has a schema because it is manually + set. Sorting and pagination data management is the responsibility of + the consuming application. Column sorting in particular is going to be + imprecise because there is no backend service to call, and data grid + instead defaults to naively applying JavaScript's default array + sort which doesn't work well with numeric data and doesn't + sort React elements such as the links. This is a good example of what + happens when you don't utilize schemas for + complex data. +

+ ), + props: { + EuiDataGrid, + EuiDataGridInMemory: DataGridInMemory, + EuiDataGridColumn: DataGridColumn, + EuiDataGridColumnVisibility: DataGridColumnVisibility, + EuiDataGridPagination: DataGridPagination, + EuiDataGridSorting: DataGridSorting, + EuiDataGridCellValueElement: DataGridCellValueElement, + EuiDataGridSchemaDetector: DataGridSchemaDetector, + EuiDataGridStyle: DataGridStyle, + EuiDataGridToolbarVisibilityOptions: DataGridToolbarVisibilityOptions, + }, + components: { InMemoryDataGrid }, + demo: , + }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: inMemoryEnhancementsDataGridSource, + }, + { + type: GuideSectionTypes.HTML, + code: inMemoryEnhancementsDataGridHtml, + }, + ], + title: 'Enhancements only in-memory', + text: ( +

+ With {'inMemory="{{ level: \'enhancements\' }}"'}{' '} + the grid will now autodetect schemas based on the content it has + available on the currently viewed page. Notice that the field list + under Sort fields has detected the type of data each column contains. +

+ ), + props: { + EuiDataGrid, + EuiDataGridInMemory: DataGridInMemory, + EuiDataGridColumn: DataGridColumn, + EuiDataGridColumnVisibility: DataGridColumnVisibility, + EuiDataGridPagination: DataGridPagination, + EuiDataGridSorting: DataGridSorting, + EuiDataGridCellValueElement: DataGridCellValueElement, + EuiDataGridSchemaDetector: DataGridSchemaDetector, + EuiDataGridStyle: DataGridStyle, + EuiDataGridToolbarVisibilityOptions: DataGridToolbarVisibilityOptions, + }, + components: { InMemoryEnhancementsDataGrid }, + demo: , + }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: inMemoryPaginationDataGridSource, + }, + { + type: GuideSectionTypes.HTML, + code: inMemoryPaginationDataGridHtml, + }, + ], + title: 'Pagination only in-memory', + text: ( +

+ With {'inMemory="{{ level: \'pagination\' }}"'} the + grid will now take care of managing the data cleanup for pagination. + Like before it will autodetect schemas when possible. +

+ ), + props: { + EuiDataGrid, + EuiDataGridInMemory: DataGridInMemory, + EuiDataGridColumn: DataGridColumn, + EuiDataGridColumnVisibility: DataGridColumnVisibility, + EuiDataGridPagination: DataGridPagination, + EuiDataGridSorting: DataGridSorting, + EuiDataGridCellValueElement: DataGridCellValueElement, + EuiDataGridSchemaDetector: DataGridSchemaDetector, + EuiDataGridStyle: DataGridStyle, + EuiDataGridToolbarVisibilityOptions: DataGridToolbarVisibilityOptions, + }, + components: { InMemoryPaginationDataGrid }, + demo: , + }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: inMemorySortingDataGridSource, + }, + { + type: GuideSectionTypes.HTML, + code: inMemorySortingDataGridHtml, + }, + ], + title: 'Sorting and pagination in-memory', + text: ( +

+ With {'inMemory="{{ level: \'sorting\' }}"'} the + grid will now take care of managing the data cleanup for sorting as + well as pagination. Like before it will autodetect schemas when + possible. +

+ ), + props: { + EuiDataGrid, + EuiDataGridInMemory: DataGridInMemory, + EuiDataGridColumn: DataGridColumn, + EuiDataGridColumnVisibility: DataGridColumnVisibility, + EuiDataGridPagination: DataGridPagination, + EuiDataGridSorting: DataGridSorting, + EuiDataGridCellValueElement: DataGridCellValueElement, + EuiDataGridSchemaDetector: DataGridSchemaDetector, + EuiDataGridStyle: DataGridStyle, + EuiDataGridToolbarVisibilityOptions: DataGridToolbarVisibilityOptions, + }, + components: { InMemorySortingDataGrid }, + demo: , + }, + ], +}; diff --git a/src-docs/src/views/datagrid/datagrid_schema_example.js b/src-docs/src/views/datagrid/datagrid_schema_example.js new file mode 100644 index 00000000000..754fbd6a866 --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid_schema_example.js @@ -0,0 +1,101 @@ +import React, { Fragment } from 'react'; + +import { renderToHtml } from '../../services'; + +import { GuideSectionTypes } from '../../components'; +import { EuiDataGrid, EuiCode } from '../../../../src/components'; + +import DataGridSchema from './schema'; +const dataGridSchemaSource = require('!!raw-loader!./schema'); +const dataGridSchemaHtml = renderToHtml(DataGridSchema); + +import { + DataGridColumn, + DataGridPagination, + DataGridSorting, + DataGridInMemory, + DataGridStyle, + DataGridCellValueElement, + DataGridSchemaDetector, + DataGridToolbarVisibilityOptions, + DataGridColumnVisibility, +} from './props'; + +export const DataGridSchemaExample = { + title: 'Data grid schemas and popovers', + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridSchemaSource, + }, + { + type: GuideSectionTypes.HTML, + code: dataGridSchemaHtml, + }, + ], + text: ( + +

+ Schemas are data types you pass to grid columns to affect how the + columns and expansion popovers render. Schemas also allow you to + define individual sorting comparators so that sorts can do more than + just A-Z comparisons. By default, EuiDataGrid{' '} + ships with a few built-in schemas for{' '} + numeric, currency, datetime, boolean and json{' '} + data. When the inMemory prop is in use it will + automatically try to figure out the best schema based on the{' '} + {'inMemory: {{ level: value }}'} you set, but + this will come with the caveat that you will need to provide and + manage sorting outside the component. In general we recommend + passing schema information to your columns instead of using + auto-detection when you have that knowledge of your data available + during ingestion. +

+

Defining custom schemas

+

+ Custom schemas are passed as an array to{' '} + schemaDetectors and are constructed against the{' '} + EuiDataGridSchemaDetector interface. You can see + an example of a simple custom schema used on the last column below. + In addition to schemas being useful to map against for cell and + expansion rendering, any schema will also add a + + className="euiDataGridRowCell--schemaName" + {' '} + to each matching cell. +

+

Defining expansio

+

+ Likewise, you can inject custom content into any of the popovers a + cell expands into. Add popoverContents functions + to populate a matching schema's popover using a new component. + You can see an example of this by clicking into one of the cells in + the last column below. +

+

Disabling expansion popovers

+

+ Often the popovers are unnecessary for short form content. In the + example below we've turned them off by setting{' '} + isExpandable=false on the boolean column. +

+
+ ), + components: { DataGridSchema }, + props: { + EuiDataGrid, + EuiDataGridColumn: DataGridColumn, + EuiDataGridColumnVisibility: DataGridColumnVisibility, + EuiDataGridInMemory: DataGridInMemory, + EuiDataGridPagination: DataGridPagination, + EuiDataGridSorting: DataGridSorting, + EuiDataGridCellValueElement: DataGridCellValueElement, + EuiDataGridSchemaDetector: DataGridSchemaDetector, + EuiDataGridStyle: DataGridStyle, + EuiDataGridToolbarVisibilityOptions: DataGridToolbarVisibilityOptions, + }, + demo: , + }, + ], +}; diff --git a/src-docs/src/views/datagrid/datagrid_styling_example.js b/src-docs/src/views/datagrid/datagrid_styling_example.js new file mode 100644 index 00000000000..d6e75cc8931 --- /dev/null +++ b/src-docs/src/views/datagrid/datagrid_styling_example.js @@ -0,0 +1,112 @@ +import React, { Fragment } from 'react'; + +import { renderToHtml } from '../../services'; + +import { GuideSectionTypes } from '../../components'; +import { EuiDataGrid, EuiCode, EuiCodeBlock } from '../../../../src/components'; + +import DataGridContainer from './container'; +const dataGridContainerSource = require('!!raw-loader!./container'); +const dataGridContainerHtml = renderToHtml(DataGridContainer); + +import DataGridStyling from './styling'; +const dataGridStylingSource = require('!!raw-loader!./styling'); +const dataGridStylingHtml = renderToHtml(DataGridStyling); + +import { DataGridStyle, DataGridToolbarVisibilityOptions } from './props'; + +const gridSnippet = ` +`; + +export const DataGridStylingExample = { + title: 'Data grid styling', + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridStylingSource, + }, + { + type: GuideSectionTypes.HTML, + code: dataGridStylingHtml, + }, + ], + text: ( + +

+ Styling can be passed down to the grid through the{' '} + gridStyle prop. It accepts an object that allows + for customization. +

+

+ The toolbarVisibility prop when used as a boolean + controls the visibility of the toolbar displayed above the grid. + Using the prop as a shape, allows setting the visibility of the + individual buttons within. +

+

+ With the default settings, the showStyleSelector{' '} + setting in toolbarVisibility means the user has + the ability to override the padding and font size passed into{' '} + gridStyle by the engineer. +

+ + {gridSnippet} + +
+ ), + components: { DataGridStyling }, + + props: { + EuiDataGrid, + EuiDataGridStyle: DataGridStyle, + EuiDataGridToolbarVisibilityOptions: DataGridToolbarVisibilityOptions, + }, + demo: , + }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridContainerSource, + }, + { + type: GuideSectionTypes.HTML, + code: dataGridContainerHtml, + }, + ], + title: 'Data grid adapts to its container', + text: ( +

+ When wrapped inside a container, like a dashboard panel, the grid will + start hiding controls and adopt a more strict flex layout +

+ ), + components: { DataGridContainer }, + + demo: , + }, + ], +}; diff --git a/src-docs/src/views/datagrid/in_memory.js b/src-docs/src/views/datagrid/in_memory.js new file mode 100644 index 00000000000..db16f2e40e4 --- /dev/null +++ b/src-docs/src/views/datagrid/in_memory.js @@ -0,0 +1,135 @@ +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { fake } from 'faker'; + +import { EuiDataGrid, EuiLink } from '../../../../src/components/'; + +const columns = [ + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'location', + }, + { + id: 'account', + }, + { + id: 'date', + }, + { + id: 'amount', + schema: 'currency', + }, + { + id: 'phone', + }, + { + id: 'version', + }, +]; + +const raw_data = []; + +for (let i = 1; i < 100; i++) { + raw_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('${{commerce.price}}'), + phone: fake('{{phone.phoneNumber}}'), + version: fake('{{system.semver}}'), + }); +} + +export default () => { + // ** Pagination config + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const onChangeItemsPerPage = useCallback( + pageSize => setPagination(pagination => ({ ...pagination, pageSize })), + [setPagination] + ); + const onChangePage = useCallback( + pageIndex => setPagination(pagination => ({ ...pagination, pageIndex })), + [setPagination] + ); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback( + sortingColumns => { + setSortingColumns(sortingColumns); + }, + [setSortingColumns] + ); + + // Sort data + let data = useMemo(() => { + return [...raw_data].sort((a, b) => { + for (let i = 0; i < sortingColumns.length; i++) { + const column = sortingColumns[i]; + const aValue = a[column.id]; + const bValue = b[column.id]; + + if (aValue < bValue) return column.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return column.direction === 'asc' ? 1 : -1; + } + + return 0; + }); + }, [raw_data, sortingColumns]); + + // Pagination + data = useMemo(() => { + const rowStart = pagination.pageIndex * pagination.pageSize; + const rowEnd = Math.min(rowStart + pagination.pageSize, data.length); + return data.slice(rowStart, rowEnd); + }, [data, pagination]); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); // initialize to the full set of columns + + const renderCellValue = useMemo(() => { + return ({ rowIndex, columnId }) => { + let adjustedRowIndex = rowIndex; + + // If we are doing the pagination (instead of leaving that to the grid) + // then the row index must be adjusted as `data` has already been pruned to the page size + adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + return data.hasOwnProperty(adjustedRowIndex) + ? data[adjustedRowIndex][columnId] + : null; + }; + }, data); + + return ( + + ); +}; diff --git a/src-docs/src/views/datagrid/in_memory_enhancements.js b/src-docs/src/views/datagrid/in_memory_enhancements.js new file mode 100644 index 00000000000..7788f1e180e --- /dev/null +++ b/src-docs/src/views/datagrid/in_memory_enhancements.js @@ -0,0 +1,138 @@ +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { fake } from 'faker'; + +import { EuiDataGrid, EuiLink } from '../../../../src/components/'; + +const columns = [ + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'location', + }, + { + id: 'account', + }, + { + id: 'date', + }, + { + id: 'amount', + }, + { + id: 'phone', + }, + { + id: 'version', + }, +]; + +const raw_data = []; + +for (let i = 1; i < 100; i++) { + raw_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('${{commerce.price}}'), + phone: fake('{{phone.phoneNumber}}'), + version: fake('{{system.semver}}'), + }); +} + +export default () => { + // ** Pagination config + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const onChangeItemsPerPage = useCallback( + pageSize => setPagination(pagination => ({ ...pagination, pageSize })), + [setPagination] + ); + const onChangePage = useCallback( + pageIndex => setPagination(pagination => ({ ...pagination, pageIndex })), + [setPagination] + ); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback( + sortingColumns => { + setSortingColumns(sortingColumns); + }, + [setSortingColumns] + ); + + // Sort data + let data = useMemo(() => { + // the grid itself is responsible for sorting if inMemory is `sorting` + + return [...raw_data].sort((a, b) => { + for (let i = 0; i < sortingColumns.length; i++) { + const column = sortingColumns[i]; + const aValue = a[column.id]; + const bValue = b[column.id]; + + if (aValue < bValue) return column.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return column.direction === 'asc' ? 1 : -1; + } + + return 0; + }); + }, [raw_data, sortingColumns]); + + // Pagination + data = useMemo(() => { + const rowStart = pagination.pageIndex * pagination.pageSize; + const rowEnd = Math.min(rowStart + pagination.pageSize, data.length); + return data.slice(rowStart, rowEnd); + }, [data, pagination]); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); // initialize to the full set of columns + + const renderCellValue = useMemo(() => { + return ({ rowIndex, columnId }) => { + // Because inMemory is not set for pagination, we need to manage it + // The row index must be adjusted as `data` has already been pruned to the page size + const adjustedRowIndex = + rowIndex - pagination.pageIndex * pagination.pageSize; + + return data.hasOwnProperty(adjustedRowIndex) + ? data[adjustedRowIndex][columnId] + : null; + }; + }, [data]); + + return ( +
+ +
+ ); +}; diff --git a/src-docs/src/views/datagrid/in_memory_pagination.js b/src-docs/src/views/datagrid/in_memory_pagination.js new file mode 100644 index 00000000000..234024c771c --- /dev/null +++ b/src-docs/src/views/datagrid/in_memory_pagination.js @@ -0,0 +1,120 @@ +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { fake } from 'faker'; + +import { EuiDataGrid, EuiLink } from '../../../../src/components/'; + +const columns = [ + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'location', + }, + { + id: 'account', + }, + { + id: 'date', + }, + { + id: 'amount', + }, + { + id: 'phone', + }, + { + id: 'version', + }, +]; + +const raw_data = []; + +for (let i = 1; i < 100; i++) { + raw_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('${{commerce.price}}'), + phone: fake('{{phone.phoneNumber}}'), + version: fake('{{system.semver}}'), + }); +} + +export default () => { + // ** Pagination config + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const onChangeItemsPerPage = useCallback( + pageSize => setPagination(pagination => ({ ...pagination, pageSize })), + [setPagination] + ); + const onChangePage = useCallback( + pageIndex => setPagination(pagination => ({ ...pagination, pageIndex })), + [setPagination] + ); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback( + sortingColumns => { + setSortingColumns(sortingColumns); + }, + [setSortingColumns] + ); + + // Because inMemory's level is set to `pagination` we still need to sort the data, but no longer need to chunk it for pagination + const data = useMemo(() => { + return [...raw_data].sort((a, b) => { + for (let i = 0; i < sortingColumns.length; i++) { + const column = sortingColumns[i]; + const aValue = a[column.id]; + const bValue = b[column.id]; + + if (aValue < bValue) return column.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return column.direction === 'asc' ? 1 : -1; + } + + return 0; + }); + }, [raw_data, sortingColumns]); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); // initialize to the full set of columns + + const renderCellValue = useMemo(() => { + return ({ rowIndex, columnId }) => { + return data.hasOwnProperty(rowIndex) ? data[rowIndex][columnId] : null; + }; + }, [data]); + + return ( + + ); +}; diff --git a/src-docs/src/views/datagrid/in_memory_sorting.js b/src-docs/src/views/datagrid/in_memory_sorting.js new file mode 100644 index 00000000000..4f676b6f570 --- /dev/null +++ b/src-docs/src/views/datagrid/in_memory_sorting.js @@ -0,0 +1,106 @@ +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { fake } from 'faker'; + +import { EuiDataGrid, EuiLink } from '../../../../src/components/'; + +const columns = [ + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'location', + }, + { + id: 'account', + }, + { + id: 'date', + }, + { + id: 'amount', + }, + { + id: 'phone', + }, + { + id: 'version', + }, +]; + +const raw_data = []; + +for (let i = 1; i < 100; i++) { + raw_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('${{commerce.price}}'), + phone: fake('{{phone.phoneNumber}}'), + version: fake('{{system.semver}}'), + }); +} + +export default () => { + // ** Pagination config + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const onChangeItemsPerPage = useCallback( + pageSize => setPagination(pagination => ({ ...pagination, pageSize })), + [setPagination] + ); + const onChangePage = useCallback( + pageIndex => setPagination(pagination => ({ ...pagination, pageIndex })), + [setPagination] + ); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback( + sortingColumns => { + setSortingColumns(sortingColumns); + }, + [setSortingColumns] + ); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); // initialize to the full set of columns + + const renderCellValue = useMemo(() => { + return ({ rowIndex, columnId }) => { + return raw_data.hasOwnProperty(rowIndex) + ? raw_data[rowIndex][columnId] + : null; + }; + }, [raw_data]); + + return ( + + ); +}; diff --git a/src-docs/src/views/datagrid/props.tsx b/src-docs/src/views/datagrid/props.tsx new file mode 100644 index 00000000000..dc0dd2ef32d --- /dev/null +++ b/src-docs/src/views/datagrid/props.tsx @@ -0,0 +1,51 @@ +import React, { FunctionComponent } from 'react'; +import { + EuiDataGridColumn, + EuiDataGridPaginationProps, + EuiDataGridSorting, + EuiDataGridInMemory, + EuiDataGridStyle, + EuiDataGridTooBarVisibilityOptions, + EuiDataGridColumnVisibility, + EuiDataGridPopoverContentProps, +} from '../../../../src/components/datagrid/data_grid_types'; +import { EuiDataGridCellValueElementProps } from '../../../../src/components/datagrid/data_grid_cell'; +import { EuiDataGridSchemaDetector } from '../../../../src/components/datagrid/data_grid_schema'; + +export const DataGridColumn: FunctionComponent = () => ( +
+); + +export const DataGridPagination: FunctionComponent< + EuiDataGridPaginationProps +> = () =>
; + +export const DataGridSorting: FunctionComponent = () => ( +
+); + +export const DataGridInMemory: FunctionComponent = () => ( +
+); + +export const DataGridStyle: FunctionComponent = () =>
; + +export const DataGridToolbarVisibilityOptions: FunctionComponent< + EuiDataGridTooBarVisibilityOptions +> = () =>
; + +export const DataGridCellValueElement: FunctionComponent< + EuiDataGridCellValueElementProps +> = () =>
; + +export const DataGridSchemaDetector: FunctionComponent< + EuiDataGridSchemaDetector +> = () =>
; + +export const DataGridColumnVisibility: FunctionComponent< + EuiDataGridColumnVisibility +> = () =>
; + +export const DataGridPopoverContent: FunctionComponent< + EuiDataGridPopoverContentProps +> = () =>
; diff --git a/src-docs/src/views/datagrid/schema.js b/src-docs/src/views/datagrid/schema.js new file mode 100644 index 00000000000..ab3b67740a7 --- /dev/null +++ b/src-docs/src/views/datagrid/schema.js @@ -0,0 +1,226 @@ +import React, { Component } from 'react'; +import { fake } from 'faker'; + +import { + EuiDataGrid, + EuiButtonIcon, + EuiImage, + EuiTitle, + EuiSpacer, +} from '../../../../src/components/'; +import { iconTypes } from '../icon/icons'; + +const columns = [ + { + id: 'default', + }, + { + id: 'boolean', + isExpandable: false, + }, + { + id: 'numeric', + }, + { + id: 'currency', + }, + { + id: 'datetime', + schema: 'datetime', + }, + { + id: 'json', + }, + { + id: 'custom', + schema: 'favoriteFranchise', + }, +]; + +const data = []; + +for (let i = 1; i < 5; i++) { + let json; + let franchise; + if (i < 3) { + franchise = 'Star Wars'; + json = JSON.stringify([ + { + default: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + boolean: fake('{{random.boolean}}'), + numeric: fake('{{finance.account}}'), + currency: fake('${{finance.amount}}'), + date: fake('{{date.past}}'), + custom: fake('{{date.past}}'), + }, + ]); + } else { + franchise = 'Star Trek'; + json = JSON.stringify([ + { + name: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + }, + ]); + } + + data.push({ + default: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + boolean: fake('{{random.boolean}}'), + numeric: fake('{{finance.account}}'), + currency: fake('${{finance.amount}}'), + datetime: fake('{{date.past}}'), + json: json, + custom: franchise, + }); +} + +const Franchise = props => { + return ( +
+ +

{props.name} is the best!

+
+ + {props.name === 'Star Wars' ? ( + + ) : ( + + )} +
+ ); +}; + +export default class DataGridSchema extends Component { + constructor(props) { + super(props); + + this.state = { + data, + sortingColumns: [{ id: 'contributions', direction: 'asc' }], + + pagination: { + pageIndex: 0, + pageSize: 10, + }, + + visibleColumns: columns.map(({ id }) => id), + }; + } + + setSorting = sortingColumns => { + const data = [...this.state.data].sort((a, b) => { + for (let i = 0; i < sortingColumns.length; i++) { + const column = sortingColumns[i]; + const aValue = a[column.id]; + const bValue = b[column.id]; + + if (aValue < bValue) return column.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return column.direction === 'asc' ? 1 : -1; + } + + return 0; + }); + + this.setState({ data, sortingColumns }); + }; + + setPageIndex = pageIndex => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageIndex }, + })); + + setPageSize = pageSize => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageSize }, + })); + + setVisibleColumns = visibleColumns => this.setState({ visibleColumns }); + + dummyIcon = () => ( + + ); + + render() { + const { data, pagination, sortingColumns } = this.state; + + return ( + { + const value = data[rowIndex][columnId]; + + if (columnId === 'custom' && isDetails) { + return ; + } + + return value; + }} + sorting={{ columns: sortingColumns, onSort: this.setSorting }} + pagination={{ + ...pagination, + pageSizeOptions: [5, 10, 25], + onChangeItemsPerPage: this.setPageSize, + onChangePage: this.setPageIndex, + }} + schemaDetectors={[ + { + type: 'favoriteFranchise', + detector(value) { + return value.toLowerCase() === 'star wars' || + value.toLowerCase() === 'star trek' + ? 1 + : 0; + }, + comparator(a, b, direction) { + const aValue = a.toLowerCase() === 'star wars'; + const bValue = b.toLowerCase() === 'star wars'; + if (aValue < bValue) return direction === 'asc' ? 1 : -1; + if (aValue > bValue) return direction === 'asc' ? -1 : 1; + return 0; + }, + sortTextAsc: 'Star wars-Star trek', + sortTextDesc: 'Star trek-Star wars', + icon: 'starFilled', + color: '#800080', + }, + ]} + popoverContents={{ + numeric: ({ cellContentsElement }) => { + // want to process the already-rendered cell value + const stringContents = cellContentsElement.textContent; + + // extract the groups-of-three digits that are right-aligned + return stringContents.replace(/((\d{3})+)$/, match => + // then replace each group of xyz digits with ,xyz + match.replace(/(\d{3})/g, ',$1') + ); + }, + }} + /> + ); + } +} diff --git a/src-docs/src/views/datagrid/styling.js b/src-docs/src/views/datagrid/styling.js new file mode 100644 index 00000000000..4078dfaabd7 --- /dev/null +++ b/src-docs/src/views/datagrid/styling.js @@ -0,0 +1,576 @@ +import React, { Component, Fragment } from 'react'; +import { fake } from 'faker'; + +import { + EuiDataGrid, + EuiButtonGroup, + EuiSpacer, + EuiFormRow, + EuiPopover, + EuiButton, + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, +} from '../../../../src/components/'; + +const columns = [ + { + id: 'avatar', + }, + { + id: 'name', + }, + { + id: 'email', + }, + { + id: 'city', + }, + { + id: 'country', + }, + { + id: 'account', + }, +]; + +const data = []; + +for (let i = 1; i < 5; i++) { + data.push({ + avatar: ( + + ), + name: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + email: fake('{{internet.email}}'), + city: fake('{{address.city}}'), + country: fake('{{address.country}}'), + account: fake('{{finance.account}}'), + }); +} + +export default class DataGrid 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.showSortSelectorOptions = [ + { + id: 'true', + label: 'True', + }, + { + id: 'false', + label: 'False', + }, + ]; + + this.showStyleSelectorOptions = [ + { + id: 'true', + label: 'True', + }, + { + id: 'false', + label: 'False', + }, + ]; + + this.showColumnSelectorOptions = [ + { + id: 'true', + label: 'True', + }, + { + id: 'false', + label: 'False', + }, + ]; + + this.showFullScreenSelectorOptions = [ + { + id: 'true', + label: 'True', + }, + { + id: 'false', + label: 'False', + }, + ]; + + this.showToolbarOptions = [ + { + id: 'true', + label: 'True', + }, + { + id: 'false', + label: 'False', + }, + ]; + + this.toolbarPropTypeIsBooleanOptions = [ + { + id: 'true', + label: 'Boolean', + }, + { + id: 'false', + label: 'Object', + }, + ]; + + this.state = { + borderSelected: 'none', + fontSizeSelected: 's', + cellPaddingSelected: 's', + stripesSelected: true, + rowHoverSelected: 'none', + isPopoverOpen: false, + isToolbarPopoverOpen: false, + headerSelected: 'underline', + showSortSelector: true, + showStyleSelector: true, + showColumnSelector: true, + showFullScreenSelector: true, + showToolbar: true, + toolbarPropTypeIsBoolean: true, + + pagination: { + pageIndex: 0, + pageSize: 50, + }, + + visibleColumns: columns.map(({ id }) => id), + }; + } + + onBorderChange = optionId => { + this.setState({ + borderSelected: optionId, + }); + }; + + onFontSizeChange = optionId => { + this.setState({ + fontSizeSelected: optionId, + }); + }; + + onCellPaddingChange = optionId => { + this.setState({ + cellPaddingSelected: optionId, + }); + }; + + onStripesChange = optionId => { + this.setState({ + stripesSelected: optionId === 'true', + }); + }; + + onRowHoverChange = optionId => { + this.setState({ + rowHoverSelected: optionId, + }); + }; + + onHeaderChange = optionId => { + this.setState({ + headerSelected: optionId, + }); + }; + + onShowSortSelectorChange = optionId => { + this.setState({ + showSortSelector: optionId === 'true', + }); + }; + + onShowStyleSelectorChange = optionId => { + this.setState({ + showStyleSelector: optionId === 'true', + }); + }; + + onShowColumnSelectorChange = optionId => { + this.setState({ + showColumnSelector: optionId === 'true', + }); + }; + + onShowFullScreenSelectorChange = optionId => { + this.setState({ + showFullScreenSelector: optionId === 'true', + }); + }; + + onShowToolbarChange = optionId => { + this.setState({ + showToolbar: optionId === 'true', + }); + }; + + onToolbarPropTypeIsBooleanChange = optionId => { + this.setState({ + toolbarPropTypeIsBoolean: optionId === 'true', + }); + }; + + onPopoverButtonClick() { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + } + + onToolbarPopoverButtonClick() { + this.setState({ + isToolbarPopoverOpen: !this.state.isPopoverOpen, + }); + } + + closePopover() { + this.setState({ + isPopoverOpen: false, + }); + } + + closeToolbarPopover() { + this.setState({ + isToolbarPopoverOpen: false, + }); + } + + setPageIndex = pageIndex => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageIndex }, + })); + + setPageSize = pageSize => + this.setState(({ pagination }) => ({ + pagination: { ...pagination, pageSize }, + })); + + setVisibleColumns = visibleColumns => this.setState({ visibleColumns }); + + render() { + const { pagination } = this.state; + + const styleButton = ( + + gridStyle options + + ); + + const toolbarButton = ( + + toolbarVisibility options + + ); + + const toolbarVisibilityOptions = { + showColumnSelector: this.state.showColumnSelector, + showStyleSelector: this.state.showStyleSelector, + showSortSelector: this.state.showSortSelector, + showFullScreenSelector: this.state.showFullScreenSelector, + }; + + let toolbarConfig; + + if (this.state.toolbarPropTypeIsBoolean) { + toolbarConfig = this.state.showToolbar; + } else { + toolbarConfig = toolbarVisibilityOptions; + } + + return ( +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + + + {this.state.toolbarPropTypeIsBoolean === false ? ( + + + + + + + + + + + + + + + + + + ) : ( + + + + )} +
+
+
+
+ + + + data[rowIndex][columnId]} + pagination={{ + ...pagination, + pageSizeOptions: [5, 10, 25], + onChangeItemsPerPage: this.setPageSize, + onChangePage: this.setPageIndex, + }} + /> +
+ ); + } +} diff --git a/src-docs/src/views/icon/icons.js b/src-docs/src/views/icon/icons.js index 9b788b9c937..0ddb2017247 100644 --- a/src-docs/src/views/icon/icons.js +++ b/src-docs/src/views/icon/icons.js @@ -20,7 +20,7 @@ import { EuiCopy, } from '../../../../src/components'; -const iconTypes = [ +export const iconTypes = [ 'alert', 'apmTrace', 'apps', @@ -66,6 +66,7 @@ const iconTypes = [ 'empty', 'exit', 'expand', + 'expandMini', 'exportAction', 'eye', 'eyeClosed', @@ -169,6 +170,9 @@ const iconTypes = [ 'submodule', 'symlink', 'tableOfContents', + 'tableDensityExpanded', + 'tableDensityCompact', + 'tableDensityNormal', 'tag', 'tear', 'temperature', diff --git a/src-docs/src/views/pagination/customizable_pagination.js b/src-docs/src/views/pagination/customizable_pagination.js index db87fd79ce3..7c1ebcd35da 100644 --- a/src-docs/src/views/pagination/customizable_pagination.js +++ b/src-docs/src/views/pagination/customizable_pagination.js @@ -95,7 +95,6 @@ export default class extends Component { formatDate(date, 'dobLong'), sortable: true, }, diff --git a/src-docs/src/views/tables/sorting/sorting.js b/src-docs/src/views/tables/sorting/sorting.js index 577db27cad9..470cd437ed5 100644 --- a/src-docs/src/views/tables/sorting/sorting.js +++ b/src-docs/src/views/tables/sorting/sorting.js @@ -133,7 +133,7 @@ export class Table extends Component { ), - dataType: 'date', + schema: 'date', render: date => formatDate(date, 'dobLong'), sortable: true, }, @@ -173,7 +173,7 @@ export class Table extends Component { ), - dataType: 'boolean', + schema: 'boolean', sortable: true, render: online => { const color = online ? 'success' : 'danger'; diff --git a/src-docs/webpack.config.js b/src-docs/webpack.config.js index d253a2054e3..d96a1006a38 100644 --- a/src-docs/webpack.config.js +++ b/src-docs/webpack.config.js @@ -40,12 +40,12 @@ const webpackConfig = { rules: [ { test: /\.(js|tsx?)$/, - loaders: useCache(['babel-loader']), + loaders: useCache(['babel-loader']), // eslint-disable-line react-hooks/rules-of-hooks exclude: /node_modules/, }, { test: /\.scss$/, - loaders: useCache([ + loaders: useCache([ // eslint-disable-line react-hooks/rules-of-hooks, prettier/prettier 'style-loader/useable', 'css-loader', 'postcss-loader', @@ -55,7 +55,7 @@ const webpackConfig = { }, { test: /\.css$/, - loaders: useCache(['style-loader/useable', 'css-loader']), + loaders: useCache(['style-loader/useable', 'css-loader']), // eslint-disable-line react-hooks/rules-of-hooks exclude: /node_modules/, }, { diff --git a/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap b/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap index 4ed2ccd557e..d0c01da29c0 100644 --- a/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap +++ b/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap @@ -327,6 +327,7 @@ exports[`EuiInMemoryTable behavior pagination 1`] = ` button={
+
+
+
+
+
+ + + + +
+
+
+`; + +exports[`EuiDataGrid rendering renders custom column headers 1`] = ` +Array [ +
, +
, +
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ Column A +
+
+
+
+
+ More Elements +
+
+
+
+
+
+
+
+
+
+
+

+ + Row: 1, Column: 1: + +

+
+ 0, A +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

+ + Row: 1, Column: 2: + +

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

+ + Row: 2, Column: 1: + +

+
+ 1, A +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

+ + Row: 2, Column: 2: + +

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

+ + Row: 3, Column: 1: + +

+
+ 2, A +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

+ + Row: 3, Column: 2: + +

+
+ 2, B +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
, +
, +] +`; + +exports[`EuiDataGrid rendering renders with common and div attributes 1`] = ` +Array [ +
, +
, +
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ A +
+
+
+
+ B +
+
+
+
+
+
+
+
+
+
+

+ + Row: 1, Column: 1: + +

+
+ 0, A +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

+ + Row: 1, Column: 2: + +

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

+ + Row: 2, Column: 1: + +

+
+ 1, A +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

+ + Row: 2, Column: 2: + +

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

+ + Row: 3, Column: 1: + +

+
+ 2, A +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

+ + Row: 3, Column: 2: + +

+
+ 2, B +
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
, +
, +] +`; diff --git a/src/components/datagrid/_data_grid.scss b/src/components/datagrid/_data_grid.scss new file mode 100644 index 00000000000..314140b5393 --- /dev/null +++ b/src/components/datagrid/_data_grid.scss @@ -0,0 +1,104 @@ +.euiDataGrid { + display: flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; + height: 100%; +} + +.euiDataGrid--fullScreen { + height: 100%; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $euiZModal; + background: $euiColorEmptyShade; + + .euiDataGrid__pagination { + padding-bottom: $euiSizeXS; + background: $euiColorLightestShade; + border-top: $euiBorderThin; + } +} + +.euiDataGrid__content { + @include euiScrollBar; + @include euiScrollBar; + + height: 100%; + overflow-y: auto; + font-feature-settings: 'tnum' 1; // Tabular numbers + overflow-x: auto; + scroll-padding: 0; + max-width: 100%; + width: 100%; +} + +.euiDataGrid__controls { + background: $euiPageBackgroundColor; + position: relative; + z-index: 2; + border: $euiBorderThin; + padding: $euiSizeXS; + flex-grow: 0; + + > * { + margin-left: $euiSizeXS / 2; + } +} + +.euiDataGrid__controlBtn { + border-radius: $euiBorderRadius; + + &:focus { + background: shadeOrTint($euiColorLightestShade, 10%, 10%); + } +} + +.euiDataGrid__controlBtn--active, +.euiDataGrid__controlBtn--active:focus { + font-weight: $euiFontWeightSemiBold; + color: $euiColorFullShade; +} + +@include euiDataGridStyles(bordersNone) { + .euiDataGrid__controls { + border: none; + background: $euiColorEmptyShade; + } +} + +@include euiDataGridStyles(bordersHorizontal) { + .euiDataGrid__controls { + border-right: none; + border-left: none; + border-top: none; + background: $euiColorEmptyShade; + } +} + +.euiDataGrid__pagination { + + padding-top: $euiSizeXS; + flex-grow: 0; +} + +.euiDataGrid__verticalScroll { + flex-grow: 1; + overflow-y: hidden; + height: 100%; +} + +.euiDataGrid__overflow { + overflow-y: hidden; + height: 100%; + background: $euiPageBackgroundColor; +} + +// This is used to remove extra scrollbars on the body when fullscreen is enabled +.euiDataGrid__restrictBody { + height: 100vh; + overflow: hidden; +} diff --git a/src/components/datagrid/_data_grid_column_resizer.scss b/src/components/datagrid/_data_grid_column_resizer.scss new file mode 100644 index 00000000000..f46bce3d618 --- /dev/null +++ b/src/components/datagrid/_data_grid_column_resizer.scss @@ -0,0 +1,48 @@ + // Resizer straddles the column border and is an invisible hitzone for dragging +.euiDataGridColumnResizer { + position: absolute; + top: 0; + right: -$euiSizeS; + height: 100%; + width: $euiSize; + cursor: ew-resize; + opacity: 0; + z-index: 2; + + // Center a vertical line within the button above + &:after { + content: ''; + position: absolute; + left: $euiSizeS - 1px; + top: 0; + bottom: 0; + width: $euiDataGridColumnResizerWidth; + background-color: $euiColorPrimary; + } + + &:hover, + &:active { + opacity: 1; + + ~ .euiDataGridHeaderCell__content { + user-select: none; + } + } +} + +// This is important. Because the resizer sits in the negative space to the right of the column +// it can cause the full grid to be a few pixels longer than it actually is. So for the last one +// we don't use negative positioning and the borders from the cell will match the container. +@include euiDataGridHeaderCell { + &:last-child { + + .euiDataGridColumnResizer { + right: 0; + + &:after { + left: auto; + right: 0; + } + } + } +} diff --git a/src/components/datagrid/_data_grid_column_selector.scss b/src/components/datagrid/_data_grid_column_selector.scss new file mode 100644 index 00000000000..a339a0de063 --- /dev/null +++ b/src/components/datagrid/_data_grid_column_selector.scss @@ -0,0 +1,26 @@ +.euiDataGridColumnSelector__item { + padding: $euiSizeXS; + + &-isDragging { + @include euiBottomShadow; + background: $euiColorEmptyShade; + } +} + +// Because we only want this to scroll vertically, we need to offset inner euiFlexGroup negative padding by adding padding +.euiDataGridColumnSelector__columnList { + @include euiYScrollWithShadows; + max-height: 400px; + padding: $euiSizeS; + margin: 0 (-$euiSizeS); +} + +.euiDataGridColumnSelectorPopover { + // Hack because the fixed positions of drag and drop don't work inside of transformed elements + // sass-lint:disable-block no-important + transform: none !important; + transition: none !important; + margin-top: -$euiSizeS; + // IE11 needs a min-width + min-width: $euiSize * 12; +} diff --git a/src/components/datagrid/_data_grid_column_sorting.scss b/src/components/datagrid/_data_grid_column_sorting.scss new file mode 100644 index 00000000000..30a9983e605 --- /dev/null +++ b/src/components/datagrid/_data_grid_column_sorting.scss @@ -0,0 +1,56 @@ +.euiDataGridColumnSorting__item { + + &-isDragging { + @include euiBottomShadow; + background: $euiColorEmptyShade; + } +} + +.euiDataGridColumnSortingPopover { + // Hack because the fixed positions of drag and drop don't work inside of transformed elements + // sass-lint:disable-block no-important + transform: none !important; + transition: none !important; + margin-top: -$euiSizeS; + // IE11 needs a min-width + min-width: $euiSize * 12; +} + +.euiDataGridColumnSorting__button { + // The button for sorting needs to be a bit more compact + // sass-lint:disable-block no-important + height: $euiSize + $euiSizeXS !important; + width: $euiSize + $euiSizeXS !important; + padding: $euiSizeXS / 2 !important; +} + +.euiDataGridColumnSorting__fieldList { + @include euiYScrollWithShadows; + max-height: 300px; +} + +.euiDataGridColumnSorting__field { + display: block; + padding: $euiSizeXS; + width: 100%; + + &:focus { + background: $euiFocusBackgroundColor; + text-decoration: underline; + } +} + +.euiDataGridColumnSorting__orderButtons { + padding-left: $euiSizeL; + + .euiDataGridColumnSorting__order { + min-width: 200px; + border: none; + + // Hack to overwrite some nested, unreachable component code with button groups + // sass-lint:disable-block no-important + button { + font-size: $euiFontSizeXS !important; + } + } +} diff --git a/src/components/datagrid/_data_grid_data_row.scss b/src/components/datagrid/_data_grid_data_row.scss new file mode 100644 index 00000000000..ceebbb98758 --- /dev/null +++ b/src/components/datagrid/_data_grid_data_row.scss @@ -0,0 +1,207 @@ +.euiDataGridRow { + display: inline-flex; + min-width: 100%; // Needed to prevent wraps. Inline flex is tricky +} + +@include euiDataGridRowCell { + @include euiFontSizeS; + + padding: $euiDataGridCellPaddingM; + border-right: $euiDataGridVerticalBorder; + border-bottom: $euiBorderThin; + flex: 0 0 auto; + background: $euiColorEmptyShade; + position: relative; + align-items: center; + display: flex; + + // Hack to allow for all the focus guard stuff + > * { + max-width: 100%; + width: 100%; + } + + &:first-of-type { + border-left: $euiBorderThin; + } + + &:last-of-type { + border-right-color: $euiBorderColor; + } + + &:focus { + border: 1px solid transparent; + margin-top: -1px; + box-shadow: 0 0 0 2px $euiFocusRingColor; + border-radius: 1px; + // Needed so it sits above potential striping in the next row + z-index: 2; + + .euiDataGridRowCell__expandButton { + margin-left: $euiDataGridCellPaddingM; + } + + .euiDataGridRowCell__expandButtonIcon { + display: flex; + width: inherit; + visibility: visible; + } + } + + &:focus:not(:first-of-type) { + // 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--boolean { + text-transform: capitalize; + } +} + +.euiDataGridRowCell__content { + @include euiTextTruncate; + overflow: hidden; + white-space: nowrap; +} + +.euiDataGridRowCell__popover { + @include euiScrollBar; + overflow: auto; + // sass-lint:disable-block no-important + max-width: 400px !important; + max-height: 400px !important; +} + +.euiDataGridRowCell__expand { + width: 100%; + max-width: 100%; +} + +.euiDataGridRowCell__expandFlex { + display: flex; + align-items: center; +} + +.euiDataGridRowCell__expandContent { + @include euiTextTruncate; + overflow: hidden; + white-space: nowrap; + flex-grow: 1; +} + + +.euiDataGridRowCell__expandButton { + display: flex; + flex-grow: 0; + + &-isActive, + &:focus { + margin-left: $euiDataGridCellPaddingM; + } +} + +.euiDataGridRowCell__expandButtonIcon { + height: $euiSizeM; + min-height: $euiSizeM; + background: $euiColorPrimary; + color: $euiColorGhost; + border-radius: $euiBorderRadius / 2; + padding: 0; + width: 0; + min-width: 0; + overflow: hidden; + visibility: hidden; + + &-isActive, + &:focus { + width: inherit; + visibility: visible; + background: $euiColorPrimary; + } +} + +.euiDataGridRowCell__truncate { + @include euiTextTruncate; + overflow: hidden; + white-space: nowrap; +} + +// Row highlights +@include euiDataGridStyles(rowHoverHighlight) { + .euiDataGridRow:hover { + @include euiDataGridRowCell { + // sass-lint:disable-block no-important + // Needed to overtake striping + background-color: $euiColorHighlight !important; + } + } +} + +// Stripes +@include euiDataGridStyles(stripes) { + .euiDataGridRow:nth-child(odd) { + @include euiDataGridRowCell { + background: $euiColorLightestShade; + } + } +} + +// Border alternates +@include euiDataGridStyles(bordersNone) { + @include euiDataGridRowCell { + // sass-lint:disable-block no-important + border-color: transparent !important; + } +} + +@include euiDataGridStyles(bordersHorizontal) { + @include euiDataGridRowCell { + border-right-color: transparent; + border-left-color: transparent; + } +} + +// Font alternates +@include euiDataGridStyles(fontSizeSmall) { + @include euiDataGridRowCell { + @include euiFontSizeXS; + } +} + +@include euiDataGridStyles(fontSizeLarge) { + @include euiDataGridRowCell { + @include euiFontSize; + } +} + +// Padding alternates +@include euiDataGridStyles(paddingSmall) { + @include euiDataGridRowCell { + padding: $euiDataGridCellPaddingS; + + &:focus:not(:first-of-type) { + // Needed because the focus state adds a border, which needs to be subtracted from padding + padding-left: $euiDataGridCellPaddingS - 1px; + } + } +} + +@include euiDataGridStyles(paddingLarge) { + @include euiDataGridRowCell { + padding: $euiDataGridCellPaddingL; + + &:focus:not(:first-of-type) { + // Needed because the focus state adds a border, which needs to be subtracted from padding + padding-left: $euiDataGridCellPaddingL - 1px; + } + } +} diff --git a/src/components/datagrid/_data_grid_header_row.scss b/src/components/datagrid/_data_grid_header_row.scss new file mode 100644 index 00000000000..7f0116c318c --- /dev/null +++ b/src/components/datagrid/_data_grid_header_row.scss @@ -0,0 +1,128 @@ +.euiDataGridHeader { + display: inline-flex; + min-width: 100%; // Needed to prevent wraps. Inline flex is tricky +} + +@include euiDataGridHeaderCell { + @include euiFontSizeS; + + font-weight: $euiFontWeightBold; + padding: $euiDataGridCellPaddingM; + flex: 0 0 auto; + position: relative; + align-items: center; + display: flex; + + > * { + max-width: 100%; + width: 100%; + } + + .euiDataGridHeaderCell__content { + @include euiTextTruncate; + overflow: hidden; + white-space: nowrap; + } + + &.euiDataGridHeaderCell--numeric { + text-align: right; + } + + &.euiDataGridHeaderCell--currency { + text-align: right; + } + + &:focus { + border: 1px solid transparent; + box-shadow: 0 0 0 2px $euiFocusRingColor; + border-radius: 1px; + // Needed so it sits above the other rows + z-index: 2; + } +} + +// Header alternates +// Often these need extra specificity because they need to gracefully clash with the border settings + +@include euiDataGridStyles(headerUnderline) { + @include euiDataGridHeaderCell { + border-top: none; + border-left: none; + border-right: none; + border-bottom: $euiBorderThick; + border-bottom-color: $euiTextColor; + } +} + +@include euiDataGridStyles(bordersNone, headerUnderline) { + @include euiDataGridHeaderCell { + border-bottom: $euiBorderThick; + border-color: $euiTextColor; + } +} + +@include euiDataGridStyles(headerShade) { + @include euiDataGridHeaderCell { + background: tintOrShade($euiColorLightestShade, 0%, 10%); + } +} + +@include euiDataGridStyles(headerShade, bordersAll) { + @include euiDataGridHeaderCell { + border-right: $euiBorderThin; + border-bottom: $euiBorderThin; + border-left: none; + + &:first-of-type { + border-left: $euiBorderThin; + } + } +} + +@include euiDataGridStyles(headerShade, bordersHorizontal) { + @include euiDataGridHeaderCell { + border-top: none; + border-bottom: $euiBorderThin; + } +} + +// Border alternates +@include euiDataGridStyles(bordersNone) { + @include euiDataGridHeaderCell { + border: none; + } +} + +@include euiDataGridStyles(borderhorizontal) { + @include euiDataGridHeaderCell { + border-top: none; + border-right: none; + border-left: none; + } +} + +// Font alternates +@include euiDataGridStyles(fontSizeSmall) { + @include euiDataGridHeaderCell { + @include euiFontSizeXS; + } +} + +@include euiDataGridStyles(fontSizeLarge) { + @include euiDataGridHeaderCell { + @include euiFontSize; + } +} + +// Padding alternates +@include euiDataGridStyles(paddingSmall) { + @include euiDataGridHeaderCell { + padding: $euiDataGridCellPaddingS; + } +} + +@include euiDataGridStyles(paddingLarge) { + @include euiDataGridHeaderCell { + padding: $euiDataGridCellPaddingL; + } +} diff --git a/src/components/datagrid/_index.scss b/src/components/datagrid/_index.scss new file mode 100644 index 00000000000..f7493415497 --- /dev/null +++ b/src/components/datagrid/_index.scss @@ -0,0 +1,8 @@ +@import 'variables'; +@import 'mixins'; +@import 'data_grid'; +@import 'data_grid_header_row'; +@import 'data_grid_column_resizer'; +@import 'data_grid_data_row'; +@import 'data_grid_column_selector'; +@import 'data_grid_column_sorting'; diff --git a/src/components/datagrid/_mixins.scss b/src/components/datagrid/_mixins.scss new file mode 100644 index 00000000000..1d1c245fecb --- /dev/null +++ b/src/components/datagrid/_mixins.scss @@ -0,0 +1,53 @@ +$euiDataGridPrefix: '.euiDataGrid--'; + +// Things can get nesty so it's nice to have an approved list that match our typings +$euiDataGridStyles: ( + 'bordersAll' + 'bordersNone' + 'bordersHorizontal' + 'paddingSmall' + 'paddingMedium' + 'paddingLarge' + 'stripes' + 'rowHoverNone' + 'rowHoverHighlight' + 'headerShade' + 'headerUnderline' + 'fontSizeSmall' + 'fontSizeLarge' +); + +// All this does is take some of the above and make a sibling selector +// selector(headerShade, fontSizeLarge) +// will produce `.euiDataGrid--headerShade.euiDataGrid--fontSizeLarge +@function euiDataGridSelector($selectorKeys...) { + $selectorList: ''; + @each $selector in $selectorKeys { + // Spit out warnings when you make typos! + @if (index($euiDataGridStyles, $selector != true)) { + @error '#{$selector} is not an allowed value in the euiDataGridStyles() mixin'; + } + $selctorValue: #{$euiDataGridPrefix}#{$selector}; + $selectorList: str-insert($selectorList, $selctorValue, 1000); + } + + @return $selectorList; +} + +@mixin euiDataGridStyles($selectorKeys...) { + #{euiDataGridSelector($selectorKeys...)} { + @content; + } +} + +@mixin euiDataGridHeaderCell { + .euiDataGridHeaderCell { + @content; + } +} + +@mixin euiDataGridRowCell { + .euiDataGridRowCell { + @content; + } +} \ No newline at end of file diff --git a/src/components/datagrid/_variables.scss b/src/components/datagrid/_variables.scss new file mode 100644 index 00000000000..0ea14821854 --- /dev/null +++ b/src/components/datagrid/_variables.scss @@ -0,0 +1,6 @@ +$euiDataGridColumnResizerWidth: 3px; // Odd number because it straddles a border + +$euiDataGridCellPaddingS: $euiSizeXS; +$euiDataGridCellPaddingM: $euiSizeM / 2; +$euiDataGridCellPaddingL: $euiSizeS; +$euiDataGridVerticalBorder: solid 1px tintOrShade($euiBorderColor, 60%, 30%); diff --git a/src/components/datagrid/column_selector.tsx b/src/components/datagrid/column_selector.tsx new file mode 100644 index 00000000000..b65d406ab6c --- /dev/null +++ b/src/components/datagrid/column_selector.tsx @@ -0,0 +1,209 @@ +import React, { + Fragment, + useState, + ReactChild, + ReactElement, + ChangeEvent, +} from 'react'; +import classNames from 'classnames'; +import { + EuiDataGridColumn, + EuiDataGridColumnVisibility, +} from './data_grid_types'; +// @ts-ignore-next-line +import { EuiPopover, EuiPopoverFooter, EuiPopoverTitle } from '../popover'; +import { EuiI18n } from '../i18n'; +// @ts-ignore-next-line +import { EuiButtonEmpty } from '../button'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; +// @ts-ignore-next-line +import { EuiSwitch, EuiFieldText } from '../form'; +import { + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + euiDragDropReorder, +} from '../drag_and_drop'; +import { DropResult } from 'react-beautiful-dnd'; +import { EuiIcon } from '../icon'; + +export const useColumnSelector = ( + availableColumns: EuiDataGridColumn[], + columnVisibility: EuiDataGridColumnVisibility +): [ReactElement, EuiDataGridColumn[]] => { + const [sortedColumns, setSortedColumns] = useState(() => + availableColumns.map(({ id }) => id) + ); + + const { visibleColumns, setVisibleColumns } = columnVisibility; + const visibleColumnIds = new Set(visibleColumns); + + const [isOpen, setIsOpen] = useState(false); + + function onDragEnd({ + source: { index: sourceIndex }, + destination, + }: DropResult) { + const destinationIndex = destination!.index; + const nextSortedColumns = euiDragDropReorder( + sortedColumns, + sourceIndex, + destinationIndex + ); + setSortedColumns(nextSortedColumns); + + const nextVisibleColumns = nextSortedColumns.filter(id => + visibleColumnIds.has(id) + ); + setVisibleColumns(nextVisibleColumns); + } + + const numberOfHiddenFields = availableColumns.length - visibleColumns.length; + + const [columnSearchText, setColumnSearchText] = useState(''); + + const controlBtnClasses = classNames('euiDataGrid__controlBtn', { + 'euiDataGrid__controlBtn--active': numberOfHiddenFields > 0, + }); + + const filteredColumns = sortedColumns.filter( + id => id.toLowerCase().indexOf(columnSearchText.toLowerCase()) !== -1 + ); + const isDragEnabled = columnSearchText.length === 0; // only allow drag-and-drop when not filtering columns + + const columnSelector = ( + setIsOpen(false)} + anchorPosition="downLeft" + ownFocus + panelPaddingSize="s" + panelClassName="euiDataGridColumnSelectorPopover" + button={ + + {([button, buttonActive]: ReactChild[]) => ( + setIsOpen(!isOpen)}> + {numberOfHiddenFields > 0 + ? `${numberOfHiddenFields} ${buttonActive}` + : button} + + )} + + }> +
+ + + {([search, searchcolumns]: string[]) => ( + ) => + setColumnSearchText(e.currentTarget.value) + } + /> + )} + + + + + + {filteredColumns.map((id, index) => ( + + {(provided, state) => ( +
+ + + ) => { + const nextVisibleColumns = sortedColumns.filter( + columnId => + checked + ? visibleColumnIds.has(columnId) || + id === columnId + : visibleColumnIds.has(columnId) && + id !== columnId + ); + setVisibleColumns(nextVisibleColumns); + }} + /> + + {isDragEnabled && ( + + + + )} + +
+ )} +
+ ))} +
+
+
+
+ + + + setVisibleColumns(sortedColumns)}> + + + + + setVisibleColumns([])}> + + + + + +
+ ); + + const orderedVisibleColumns = visibleColumns + .map( + columnId => + availableColumns.find(({ id }) => id === columnId) as EuiDataGridColumn // cast to avoid `undefined`, it filters those out next + ) + .filter(column => column != null); + return [columnSelector, orderedVisibleColumns]; +}; diff --git a/src/components/datagrid/column_sorting.tsx b/src/components/datagrid/column_sorting.tsx new file mode 100644 index 00000000000..f08614f2e18 --- /dev/null +++ b/src/components/datagrid/column_sorting.tsx @@ -0,0 +1,274 @@ +import React, { + Fragment, + useState, + ReactChild, + ReactNode, + useEffect, +} from 'react'; +import classNames from 'classnames'; +import { EuiDataGridColumn, EuiDataGridSorting } from './data_grid_types'; +import { EuiPopover, EuiPopoverFooter } from '../popover'; +import { EuiI18n } from '../i18n'; +import { EuiText } from '../text'; +import { EuiButtonEmpty } from '../button'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { + EuiDragDropContext, + EuiDroppable, + euiDragDropReorder, +} from '../drag_and_drop'; +import { DropResult } from 'react-beautiful-dnd'; +import { EuiIcon } from '../icon'; +import { EuiDataGridColumnSortingDraggable } from './column_sorting_draggable'; +import { + EuiDataGridSchema, + EuiDataGridSchemaDetector, + getDetailsForSchema, +} from './data_grid_schema'; +import { palettes } from '../../services/color/eui_palettes'; + +export const useColumnSorting = ( + columns: EuiDataGridColumn[], + sorting: EuiDataGridSorting | undefined, + schema: EuiDataGridSchema, + schemaDetectors: EuiDataGridSchemaDetector[] +): ReactNode => { + const [isOpen, setIsOpen] = useState(false); + const [avilableColumnsisOpen, setAvailableColumnsIsOpen] = useState(false); + const defaultSchemaColor: string = palettes.euiPaletteColorBlind.colors[4]; + + // prune any non-existant/hidden columns from sorting + useEffect(() => { + if (sorting) { + const nextSortingColumns: EuiDataGridSorting['columns'] = []; + + const availableColumnIds = new Set(columns.map(({ id }) => id)); + for (let i = 0; i < sorting.columns.length; i++) { + const column = sorting.columns[i]; + if (availableColumnIds.has(column.id)) { + nextSortingColumns.push(column); + } + } + + // if the column array lengths differ then the sorting columns have been pruned + if (nextSortingColumns.length !== sorting.columns.length) { + sorting.onSort(nextSortingColumns); + } + } + }, [columns, sorting]); + + if (sorting == null) return [null]; + + const activeColumnIds = new Set(sorting.columns.map(({ id }) => id)); + const { inactiveColumns } = columns.reduce<{ + activeColumns: EuiDataGridColumn[]; + inactiveColumns: EuiDataGridColumn[]; + }>( + (acc, column) => { + if (activeColumnIds.has(column.id)) { + acc.activeColumns.push(column); + } else { + acc.inactiveColumns.push(column); + } + return acc; + }, + { + activeColumns: [], + inactiveColumns: [], + } + ); + + function onDragEnd({ + source: { index: sourceIndex }, + destination, + }: DropResult) { + const destinationIndex = destination!.index; + const nextColumns = euiDragDropReorder( + sorting!.columns, + sourceIndex, + destinationIndex + ); + + sorting!.onSort(nextColumns); + } + + const controlBtnClasses = classNames('euiDataGrid__controlBtn', { + 'euiDataGrid__controlBtn--active': sorting.columns.length > 0, + }); + + const numberOfSortedFields = sorting.columns.length; + + const columnSorting = ( + setIsOpen(false)} + anchorPosition="downLeft" + ownFocus + panelPaddingSize="s" + panelClassName="euiDataGridColumnSortingPopover" + button={ + + {([button, buttonActive]: ReactChild[]) => ( + setIsOpen(!isOpen)}> + {numberOfSortedFields > 0 + ? `${numberOfSortedFields} ${buttonActive}` + : button} + + )} + + }> + {sorting.columns.length > 0 ? ( +
+ + + + {sorting.columns.map(({ id, direction }, index) => { + return ( + + ); + })} + + + +
+ ) : ( + +

+ +

+
+ )} + {(inactiveColumns.length > 0 || sorting.columns.length > 0) && ( + + + + {inactiveColumns.length > 0 && ( + setAvailableColumnsIsOpen(false)} + anchorPosition="downLeft" + ownFocus + panelPaddingSize="s" + button={ + + setAvailableColumnsIsOpen(!avilableColumnsisOpen) + }> + + + }> + + {(sortFieldAriaLabel: string) => ( +
+ {inactiveColumns.map(({ id }) => ( + + ))} +
+ )} +
+
+ )} +
+ {sorting.columns.length > 0 ? ( + + sorting.onSort([])}> + + + + ) : null} +
+
+ )} +
+ ); + + return columnSorting; +}; diff --git a/src/components/datagrid/column_sorting_draggable.tsx b/src/components/datagrid/column_sorting_draggable.tsx new file mode 100644 index 00000000000..65d80143aeb --- /dev/null +++ b/src/components/datagrid/column_sorting_draggable.tsx @@ -0,0 +1,180 @@ +import React, { FunctionComponent, ReactChild } from 'react'; +import { EuiI18n } from '../i18n'; +import { EuiDraggable } from '../drag_and_drop'; +import { EuiScreenReaderOnly } from '../accessibility'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiButtonIcon, EuiButtonGroup } from '../button'; +import { EuiIcon } from '../icon'; +import { EuiText } from '../text'; +import { + getDetailsForSchema, + EuiDataGridSchema, + EuiDataGridSchemaDetector, +} from './data_grid_schema'; +import { EuiDataGridSorting } from './data_grid_types'; + +export interface EuiDataGridColumnSortingDraggableProps { + id: string; + direction: string; + index: number; + sorting: EuiDataGridSorting; + schema: EuiDataGridSchema; + schemaDetectors: EuiDataGridSchemaDetector[]; + defaultSchemaColor: string; +} + +export const EuiDataGridColumnSortingDraggable: FunctionComponent< + EuiDataGridColumnSortingDraggableProps +> = ({ + id, + direction, + index, + sorting, + schema, + schemaDetectors, + defaultSchemaColor, + ...rest +}) => { + const textSortAsc = + schema.hasOwnProperty(id) && schema[id].columnType != null ? ( + getDetailsForSchema(schemaDetectors, schema[id].columnType).sortTextAsc + ) : ( + + ); + + const textSortDesc = + schema.hasOwnProperty(id) && schema[id].columnType != null ? ( + getDetailsForSchema(schemaDetectors, schema[id].columnType).sortTextDesc + ) : ( + + ); + + const toggleOptions = [ + { + id: `${id}Asc`, + value: 'asc', + label: textSortAsc, + 'data-test-subj': `euiDataGridColumnSorting-sortColumn-${id}-asc`, + }, + { + id: `${id}Desc`, + value: 'desc', + label: textSortDesc, + 'data-test-subj': `euiDataGridColumnSorting-sortColumn-${id}-desc`, + }, + ]; + + return ( + + {(provided, state) => ( +
+ +

+ + {(activeSortLabel: ReactChild) => ( + + {id} {activeSortLabel} + + )} + +

+
+ + + + {(removeSortLabel: ReactChild) => ( + { + const nextColumns = [...sorting.columns]; + const columnIndex = nextColumns + .map(({ id }) => id) + .indexOf(id); + nextColumns.splice(columnIndex, 1); + sorting.onSort(nextColumns); + }} + /> + )} + + + + + + + + +

{id}

+
+
+ + + {(toggleLegend: ReactChild) => ( + { + const nextColumns = [...sorting.columns]; + const columnIndex = nextColumns + .map(({ id }) => id) + .indexOf(id); + nextColumns.splice(columnIndex, 1, { + id, + direction, + }); + sorting.onSort(nextColumns); + }} + /> + )} + + + +
+ +
+
+
+
+ )} +
+ ); +}; diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx new file mode 100644 index 00000000000..dfcff2741f7 --- /dev/null +++ b/src/components/datagrid/data_grid.test.tsx @@ -0,0 +1,1716 @@ +import React, { useEffect, useState } from 'react'; +import { mount, ReactWrapper, render } from 'enzyme'; +import { EuiDataGrid } from './'; +import { + findTestSubject, + requiredProps, + takeMountedSnapshot, +} from '../../test'; +import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; +import { keyCodes } from '../../services'; +import { act } from 'react-dom/test-utils'; +import cheerio from 'cheerio'; + +jest.mock('../../services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'htmlId', +})); + +function getFocusableCell(component: ReactWrapper) { + return findTestSubject(component, 'dataGridRowCell').find('[tabIndex=0]'); +} + +function extractGridData(datagrid: ReactWrapper) { + const rows: string[][] = []; + + const headerCells = findTestSubject(datagrid, 'dataGridHeaderCell', '|='); + const headerRow: string[] = []; + headerCells.forEach((cell: any) => + headerRow.push( + cell.find('[className="euiDataGridHeaderCell__content"]').text() + ) + ); + rows.push(headerRow); + + const gridRows = findTestSubject(datagrid, 'dataGridRow'); + gridRows.forEach((row: any) => { + const rowContent: string[] = []; + const cells = findTestSubject(row, 'dataGridRowCell'); + cells.forEach((cell: any) => + rowContent.push(cell.find('[data-test-subj="cell-content"]').text()) + ); + rows.push(rowContent); + }); + + return rows; +} + +function extractColumnWidths(datagrid: ReactWrapper) { + return (findTestSubject(datagrid, 'dataGridHeaderCell', '|=') as ReactWrapper< + any + >).reduce((widths: { [key: string]: number }, cell) => { + const [, columnId] = cell + .props() + ['data-test-subj'].match(/dataGridHeaderCell-(.*)/); + widths[columnId] = parseFloat(cell.props().style.width); + return widths; + }, {}); +} + +function resizeColumn( + datagrid: ReactWrapper, + columnId: string, + columnWidth: number +) { + const widths = extractColumnWidths(datagrid); + const originalWidth = widths[columnId]; + + const firstResizer = datagrid + .find(`EuiDataGridColumnResizer[columnId="${columnId}"]`) + .instance() as EuiDataGridColumnResizer; + firstResizer.onMouseDown({ pageX: originalWidth }); + firstResizer.onMouseMove({ pageX: columnWidth }); + act(() => firstResizer.onMouseUp()); + + datagrid.update(); +} + +function getColumnSortDirection( + datagrid: ReactWrapper, + columnId: string +): [ReactWrapper, string] { + // get the button that sorts by this column + let columnSorter = datagrid.find( + `div[data-test-subj="euiDataGridColumnSorting-sortColumn-${columnId}"]` + ); + if (columnSorter.length === 0) { + // need to enable this column + + // open the column selection popover + let columnSelectionPopover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopoverColumnSelection"]' + ); + expect(columnSelectionPopover).not.euiPopoverToBeOpen(); + let popoverButton = columnSelectionPopover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + columnSelectionPopover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopoverColumnSelection"]' + ); + expect(columnSelectionPopover).euiPopoverToBeOpen(); + + // find button to enable this column and click it + const selectColumnButton = datagrid.find( + `[data-test-subj="dataGridColumnSortingPopoverColumnSelection-${columnId}"]` + ); + expect(selectColumnButton.length).toBe(1); + // @ts-ignore-next-line + act(() => selectColumnButton.props().onClick()); + + // close column selection popover + columnSelectionPopover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopoverColumnSelection"]' + ); + // popover will go away if all of the columns are selected + if (columnSelectionPopover.length > 0) { + expect(columnSelectionPopover).euiPopoverToBeOpen(); + + popoverButton = columnSelectionPopover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + columnSelectionPopover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopoverColumnSelection"]' + ); + expect(columnSelectionPopover).not.euiPopoverToBeOpen(); + } + + // find the column sorter + columnSelectionPopover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopover"]' + ); + columnSorter = columnSelectionPopover.find( + `div[data-test-subj="euiDataGridColumnSorting-sortColumn-${columnId}"]` + ); + } + + expect(columnSorter.length).toBe(1); + const activeSort = columnSorter.find( + 'button[className*="euiButtonGroup__button--selected"]' + ); + const sortDirection = (activeSort.props() as { + 'data-test-subj': string; + })['data-test-subj'].match(/(?[^-]+)$/)!.groups!.direction; + + return [columnSorter, sortDirection]; +} + +function sortByColumn( + datagrid: ReactWrapper, + columnId: string, + direction: 'asc' | 'desc' | 'off' +) { + // open datagrid sorting options + let popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopover"]' + ); + expect(popover).not.euiPopoverToBeOpen(); + + let popoverButton = popover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopover"]' + ); + expect(popover).euiPopoverToBeOpen(); + + let [columnSorter, currentSortDirection] = getColumnSortDirection( + datagrid, + columnId + ); + + // if this column isn't being sorted, enable it + if (currentSortDirection === 'off') { + act(() => { + // @ts-ignore-next-line + columnSorter + .find('EuiSwitch') + .props() + .onChange(); + }); + + datagrid.update(); + + // inspect the column's new sort details + [columnSorter, currentSortDirection] = getColumnSortDirection( + datagrid, + columnId + ); + } + + if (currentSortDirection !== direction) { + const sortButton = columnSorter.find( + `button[data-test-subj="euiDataGridColumnSorting-sortColumn-${columnId}-${direction}"]` + ); + expect(sortButton.length).toBe(1); + act(() => + // @ts-ignore-next-line + sortButton + .parents('EuiButtonGroup') + .props() + .onChange(undefined, direction) + ); + } + + // close popover + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopover"]' + ); + expect(popover).euiPopoverToBeOpen(); + + popoverButton = popover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSortingPopover"]' + ); + expect(popover).not.euiPopoverToBeOpen(); +} + +expect.extend({ + toBeEuiPopover(received: ReactWrapper) { + const pass = received.name() === 'EuiPopover'; + if (pass) { + return { + pass: true, + message: () => + `expected component "${received.name}" to not be EuiPopover`, + }; + } else { + return { + pass: false, + message: () => `expected component "${received.name}" to be EuiPopover`, + }; + } + }, + euiPopoverToBeOpen(received) { + expect(received).toBeEuiPopover(); + const { isOpen } = received.props(); + const pass = isOpen === true; + if (pass) { + return { + pass: true, + message: () => 'expected EuiPopover to be closed', + }; + } else { + return { + pass: false, + message: () => 'expected EuiPopover to be open', + }; + } + }, +}); +declare global { + /* eslint-disable @typescript-eslint/no-namespace */ + namespace jest { + interface Matchers { + toBeEuiPopover(): R; + euiPopoverToBeOpen(): R; + } + } +} + +function setColumnVisibility( + datagrid: ReactWrapper, + columnId: string, + isVisible: boolean +) { + // open datagrid column options + let popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).not.euiPopoverToBeOpen(); + + let popoverButton = popover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).euiPopoverToBeOpen(); + + // toggle column's visibility switch + const portal = popover.find('EuiPortal'); + + const columnSwitch = portal.find(`EuiSwitch[name="${columnId}"]`); + const switchInput = columnSwitch.find('input'); + (switchInput.getDOMNode() as HTMLInputElement).checked = isVisible; + switchInput.simulate('change'); + + // close popover + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).euiPopoverToBeOpen(); + + popoverButton = popover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).not.euiPopoverToBeOpen(); +} + +function moveColumnToIndex( + datagrid: ReactWrapper, + columnId: string, + nextIndex: number +) { + // open datagrid column options + let popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).not.euiPopoverToBeOpen(); + + let popoverButton = popover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).euiPopoverToBeOpen(); + + const [initialColumnOrder] = extractGridData(datagrid); + const initialColumnIndex = initialColumnOrder.indexOf(columnId); + + // "drag" column into new location + const portal = popover.find('EuiPortal'); + act(() => + portal.find('EuiDragDropContext').props().onDragEnd!({ + // @ts-ignore-next-line - only `index` is used from `source`, don't need to mock rest of the event + source: { index: initialColumnIndex }, + destination: { index: nextIndex }, + }) + ); + + datagrid.update(); + + // close popover + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).euiPopoverToBeOpen(); + + popoverButton = popover + .find('div[className="euiPopover__anchor"]') + .find('[onClick]') + .first(); + // @ts-ignore-next-line + act(() => popoverButton.props().onClick()); + + datagrid.update(); + + popover = datagrid.find( + 'EuiPopover[data-test-subj="dataGridColumnSelectorPopover"]' + ); + expect(popover).not.euiPopoverToBeOpen(); +} + +describe('EuiDataGrid', () => { + describe('rendering', () => { + it('renders with common and div attributes', () => { + const component = render( + {}, + }} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => + `${rowIndex}, ${columnId}` + } + /> + ); + + expect(component).toMatchSnapshot(); + }); + + it('renders custom column headers', () => { + const component = render( + More Elements
}, + ]} + columnVisibility={{ + visibleColumns: ['A', 'B'], + setVisibleColumns: () => {}, + }} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => + `${rowIndex}, ${columnId}` + } + /> + ); + + 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( + {}, + }} + rowCount={2} + renderCellValue={({ rowIndex, columnId, setCellProps }) => { + useEffect(() => { + 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], + "onKeyDown": [Function], + "role": "gridcell", + "style": Object { + "color": "red", + "width": "100px", + }, + "tabIndex": 0, + }, + Object { + "className": "euiDataGridRowCell customClass", + "data-test-subj": "dataGridRowCell", + "onFocus": [Function], + "onKeyDown": [Function], + "role": "gridcell", + "style": Object { + "color": "blue", + "width": "100px", + }, + "tabIndex": -1, + }, + Object { + "className": "euiDataGridRowCell customClass", + "data-test-subj": "dataGridRowCell", + "onFocus": [Function], + "onKeyDown": [Function], + "role": "gridcell", + "style": Object { + "color": "red", + "width": "100px", + }, + "tabIndex": -1, + }, + Object { + "className": "euiDataGridRowCell customClass", + "data-test-subj": "dataGridRowCell", + "onFocus": [Function], + "onKeyDown": [Function], + "role": "gridcell", + "style": Object { + "color": "blue", + "width": "100px", + }, + "tabIndex": -1, + }, +] +`); + }); + + it('renders correct aria attributes on column headers', () => { + const component = mount( + {}, + }} + rowCount={1} + renderCellValue={() => 'value'} + /> + ); + + // no columns are sorted, expect no aria-sort or aria-describedby attributes + expect(component.find('[role="columnheader"][aria-sort]').length).toBe(0); + expect( + component.find('[role="columnheader"][aria-describedby]').length + ).toBe(0); + + // sort on one column + component.setProps({ + sorting: { columns: [{ id: 'A', direction: 'asc' }], onSort: () => {} }, + }); + + // expect A column to have aria-sort, expect no aria-describedby + expect(component.find('[role="columnheader"][aria-sort]').length).toBe(1); + expect( + component.find( + '[role="columnheader"][aria-sort="ascending"][data-test-subj="dataGridHeaderCell-A"]' + ).length + ).toBe(1); + expect( + component.find('[role="columnheader"][aria-describedby]').length + ).toBe(0); + + // sort on both columns + component.setProps({ + sorting: { + columns: [ + { id: 'A', direction: 'asc' }, + { id: 'B', direction: 'desc' }, + ], + onSort: () => {}, + }, + }); + + // expect no aria-sort, both columns have aria-describedby + expect(component.find('[role="columnheader"][aria-sort]').length).toBe(0); + expect( + component.find('[role="columnheader"][aria-describedby]').length + ).toBe(2); + expect( + component.find('[role="columnheader"][aria-describedby="htmlId"]') + .length + ).toBe(2); + }); + + it('can hide the toolbar', () => { + const component = mount( + {}, + }} + toolbarVisibility={false} + rowCount={1} + renderCellValue={() => 'value'} + /> + ); + + // The toolbar should not show + expect(findTestSubject(component, 'dataGridControls').length).toBe(0); + + // Check for false / true and unset values + component.setProps({ + toolbarVisibility: { + showFullScreenSelector: false, + showSortSelector: false, + showStyleSelector: true, + }, + }); + + // fullscreen selector + expect(findTestSubject(component, 'dataGridFullScrenButton').length).toBe( + 0 + ); + + // sort selector + expect( + findTestSubject(component, 'dataGridColumnSortingButton').length + ).toBe(0); + + // style selector + expect( + findTestSubject(component, 'dataGridStyleSelectorButton').length + ).toBe(1); + + // column selector + expect( + findTestSubject(component, 'dataGridColumnSelectorButton').length + ).toBe(1); + }); + + describe('schema schema classnames', () => { + it('applies classnames from explicit schemas', () => { + const component = mount( + {}, + }} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => + `${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( + {}, + }} + inMemory={{ level: 'pagination' }} + rowCount={2} + renderCellValue={({ columnId }) => { + 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( + {}, + }} + inMemory={{ level: 'pagination' }} + rowCount={2} + renderCellValue={({ columnId }) => + 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 }))} + columnVisibility={{ + visibleColumns: Object.keys(values), + setVisibleColumns: () => {}, + }} + inMemory={{ level: '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 }))} + columnVisibility={{ + visibleColumns: Object.keys(values), + setVisibleColumns: () => {}, + }} + schemaDetectors={[ + { + type: 'ipaddress', + detector(value: string) { + return value.match(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/) + ? 1 + : 0; + }, + icon: 'alert', + color: 'primary', + sortTextAsc: 'a-z', + sortTextDesc: 'z-a', + }, + ]} + inMemory={{ level: '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', () => { + it('supports hooks', () => { + const component = mount( + {}, + }} + rowCount={2} + renderCellValue={({ rowIndex, columnId }) => { + const [value] = useState(`Hello, Row ${rowIndex}-${columnId}!`); + return {value}; + }} + /> + ); + 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!", + ], +] +`); + }); + }); + + describe('pagination', () => { + it('renders', () => { + const component = mount( + {}, + }} + rowCount={10} + renderCellValue={({ rowIndex }) => rowIndex} + pagination={{ + pageIndex: 1, + pageSize: 6, + pageSizeOptions: [3, 6, 10], + onChangePage: () => {}, + onChangeItemsPerPage: () => {}, + }} + /> + ); + + expect( + takeMountedSnapshot(component.find('EuiTablePagination')) + ).toMatchSnapshot(); + }); + + describe('page navigation', () => { + it('next button pages through content', () => { + const component = mount( + {}, + }} + rowCount={8} + renderCellValue={({ rowIndex }) => rowIndex} + pagination={{ + pageIndex: 0, + pageSize: 3, + pageSizeOptions: [3, 6, 10], + onChangePage: jest.fn(pageIndex => { + const pagination = component.props().pagination; + component.setProps({ + pagination: { ...pagination, pageIndex }, + }); + }), + onChangeItemsPerPage: jest.fn(), + }} + /> + ); + + expect(extractGridData(component)).toEqual([ + ['Column'], + ['0'], + ['1'], + ['2'], + ]); + + findTestSubject(component, 'pagination-button-next').simulate('click'); + + expect(component.props().pagination.onChangePage).toHaveBeenCalledTimes( + 1 + ); + const firstCallPageIndex = component.props().pagination.onChangePage + .mock.calls[0][0]; + expect(firstCallPageIndex).toBe(1); + + expect(extractGridData(component)).toEqual([ + ['Column'], + ['3'], + ['4'], + ['5'], + ]); + + findTestSubject(component, 'pagination-button-next').simulate('click'); + + expect(component.props().pagination.onChangePage).toHaveBeenCalledTimes( + 2 + ); + const secondCallPageIndex = component.props().pagination.onChangePage + .mock.calls[1][0]; + expect(secondCallPageIndex).toBe(2); + + expect(extractGridData(component)).toEqual([['Column'], ['6'], ['7']]); + }); + + it('pages are navigable through page links', () => { + const component = mount( + {}, + }} + rowCount={8} + renderCellValue={({ rowIndex }) => rowIndex} + pagination={{ + pageIndex: 0, + pageSize: 3, + pageSizeOptions: [3, 6, 10], + onChangePage: jest.fn(pageIndex => { + const pagination = component.props().pagination; + component.setProps({ + pagination: { ...pagination, pageIndex }, + }); + }), + onChangeItemsPerPage: jest.fn(), + }} + /> + ); + + expect(extractGridData(component)).toEqual([ + ['Column'], + ['0'], + ['1'], + ['2'], + ]); + + // goto page 3 + findTestSubject(component, 'pagination-button-2').simulate('click'); + + expect(component.props().pagination.onChangePage).toHaveBeenCalledTimes( + 1 + ); + const firstCallPageIndex = component.props().pagination.onChangePage + .mock.calls[0][0]; + expect(firstCallPageIndex).toBe(2); + + expect(extractGridData(component)).toEqual([['Column'], ['6'], ['7']]); + + // goto page 2 + findTestSubject(component, 'pagination-button-1').simulate('click'); + + expect(component.props().pagination.onChangePage).toHaveBeenCalledTimes( + 2 + ); + const secondCallPageIndex = component.props().pagination.onChangePage + .mock.calls[1][0]; + expect(secondCallPageIndex).toBe(1); + + expect(extractGridData(component)).toEqual([ + ['Column'], + ['3'], + ['4'], + ['5'], + ]); + }); + }); + + it('changes the page size', () => { + const component = mount( + {}, + }} + rowCount={8} + renderCellValue={({ rowIndex }) => rowIndex} + pagination={{ + pageIndex: 0, + pageSize: 3, + pageSizeOptions: [3, 6, 10], + onChangePage: jest.fn(), + onChangeItemsPerPage: jest.fn(pageSize => { + const pagination = component.props().pagination; + component.setProps({ + pagination: { ...pagination, pageSize }, + }); + }), + }} + /> + ); + + expect(extractGridData(component)).toEqual([ + ['Column'], + ['0'], + ['1'], + ['2'], + ]); + + findTestSubject(component, 'tablePaginationPopoverButton').simulate( + 'click' + ); + const rowButtons: NodeListOf< + HTMLButtonElement + > = document.body.querySelectorAll('.euiContextMenuItem'); + expect( + Array.prototype.map.call( + rowButtons, + (button: HTMLDivElement) => button.textContent || '' + ) + ).toEqual(['3 rows', '6 rows', '10 rows']); + rowButtons[1].click(); + + expect( + component.props().pagination.onChangeItemsPerPage + ).toHaveBeenCalledTimes(1); + const firstCallPageIndex = component.props().pagination + .onChangeItemsPerPage.mock.calls[0][0]; + expect(firstCallPageIndex).toBe(6); + + expect(extractGridData(component)).toEqual([ + ['Column'], + ['0'], + ['1'], + ['2'], + ['3'], + ['4'], + ['5'], + ]); + }); + }); + + describe('column resizing', () => { + it('resizes a column by grab handles', () => { + const component = mount( + {}, + }} + rowCount={3} + renderCellValue={() => 'value'} + /> + ); + + const originalCellWidths = extractColumnWidths(component); + expect(originalCellWidths).toEqual({ + 'Column 1': 100, + 'Column 2': 100, + }); + + resizeColumn(component, 'Column 1', 150); + + const updatedCellWidths = extractColumnWidths(component); + expect(updatedCellWidths).toEqual({ + 'Column 1': 150, + 'Column 2': 100, + }); + }); + + it('does not trigger value re-renders', () => { + const renderCellValue = jest.fn(() => 'value'); + + const component = mount( + {}, + }} + rowCount={3} + renderCellValue={renderCellValue} + /> + ); + + expect(renderCellValue).toHaveBeenCalledTimes(3); + renderCellValue.mockClear(); + + resizeColumn(component, 'ColumnA', 200); + + expect(extractColumnWidths(component)).toEqual({ ColumnA: 200 }); + expect(renderCellValue).toHaveBeenCalledTimes(0); + }); + }); + + describe('column options', () => { + it('column visibility can be toggled', () => { + const columnVisibility = { + visibleColumns: ['ColumnA', 'ColumnB'], + setVisibleColumns: (visibleColumns: string[]) => { + columnVisibility.visibleColumns = visibleColumns; + component.setProps({ columnVisibility }); + }, + }; + + const component = mount( + + `${rowIndex}-${columnId}` + } + /> + ); + + expect(extractGridData(component)).toEqual([ + ['ColumnA', 'ColumnB'], + ['0-ColumnA', '0-ColumnB'], + ['1-ColumnA', '1-ColumnB'], + ]); + + setColumnVisibility(component, 'ColumnA', false); + expect(extractGridData(component)).toEqual([ + ['ColumnB'], + ['0-ColumnB'], + ['1-ColumnB'], + ]); + + setColumnVisibility(component, 'ColumnA', true); + expect(extractGridData(component)).toEqual([ + ['ColumnA', 'ColumnB'], + ['0-ColumnA', '0-ColumnB'], + ['1-ColumnA', '1-ColumnB'], + ]); + }); + + it('column order can be changed', () => { + const columnVisibility = { + visibleColumns: ['ColumnA', 'ColumnB'], + setVisibleColumns: (visibleColumns: string[]) => { + columnVisibility.visibleColumns = visibleColumns; + component.setProps({ columnVisibility }); + }, + }; + + const component = mount( + + `${rowIndex}-${columnId}` + } + /> + ); + + expect(extractGridData(component)).toEqual([ + ['ColumnA', 'ColumnB'], + ['0-ColumnA', '0-ColumnB'], + ['1-ColumnA', '1-ColumnB'], + ]); + + moveColumnToIndex(component, 'ColumnB', 0); + + expect(extractGridData(component)).toEqual([ + ['ColumnB', 'ColumnA'], + ['0-ColumnB', '0-ColumnA'], + ['1-ColumnB', '1-ColumnA'], + ]); + }); + }); + + describe('column sorting', () => { + it('calls the onSort callback', () => { + const onSort = jest.fn(columns => { + component.setProps({ sorting: { columns, onSort } }); + component.update(); + }); + + const component = mount( + {}, + }} + rowCount={1} + sorting={{ + columns: [], + onSort, + }} + renderCellValue={() => 'hello'} + /> + ); + + sortByColumn(component, 'ColumnA', 'desc'); + + expect(onSort).toHaveBeenCalledTimes(2); + expect(onSort).toHaveBeenCalledWith([ + { id: 'ColumnA', direction: 'asc' }, + ]); + expect(onSort).toHaveBeenCalledWith([ + { id: 'ColumnA', direction: 'desc' }, + ]); + + const [, sortDirection] = getColumnSortDirection(component, 'ColumnA'); + expect(sortDirection).toBe('desc'); + }); + + describe('in-memory sorting', () => { + it('sorts on initial render', () => { + const component = mount( + {}, + }} + rowCount={5} + renderCellValue={({ rowIndex, columnId }) => + // render A 0->4 and B 9->5 + columnId === 'A' ? rowIndex : 9 - rowIndex + } + inMemory={{ level: 'sorting' }} + sorting={{ + columns: [{ id: 'A', direction: 'desc' }], + onSort: () => {}, + }} + /> + ); + + expect(extractGridData(component)).toEqual([ + ['A', 'B'], + ['4', '5'], + ['3', '6'], + ['2', '7'], + ['1', '8'], + ['0', '9'], + ]); + }); + + it('sorts on multiple columns', () => { + const component = mount( + {}, + }} + rowCount={5} + renderCellValue={({ rowIndex, columnId }) => + // render A as 0, 1, 0, 1, 0 and B as 9->5 + columnId === 'A' ? rowIndex % 2 : 9 - rowIndex + } + inMemory={{ level: 'sorting' }} + sorting={{ + columns: [ + { id: 'A', direction: 'desc' }, + { id: 'B', direction: 'asc' }, + ], + onSort: () => {}, + }} + /> + ); + + expect(extractGridData(component)).toEqual([ + ['A', 'B'], + ['1', '6'], + ['1', '8'], + ['0', '5'], + ['0', '7'], + ['0', '9'], + ]); + }); + + it('sorts in response to user interaction', () => { + const onSort = jest.fn(columns => { + component.setProps({ sorting: { columns, onSort } }); + component.update(); + }); + + const component = mount( + {}, + }} + rowCount={5} + renderCellValue={({ rowIndex, columnId }) => + // render A as 0, 1, 0, 1, 0 and B as 9->5 + columnId === 'A' ? rowIndex % 2 : 9 - rowIndex + } + inMemory={{ level: 'sorting' }} + sorting={{ + columns: [], + onSort, + }} + /> + ); + + expect(extractGridData(component)).toEqual([ + ['A', 'B'], + ['0', '9'], + ['1', '8'], + ['0', '7'], + ['1', '6'], + ['0', '5'], + ]); + + sortByColumn(component, 'A', 'desc'); + expect(extractGridData(component)).toEqual([ + ['A', 'B'], + ['1', '8'], + ['1', '6'], + ['0', '9'], + ['0', '7'], + ['0', '5'], + ]); + + sortByColumn(component, 'B', 'asc'); + expect(extractGridData(component)).toEqual([ + ['A', 'B'], + ['1', '6'], + ['1', '8'], + ['0', '5'], + ['0', '7'], + ['0', '9'], + ]); + }); + }); + + it('uses schema information to sort', () => { + const component = mount( + {}, + }} + rowCount={5} + renderCellValue={({ rowIndex, columnId }) => + // render A 0->4 and B 12->8 + columnId === 'A' ? rowIndex : 12 - rowIndex + } + inMemory={{ level: 'sorting' }} + sorting={{ + columns: [{ id: 'B', direction: 'asc' }], + onSort: () => {}, + }} + /> + ); + + expect(extractGridData(component)).toEqual([ + ['A', 'B'], + ['4', '8'], + ['3', '9'], + ['2', '10'], + ['1', '11'], + ['0', '12'], + ]); + }); + }); + + describe('keyboard controls', () => { + it('supports simple arrow navigation', () => { + const component = mount( + {}, + }} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => + `${rowIndex}, ${columnId}` + } + /> + ); + + let focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); + + focusableCell + .simulate('focus') + .simulate('keydown', { keyCode: keyCodes.LEFT }); + + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); // focus should not move when up against an edge + + focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); // focus should not move when up against an edge + + focusableCell.simulate('keydown', { keyCode: keyCodes.DOWN }); + + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('1, A'); + + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('1, B'); + + focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); + + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, B'); + + focusableCell.simulate('keydown', { keyCode: keyCodes.LEFT }); + + focusableCell = getFocusableCell(component); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); + }); + it('does not break arrow key focus control behavior when also using a mouse', () => { + const component = mount( + {}, + }} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => + `${rowIndex}, ${columnId}` + } + /> + ); + + let focusableCell = getFocusableCell(component); + // console.log(focusableCell.debug()); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('0, A'); + + findTestSubject(component, 'dataGridRowCell') + .at(3) + .simulate('focus'); + + focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect( + focusableCell.find('[data-test-subj="cell-content"]').text() + ).toEqual('1, B'); + }); + it.skip('supports arrow navigation through grids with different interactive cells', () => { + const component = mount( + {}, + }} + rowCount={2} + renderCellValue={({ rowIndex, columnId }) => { + if (columnId === 'A') { + return `${rowIndex}, A`; + } + + if (columnId === 'B') { + return ; + } + + if (columnId === 'C') { + return ( + <> + , + + ); + } + + if (columnId === 'D') { + return ( +
+ {rowIndex}, +
+ ); + } + + return 'error'; + }} + /> + ); + + /** + * Make sure we start from a happy state + */ + let focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect(focusableCell.text()).toEqual('0, A'); + focusableCell + .simulate('focus') + .simulate('keydown', { keyCode: keyCodes.DOWN }); + + /** + * On text only cells, the cell receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, A'); // make sure we're on the right cell + expect(focusableCell.getDOMNode()).toBe(document.activeElement); + + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + + /** + * On cells with 1 interactive item, the interactive item receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, B'); + expect(focusableCell.find('button').getDOMNode()).toBe( + document.activeElement + ); + + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + + /** + * On cells with multiple interactive items, the cell receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, C'); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); + + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + + /** + * On cells with 1 interactive item and non-interactive item(s), the cell receives focus + */ + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, D'); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); + }); + it.skip('allows user to enter and exit grid navigation', async () => { + const component = mount( + {}, + }} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => ( + <> + , + + )} + /> + ); + + /** + * Make sure we start from a happy state + */ + let focusableCell = getFocusableCell(component); + expect(focusableCell.length).toEqual(1); + expect(focusableCell.text()).toEqual('0, A'); + focusableCell + .simulate('focus') + .simulate('keydown', { keyCode: keyCodes.DOWN }); + focusableCell = getFocusableCell(component); + + /** + * Confirm initial state is with grid navigation turn on + */ + expect(focusableCell.text()).toEqual('1, A'); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); + expect(takeMountedSnapshot(component)).toMatchSnapshot(); + + /** + * Disable grid navigation using ENTER + */ + focusableCell + .simulate('keydown', { keyCode: keyCodes.ENTER }) + .simulate('keydown', { keyCode: keyCodes.DOWN }); + + let buttons = focusableCell.find('button'); + + // grid navigation is disabled, location should not move + expect(buttons.at(0).text()).toEqual('1'); + expect(buttons.at(1).text()).toEqual('A'); + expect(buttons.at(0).getDOMNode()).toBe(document.activeElement); // focus should move to first button + expect(takeMountedSnapshot(component)).toMatchSnapshot(); // should prove focus lock is on + + /** + * Enable grid navigation ESCAPE + */ + focusableCell.simulate('keydown', { keyCode: keyCodes.ESCAPE }); + focusableCell = getFocusableCell(component); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); // focus should move back to cell + + focusableCell.simulate('keydown', { keyCode: keyCodes.RIGHT }); + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('1, B'); // grid navigation is enabled again, check that we can move + expect(takeMountedSnapshot(component)).toMatchSnapshot(); + + /** + * Disable grid navigation using F2 + */ + focusableCell = getFocusableCell(component); + focusableCell + .simulate('keydown', { keyCode: keyCodes.F2 }) + .simulate('keydown', { keyCode: keyCodes.UP }); + buttons = focusableCell.find('button'); + + // grid navigation is disabled, location should not move + expect(buttons.at(0).text()).toEqual('1'); + expect(buttons.at(1).text()).toEqual('B'); + expect(buttons.at(0).getDOMNode()).toBe(document.activeElement); // focus should move to first button + expect(takeMountedSnapshot(component)).toMatchSnapshot(); // should prove focus lock is on + + /** + * Enable grid navigation using F2 + */ + focusableCell.simulate('keydown', { keyCode: keyCodes.F2 }); + focusableCell = getFocusableCell(component); + expect(focusableCell.getDOMNode()).toBe(document.activeElement); // focus should move back to cell + + focusableCell.simulate('keydown', { keyCode: keyCodes.UP }); + focusableCell = getFocusableCell(component); + expect(focusableCell.text()).toEqual('0, B'); // grid navigation is enabled again, check that we can move + expect(takeMountedSnapshot(component)).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx new file mode 100644 index 00000000000..46560279bb1 --- /dev/null +++ b/src/components/datagrid/data_grid.tsx @@ -0,0 +1,665 @@ +import React, { + FunctionComponent, + HTMLAttributes, + KeyboardEvent, + useCallback, + useState, + useEffect, + Fragment, + ReactChild, + useMemo, +} from 'react'; +import classNames from 'classnames'; +import tabbable from 'tabbable'; +import { EuiI18n } from '../i18n'; +import { EuiDataGridHeaderRow } from './data_grid_header_row'; +import { CommonProps, Omit } from '../common'; +import { + EuiDataGridColumn, + EuiDataGridColumnWidths, + EuiDataGridInMemory, + EuiDataGridPaginationProps, + EuiDataGridInMemoryValues, + EuiDataGridSorting, + EuiDataGridStyle, + EuiDataGridStyleBorders, + EuiDataGridStyleCellPaddings, + EuiDataGridStyleFontSizes, + EuiDataGridStyleHeader, + EuiDataGridStyleRowHover, + EuiDataGridPopoverContents, + EuiDataGridColumnVisibility, + EuiDataGridTooBarVisibilityOptions, +} from './data_grid_types'; +import { EuiDataGridCellProps } from './data_grid_cell'; +// @ts-ignore-next-line +import { EuiButtonEmpty } from '../button'; +import { keyCodes, htmlIdGenerator } from '../../services'; +import { EuiDataGridBody } from './data_grid_body'; +import { useColumnSelector } from './column_selector'; +import { useStyleSelector, startingStyles } from './style_selector'; +// @ts-ignore-next-line +import { EuiTablePagination } from '../table/table_pagination'; +// @ts-ignore-next-line +import { EuiFocusTrap } from '../focus_trap'; +import { EuiResizeObserver } from '../observer/resize_observer'; +import { EuiDataGridInMemoryRenderer } from './data_grid_inmemory_renderer'; +import { + getMergedSchema, + EuiDataGridSchemaDetector, + useDetectSchema, + schemaDetectors as providedSchemaDetectors, +} from './data_grid_schema'; +import { useColumnSorting } from './column_sorting'; +import { EuiMutationObserver } from '../observer/mutation_observer'; + +// When below this number the grid only shows the full screen button +const MINIMUM_WIDTH_FOR_GRID_CONTROLS = 479; + +type CommonGridProps = CommonProps & + HTMLAttributes & { + /** + * An array of #EuiDataGridColumn objects. Lists the columns available and the schema and settings tied to it. + */ + columns: EuiDataGridColumn[]; + /** + * An array of #EuiDataGridColumnVisibility objects. Defines which columns are visible in the grid and the order they are displayed. + */ + columnVisibility: EuiDataGridColumnVisibility; + /** + * An array of custom #EuiDataGridSchemaDetector objects. You can inject custom schemas to the grid to define the classnames applied + */ + schemaDetectors?: EuiDataGridSchemaDetector[]; + /** + * An object mapping #EuiDataGridColumn `schema`s to a custom popover formatting component which receives #EuiDataGridPopoverContent props + */ + popoverContents?: EuiDataGridPopoverContents; + /** + * The total number of rows in the dataset (used by e.g. pagination to know how many pages to list) + */ + rowCount: number; + /** + * A function called to render a cell's value. Behind the scenes it is treated as a React component + * allowing hooks, context, and other React concepts to be used. The function receives a #CellValueElement + * as its only argument. + */ + renderCellValue: EuiDataGridCellProps['renderCellValue']; + /** + * Defines the look and feel for the grid. Accepts a partial #EuiDataGridStyle object. Settings provided may be overwritten or merged with user defined preferences if toolbarVisibility density controls are available. + */ + gridStyle?: EuiDataGridStyle; + /** + * Accepts either a boolean or #EuiDataGridToolbarVisibilityOptions object. When used as a boolean, defines the display of the toolbar entire. WHen passed an object allows you to turn off individual controls within the toolbar. + */ + toolbarVisibility?: boolean | EuiDataGridTooBarVisibilityOptions; + /** + * A #EuiDataGridInMemory object to definite the level of high order schema-detection and sorting logic to use on your data. *Try to set when possible*. When ommited, disables all enhancements and assumes content is flat strings. + */ + inMemory?: EuiDataGridInMemory; + /** + * A #EuiDataGridPagination object. Omit to disable pagination completely. + */ + pagination?: EuiDataGridPaginationProps; + /** + * A #EuiDataGridSorting oject that provides the sorted columns along with their direction. Omit to disable, but you'll likely want to also turn off the user sorting controls through the `toolbarVisibility` prop. + */ + sorting?: EuiDataGridSorting; + }; + +// This structure forces either aria-label or aria-labelledby to be defined +// making some type of label a requirement +type EuiDataGridProps = Omit< + CommonGridProps, + 'aria-label' | 'aria-labelledby' +> & + ( + | { + /** + * must provide either aria-label OR aria-labelledby as a title for the grid + */ + 'aria-label': string; + } + | { + /** + * must provide either aria-label OR aria-labelledby as a title for the grid + */ + 'aria-labelledby': string; + }); + +// Each gridStyle object above sets a specific CSS select to .euiGrid +const fontSizesToClassMap: { [size in EuiDataGridStyleFontSizes]: string } = { + s: 'euiDataGrid--fontSizeSmall', + m: '', + l: 'euiDataGrid--fontSizeLarge', +}; + +const headerToClassMap: { [header in EuiDataGridStyleHeader]: string } = { + shade: 'euiDataGrid--headerShade', + underline: 'euiDataGrid--headerUnderline', +}; + +const rowHoverToClassMap: { + [rowHighlight in EuiDataGridStyleRowHover]: string +} = { + highlight: 'euiDataGrid--rowHoverHighlight', + none: '', +}; + +const bordersToClassMap: { [border in EuiDataGridStyleBorders]: string } = { + all: 'euiDataGrid--bordersAll', + horizontal: 'euiDataGrid--bordersHorizontal', + none: 'euiDataGrid--bordersNone', +}; + +const cellPaddingsToClassMap: { + [cellPaddings in EuiDataGridStyleCellPaddings]: string +} = { + s: 'euiDataGrid--paddingSmall', + m: '', + l: 'euiDataGrid--paddingLarge', +}; + +function computeVisibleRows(props: EuiDataGridProps) { + const { pagination, rowCount } = props; + + const startRow = pagination ? pagination.pageIndex * pagination.pageSize : 0; + let endRow = pagination + ? (pagination.pageIndex + 1) * pagination.pageSize + : rowCount; + endRow = Math.min(endRow, rowCount); + + return endRow - startRow; +} + +function renderPagination(props: EuiDataGridProps) { + const { pagination } = props; + + if (pagination == null) { + return null; + } + + const { + pageIndex, + pageSize, + pageSizeOptions, + onChangePage, + onChangeItemsPerPage, + } = pagination; + const pageCount = Math.ceil(props.rowCount / pageSize); + + if (pageCount === 1) { + return null; + } + + return ( +
+ +
+ ); +} + +function useDefaultColumnWidth( + container: HTMLElement | null, + columns: EuiDataGridProps['columns'] +): number | null { + const [defaultColumnWidth, setDefaultColumnWidth] = useState( + null + ); + + useEffect(() => { + if (container != null) { + const gridWidth = container.clientWidth; + const columnWidth = Math.max(gridWidth / columns.length, 100); + setDefaultColumnWidth(columnWidth); + } + }, [container, columns]); + + return defaultColumnWidth; +} + +function useColumnWidths(): [ + EuiDataGridColumnWidths, + (columnId: string, width: number) => void +] { + const [columnWidths, setColumnWidths] = useState({}); + const setColumnWidth = (columnId: string, width: number) => { + setColumnWidths({ ...columnWidths, [columnId]: width }); + }; + return [columnWidths, setColumnWidth]; +} + +function useOnResize( + setHasRoomForGridControls: (hasRoomForGridControls: boolean) => void, + isFullScreen: boolean +) { + return useCallback( + ({ width }: { width: number }) => { + setHasRoomForGridControls( + width > MINIMUM_WIDTH_FOR_GRID_CONTROLS || isFullScreen + ); + }, + [setHasRoomForGridControls, isFullScreen] + ); +} + +function useInMemoryValues( + inMemory: EuiDataGridInMemory | undefined, + rowCount: number +): [ + EuiDataGridInMemoryValues, + (rowIndex: number, column: EuiDataGridColumn, value: string) => void +] { + const [inMemoryValues, setInMemoryValues] = useState< + EuiDataGridInMemoryValues + >({}); + + const onCellRender = useCallback( + (rowIndex, column, value) => { + setInMemoryValues(inMemoryValues => { + const nextInMemoryValues = { ...inMemoryValues }; + nextInMemoryValues[rowIndex] = nextInMemoryValues[rowIndex] || {}; + nextInMemoryValues[rowIndex][column.id] = value; + return nextInMemoryValues; + }); + }, + [setInMemoryValues] + ); + + // if `inMemory.level` or `rowCount` changes reset the values + const inMemoryLevel = inMemory && inMemory.level; + useEffect(() => { + setInMemoryValues({}); + }, [inMemoryLevel, rowCount]); + + return [inMemoryValues, onCellRender]; +} + +function createKeyDownHandler( + props: EuiDataGridProps, + visibleColumns: EuiDataGridProps['columns'], + focusedCell: [number, number], + headerIsInteractive: boolean, + setFocusedCell: (focusedCell: [number, number]) => void +) { + return (event: KeyboardEvent) => { + const colCount = visibleColumns.length - 1; + const [x, y] = focusedCell; + const rowCount = computeVisibleRows(props); + const { keyCode } = event; + + switch (keyCode) { + case keyCodes.DOWN: + event.preventDefault(); + if (y < rowCount - 1) { + setFocusedCell([x, y + 1]); + } + break; + case keyCodes.LEFT: + event.preventDefault(); + if (x > 0) { + setFocusedCell([x - 1, y]); + } + break; + case keyCodes.UP: + event.preventDefault(); + // TODO sort out when a user can arrow up into the column headers + const minimumIndex = headerIsInteractive ? -1 : 0; + if (y > minimumIndex) { + setFocusedCell([x, y - 1]); + } + break; + case keyCodes.RIGHT: + event.preventDefault(); + if (x < colCount) { + setFocusedCell([x + 1, y]); + } + break; + } + }; +} + +export const EuiDataGrid: FunctionComponent = props => { + const [isFullScreen, setIsFullScreen] = useState(false); + const [hasRoomForGridControls, setHasRoomForGridControls] = useState(true); + const [focusedCell, setFocusedCell] = useState<[number, number] | null>(null); + const [containerRef, setContainerRef] = useState(null); + const [interactiveCellId] = useState(htmlIdGenerator()()); + + const [headerIsInteractive, setHeaderIsInteractive] = useState(false); + const handleHeaderChange = useCallback( + records => { + const [{ target }] = records; + + // find the wrapping header div + let headerRow = target.parentElement; + while ( + headerRow && + (headerRow.getAttribute('data-test-subj') || '').indexOf( + 'dataGridHeader' + ) === -1 + ) { + headerRow = headerRow.parentElement; + } + + if (headerRow) { + const tabbables = tabbable(headerRow); + const managed = headerRow.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + const hasInteractives = tabbables.length > 0 || managed.length > 0; + if (hasInteractives !== headerIsInteractive) { + setHeaderIsInteractive(hasInteractives); + + // if the focus is on the header, and the header is no longer interactive + // move the focus down to the first row + if ( + hasInteractives === false && + focusedCell && + focusedCell[1] === -1 + ) { + setFocusedCell([focusedCell[0], 0]); + } + } + } + }, + [headerIsInteractive, setHeaderIsInteractive, focusedCell, setFocusedCell] + ); + + const [columnWidths, setColumnWidth] = useColumnWidths(); + + // enables/disables grid controls based on available width + const onResize = useOnResize(nextHasRoomForGridControls => { + if (nextHasRoomForGridControls !== hasRoomForGridControls) { + setHasRoomForGridControls(nextHasRoomForGridControls); + } + }, isFullScreen); + + const handleGridKeyDown = (e: KeyboardEvent) => { + switch (e.keyCode) { + case keyCodes.ESCAPE: + if (isFullScreen) { + e.preventDefault(); + setIsFullScreen(false); + } + break; + } + }; + + const { + columns, + columnVisibility, + schemaDetectors, + rowCount, + renderCellValue, + className, + gridStyle, + toolbarVisibility = true, + pagination, + sorting, + inMemory, + popoverContents, + ...rest + } = props; + + // apply style props on top of defaults + const gridStyleWithDefaults = { ...startingStyles, ...gridStyle }; + + const [inMemoryValues, onCellRender] = useInMemoryValues(inMemory, rowCount); + + const allSchemaDetectors = useMemo( + () => [...providedSchemaDetectors, ...(schemaDetectors || [])], + [schemaDetectors] + ); + const detectedSchema = useDetectSchema( + inMemoryValues, + allSchemaDetectors, + inMemory != null + ); + const mergedSchema = getMergedSchema(detectedSchema, columns); + + const [columnSelector, orderedVisibleColumns] = useColumnSelector( + columns, + columnVisibility + ); + const columnSorting = useColumnSorting( + orderedVisibleColumns, + sorting, + detectedSchema, + allSchemaDetectors + ); + const [styleSelector, gridStyles] = useStyleSelector(gridStyleWithDefaults); + + // compute the default column width from the container's clientWidth and count of visible columns + const defaultColumnWidth = useDefaultColumnWidth( + containerRef, + orderedVisibleColumns + ); + + const classes = classNames( + 'euiDataGrid', + fontSizesToClassMap[gridStyles.fontSize!], + bordersToClassMap[gridStyles.border!], + headerToClassMap[gridStyles.header!], + rowHoverToClassMap[gridStyles.rowHover!], + cellPaddingsToClassMap[gridStyles.cellPadding!], + { + 'euiDataGrid--stripes': gridStyles.stripes!, + }, + { + 'euiDataGrid--fullScreen': isFullScreen, + }, + className + ); + + const controlBtnClasses = classNames( + 'euiDataGrid__controlBtn', + { + 'euiDataGrid__controlBtn--active': isFullScreen, + }, + className + ); + + // By default the toolbar appears + const showToolbar = !!toolbarVisibility; + + // Typegaurd to see if toolbarVisibility has a certain boolean property assigned + // If not, just set it to true and assume it's OK to show + function checkOrDefaultToolBarDiplayOptions( + arg: EuiDataGridProps['toolbarVisibility'], + option: keyof EuiDataGridTooBarVisibilityOptions + ): boolean { + if (arg === undefined) { + return true; + } else if (typeof arg === 'boolean') { + return arg as boolean; + } else if ( + (arg as EuiDataGridTooBarVisibilityOptions).hasOwnProperty(option) + ) { + return arg[option]!; + } else { + return true; + } + } + + // These grid controls will only show when there is room. Check the resize observer callback + // They can also be optionally turned off individually by using toolbarVisibility + const gridControls = ( + + {checkOrDefaultToolBarDiplayOptions( + toolbarVisibility, + 'showColumnSelector' + ) + ? columnSelector + : null} + {checkOrDefaultToolBarDiplayOptions( + toolbarVisibility, + 'showStyleSelector' + ) + ? styleSelector + : null} + {checkOrDefaultToolBarDiplayOptions(toolbarVisibility, 'showSortSelector') + ? columnSorting + : null} + + ); + + // When data grid is full screen, we add a class to the body to remove the extra scrollbar + if (isFullScreen) { + document.body.classList.add('euiDataGrid__restrictBody'); + } else { + document.body.classList.remove('euiDataGrid__restrictBody'); + } + + // extract aria-label and/or aria-labelledby from `rest` + const gridAriaProps: { + 'aria-label'?: string; + 'aria-labelledby'?: string; + } = {}; + if ('aria-label' in rest) { + gridAriaProps['aria-label'] = rest['aria-label']; + delete rest['aria-label']; + } + if ('aria-labelledby' in rest) { + gridAriaProps['aria-labelledby'] = rest['aria-labelledby']; + delete rest['aria-labelledby']; + } + + const realizedFocusedCell: [number, number] = + focusedCell || (headerIsInteractive ? [0, -1] : [0, 0]); + + const fullScreenSelector = ( + + {([fullScreenButton, fullScreenButtonActive]: ReactChild[]) => ( + setIsFullScreen(!isFullScreen)}> + {isFullScreen ? fullScreenButtonActive : fullScreenButton} + + )} + + ); + + return ( + +
+ {showToolbar ? ( +
+ {hasRoomForGridControls ? gridControls : null} + {checkOrDefaultToolBarDiplayOptions( + toolbarVisibility, + 'showFullScreenSelector' + ) + ? fullScreenSelector + : null} +
+ ) : null} + + {resizeRef => ( +
+
+ {inMemory ? ( + + ) : null} +
+ + {ref => ( + + )} + + +
+
+
+ )} +
+ + {renderPagination(props)} + +
+
+ ); +}; diff --git a/src/components/datagrid/data_grid_body.tsx b/src/components/datagrid/data_grid_body.tsx new file mode 100644 index 00000000000..2db4592c50f --- /dev/null +++ b/src/components/datagrid/data_grid_body.tsx @@ -0,0 +1,238 @@ +import React, { + Fragment, + FunctionComponent, + useCallback, + useMemo, +} from 'react'; +// @ts-ignore-next-line +import { EuiCodeBlock } from '../code'; +import { + EuiDataGridColumn, + EuiDataGridColumnWidths, + EuiDataGridPopoverContents, + EuiDataGridInMemory, + EuiDataGridInMemoryValues, + EuiDataGridPaginationProps, + EuiDataGridSorting, +} from './data_grid_types'; +import { EuiDataGridCellProps } from './data_grid_cell'; +import { + EuiDataGridDataRow, + EuiDataGridDataRowProps, +} from './data_grid_data_row'; +import { + EuiDataGridSchema, + EuiDataGridSchemaDetector, +} from './data_grid_schema'; + +interface EuiDataGridBodyProps { + columnWidths: EuiDataGridColumnWidths; + defaultColumnWidth?: number | null; + columns: EuiDataGridColumn[]; + schema: EuiDataGridSchema; + schemaDetectors: EuiDataGridSchemaDetector[]; + popoverContents?: EuiDataGridPopoverContents; + focusedCell: EuiDataGridDataRowProps['focusedCell']; + onCellFocus: EuiDataGridDataRowProps['onCellFocus']; + rowCount: number; + renderCellValue: EuiDataGridCellProps['renderCellValue']; + inMemory?: EuiDataGridInMemory; + inMemoryValues: EuiDataGridInMemoryValues; + interactiveCellId: EuiDataGridCellProps['interactiveCellId']; + pagination?: EuiDataGridPaginationProps; + sorting?: EuiDataGridSorting; +} + +const defaultComparator: NonNullable< + EuiDataGridSchemaDetector['comparator'] +> = (a, b, direction) => { + if (a < b) return direction === 'asc' ? -1 : 1; + if (a > b) return direction === 'asc' ? 1 : -1; + return 0; +}; + +const providedPopoverContents: EuiDataGridPopoverContents = { + json: ({ cellContentsElement }) => { + let formattedText = cellContentsElement.innerText; + + // attempt to pretty-print the json + try { + formattedText = JSON.stringify(JSON.parse(formattedText), null, 2); + } catch (e) {} // eslint-disable-line no-empty + + return ( + + {formattedText} + + ); + }, +}; + +export const EuiDataGridBody: FunctionComponent< + EuiDataGridBodyProps +> = props => { + const { + columnWidths, + defaultColumnWidth, + columns, + schema, + schemaDetectors, + popoverContents, + focusedCell, + onCellFocus, + rowCount, + renderCellValue, + inMemory, + inMemoryValues, + interactiveCellId, + pagination, + sorting, + } = props; + + const startRow = pagination ? pagination.pageIndex * pagination.pageSize : 0; + let endRow = pagination + ? (pagination.pageIndex + 1) * pagination.pageSize + : rowCount; + endRow = Math.min(endRow, rowCount); + + const visibleRowIndices = useMemo(() => { + const visibleRowIndices = []; + for (let i = startRow; i < endRow; i++) { + visibleRowIndices.push(i); + } + return visibleRowIndices; + }, [startRow, endRow]); + + const rowMap = useMemo(() => { + const rowMap: { [key: number]: number } = {}; + + if ( + inMemory && + inMemory.level === 'sorting' && + sorting != null && + sorting.columns.length > 0 + ) { + const inMemoryRowIndices = Object.keys(inMemoryValues); + const wrappedValues: Array<{ + index: number; + values: EuiDataGridInMemoryValues[number]; + }> = []; + for (let i = 0; i < inMemoryRowIndices.length; i++) { + const inMemoryRow = inMemoryValues[inMemoryRowIndices[i]]; + wrappedValues.push({ index: i, values: inMemoryRow }); + } + + wrappedValues.sort((a, b) => { + for (let i = 0; i < sorting.columns.length; i++) { + const column = sorting.columns[i]; + const aValue = a.values[column.id]; + const bValue = b.values[column.id]; + + // get the comparator, based on schema + let comparator = defaultComparator; + if (schema.hasOwnProperty(column.id)) { + const columnType = schema[column.id].columnType; + for (let i = 0; i < schemaDetectors.length; i++) { + const detector = schemaDetectors[i]; + if ( + detector.type === columnType && + detector.hasOwnProperty('comparator') + ) { + comparator = detector.comparator!; + } + } + } + + const result = comparator(aValue, bValue, column.direction); + // only return if the columns are inequal, otherwise allow the next sort-by column to run + if (result !== 0) return result; + } + + return 0; + }); + + for (let i = 0; i < wrappedValues.length; i++) { + rowMap[i] = wrappedValues[i].index; + } + } + + return rowMap; + }, [sorting, inMemory, inMemoryValues, schema, schemaDetectors]); + + const setCellFocus = useCallback( + ([colIndex, rowIndex]) => { + // If the rows in the grid have been mapped in some way (e.g. sorting) + // then this callback must unmap the reported rowIndex + const mappedRowIndicies = Object.keys(rowMap); + let reverseMappedIndex = rowIndex; + for (let i = 0; i < mappedRowIndicies.length; i++) { + const mappedRowIndex = mappedRowIndicies[i]; + const rowMappedToIndex = rowMap[(mappedRowIndex as any) as number]; + if (`${rowMappedToIndex}` === `${rowIndex}`) { + reverseMappedIndex = parseInt(mappedRowIndex); + break; + } + } + + // map the row into the visible rows + if (pagination) { + reverseMappedIndex -= pagination.pageIndex * pagination.pageSize; + } + onCellFocus([colIndex, reverseMappedIndex]); + }, + [onCellFocus, rowMap, pagination] + ); + + const rows = useMemo(() => { + const rows = []; + for (let i = 0; i < visibleRowIndices.length; i++) { + let rowIndex = visibleRowIndices[i]; + if (rowMap.hasOwnProperty(rowIndex)) { + rowIndex = rowMap[rowIndex]; + } + + const mergedPopoverContents = { + ...providedPopoverContents, + ...popoverContents, + }; + + rows.push( + + ); + } + + return rows; + }, [ + columns, + columnWidths, + defaultColumnWidth, + focusedCell, + onCellFocus, + renderCellValue, + rowMap, + schema, + popoverContents, + visibleRowIndices, + startRow, + interactiveCellId, + ]); + + return {rows}; +}; diff --git a/src/components/datagrid/data_grid_cell.tsx b/src/components/datagrid/data_grid_cell.tsx new file mode 100644 index 00000000000..904bb38ecdb --- /dev/null +++ b/src/components/datagrid/data_grid_cell.tsx @@ -0,0 +1,369 @@ +import React, { + Component, + FunctionComponent, + JSXElementConstructor, + memo, + ReactNode, + createRef, + HTMLAttributes, + KeyboardEvent, + ReactChild, +} from 'react'; +import classNames from 'classnames'; +import tabbable from 'tabbable'; +import { EuiPopover } from '../popover'; +import { CommonProps, Omit } from '../common'; +import { EuiScreenReaderOnly } from '../accessibility'; +import { EuiI18n } from '../i18n'; +import { EuiButtonIcon } from '../button'; +import { keyCodes } from '../../services'; +import { EuiDataGridPopoverContent } from './data_grid_types'; +import { EuiMutationObserver } from '../observer/mutation_observer'; + +export interface EuiDataGridCellValueElementProps { + /** + * index of the row being rendered, 0 represents the first row. This index always includes + * pagination offset, meaning the first rowIndex in a grid is `pagination.pageIndex * pagination.pageSize` + * so take care if you need to adjust the rowIndex to fit your data + */ + rowIndex: number; + /** + * id of the column being rendered, the value comes from the #EuiDataGridColumn `id` + */ + columnId: string; + /** + * callback function to set custom props & attributes on the cell's wrapping `div` element; + * it's best to wrap calls to `setCellProps` in a `useEffect` hook + */ + setCellProps: (props: CommonProps & HTMLAttributes) => void; + /** + * whether or not the cell is expandable, comes from the #EuiDataGridColumn `isExpandable` which defaults to `true` + */ + isExpandable: boolean; + /** + * whether or not the cell is expanded + */ + isExpanded: boolean; + /** + * when rendering the cell, `isDetails` is false; when the cell is expanded, `renderCellValue` is called again to render into the details popover and `isDetails` is true + */ + isDetails: boolean; +} + +export interface EuiDataGridCellProps { + rowIndex: number; + colIndex: number; + columnId: string; + columnType?: string | null; + width?: number; + isFocused: boolean; + onCellFocus: Function; + interactiveCellId: string; + isExpandable: boolean; + popoverContent: EuiDataGridPopoverContent; + renderCellValue: + | JSXElementConstructor + | ((props: EuiDataGridCellValueElementProps) => ReactNode); +} + +interface EuiDataGridCellState { + cellProps: CommonProps & HTMLAttributes; + popoverIsOpen: boolean; +} + +type EuiDataGridCellValueProps = Omit< + EuiDataGridCellProps, + 'width' | 'isFocused' | 'interactiveCellId' | 'onCellFocus' | 'popoverContent' +>; + +const EuiDataGridCellContent: FunctionComponent< + EuiDataGridCellValueProps & { + setCellProps: EuiDataGridCellValueElementProps['setCellProps']; + isExpanded: boolean; + } +> = memo(props => { + const { renderCellValue, ...rest } = props; + + // React is more permissible than the TS types indicate + const CellElement = renderCellValue as JSXElementConstructor< + EuiDataGridCellValueElementProps + >; + + return ( + + ); +}); + +export class EuiDataGridCell extends Component< + EuiDataGridCellProps, + EuiDataGridCellState +> { + cellRef = createRef(); + cellContentsRef = createRef(); + tabbingRef: HTMLDivElement | null = null; + state: EuiDataGridCellState = { + cellProps: {}, + popoverIsOpen: false, + }; + + updateFocus = () => { + const cell = this.cellRef.current; + const { isFocused } = this.props; + + if (cell && isFocused) { + cell.focus(); + } + }; + + componentDidUpdate(prevProps: EuiDataGridCellProps) { + const didFocusChange = prevProps.isFocused !== this.props.isFocused; + + if (didFocusChange) { + this.updateFocus(); + } + } + + shouldComponentUpdate( + nextProps: EuiDataGridCellProps, + nextState: EuiDataGridCellState + ) { + if (nextProps.rowIndex !== this.props.rowIndex) return true; + if (nextProps.colIndex !== this.props.colIndex) return true; + if (nextProps.columnId !== this.props.columnId) 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; + + if (nextState.cellProps !== this.state.cellProps) return true; + if (nextState.popoverIsOpen !== this.state.popoverIsOpen) return true; + + return false; + } + + setCellProps = (cellProps: HTMLAttributes) => { + this.setState({ cellProps }); + }; + + setTabbingRef = (ref: HTMLDivElement | null) => { + this.tabbingRef = ref; + this.preventTabbing(); + }; + + preventTabbing = () => { + if (this.tabbingRef) { + const tabbables = tabbable(this.tabbingRef); + for (let i = 0; i < tabbables.length; i++) { + tabbables[i].setAttribute('tabIndex', '-1'); + } + } + }; + + render() { + const { + width, + isFocused, + isExpandable, + popoverContent: PopoverContent, + interactiveCellId, + columnType, + onCellFocus, + ...rest + } = this.props; + const { colIndex, rowIndex } = rest; + + 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; + } + + const handleCellKeyDown = (e: KeyboardEvent) => { + if (isExpandable) { + switch (e.keyCode) { + case keyCodes.ENTER: + e.preventDefault(); + this.setState({ popoverIsOpen: true }); + break; + case keyCodes.F2: + e.preventDefault(); + this.setState({ popoverIsOpen: true }); + break; + } + } + }; + + const cellContentProps = { + ...rest, + setCellProps: this.setCellProps, + columnType: columnType, + isExpandable, + isExpanded: this.state.popoverIsOpen, + isDetails: false, + }; + + const buttonIconClasses = classNames( + 'euiDataGridRowCell__expandButtonIcon', + { + 'euiDataGridRowCell__expandButtonIcon-isActive': this.state + .popoverIsOpen, + } + ); + + const buttonClasses = classNames('euiDataGridRowCell__expandButton', { + 'euiDataGridRowCell__expandButton-isActive': this.state.popoverIsOpen, + }); + + const expandButton = ( + + {(expandButtonTitle: string) => ( + + this.setState(({ popoverIsOpen }) => ({ + popoverIsOpen: !popoverIsOpen, + })) + } + title={expandButtonTitle} + /> + )} + + ); + + const screenReaderPosition = ( + +

+ + {([row, column]: ReactChild[]) => ( + + {row}: {rowIndex + 1}, {column}: {colIndex + 1}: + + )} + +

+
+ ); + + let anchorContent = ( +
+ + {mutationRef => { + const onRef = (ref: HTMLDivElement | null) => { + mutationRef(ref); + this.setTabbingRef(ref); + }; + + return ( +
+ {screenReaderPosition} +
+ +
+
+ ); + }} +
+
+ ); + + if (isExpandable) { + anchorContent = ( +
+ + {mutationRef => { + const onRef = (ref: HTMLDivElement | null) => { + mutationRef(ref); + this.setTabbingRef(ref); + }; + + return ( +
+ {screenReaderPosition} +
+ +
+
+ ); + }} +
+
{expandButton}
+
+ ); + } + + let innerContent = anchorContent; + if (isExpandable) { + const CellElement = rest.renderCellValue as JSXElementConstructor< + EuiDataGridCellValueElementProps + >; + const popoverContent = ( + + + + ); + + innerContent = ( +
+ this.setState({ popoverIsOpen: false })} + onTrapDeactivation={this.updateFocus}> + {popoverContent} + +
+ ); + } + + return ( +
onCellFocus([colIndex, rowIndex])}> + {innerContent} +
+ ); + } +} diff --git a/src/components/datagrid/data_grid_column_resizer.tsx b/src/components/datagrid/data_grid_column_resizer.tsx new file mode 100644 index 00000000000..a52441107bb --- /dev/null +++ b/src/components/datagrid/data_grid_column_resizer.tsx @@ -0,0 +1,72 @@ +import React, { Component } from 'react'; + +const MINIMUM_COLUMN_WIDTH = 40; + +interface EuiDataGridColumnResizerProps { + columnId: string; + columnWidth: number; + setColumnWidth: (columnId: string, width: number) => void; +} + +interface EuiDataGridColumnResizerState { + initialX: number; + offset: number; +} + +export class EuiDataGridColumnResizer extends Component< + EuiDataGridColumnResizerProps, + EuiDataGridColumnResizerState +> { + state = { + initialX: 0, + offset: 0, + }; + + onMouseDown = (e: { pageX: number }) => { + this.setState({ + initialX: e.pageX, + }); + + window.addEventListener('mouseup', this.onMouseUp); + window.addEventListener('blur', this.onMouseUp); + window.addEventListener('mousemove', this.onMouseMove); + }; + + onMouseUp = () => { + const { offset } = this.state; + const { columnId, columnWidth, setColumnWidth } = this.props; + setColumnWidth( + columnId, + Math.max(MINIMUM_COLUMN_WIDTH, columnWidth + offset) + ); + + this.setState({ offset: 0 }); + + window.removeEventListener('mouseup', this.onMouseUp); + window.removeEventListener('blur', this.onMouseUp); + window.removeEventListener('mousemove', this.onMouseMove); + }; + + onMouseMove = (e: { pageX: number }) => { + const { columnWidth } = this.props; + this.setState(({ initialX }) => ({ + offset: Math.max( + e.pageX - initialX, + -(columnWidth - MINIMUM_COLUMN_WIDTH) + ), + })); + }; + + render() { + const { offset } = this.state; + + return ( +
+ ); + } +} diff --git a/src/components/datagrid/data_grid_data_row.tsx b/src/components/datagrid/data_grid_data_row.tsx new file mode 100644 index 00000000000..abe7aed8ec2 --- /dev/null +++ b/src/components/datagrid/data_grid_data_row.tsx @@ -0,0 +1,94 @@ +import React, { FunctionComponent, HTMLAttributes } from 'react'; +import classnames from 'classnames'; +import { + EuiDataGridColumn, + EuiDataGridColumnWidths, + EuiDataGridPopoverContent, + EuiDataGridPopoverContents, +} from './data_grid_types'; +import { CommonProps } from '../common'; + +import { EuiDataGridCell, EuiDataGridCellProps } from './data_grid_cell'; +import { EuiDataGridSchema } from './data_grid_schema'; +import { EuiText } from '../text'; + +export type EuiDataGridDataRowProps = CommonProps & + HTMLAttributes & { + rowIndex: number; + columns: EuiDataGridColumn[]; + schema: EuiDataGridSchema; + popoverContents: EuiDataGridPopoverContents; + columnWidths: EuiDataGridColumnWidths; + defaultColumnWidth?: number | null; + focusedCell: [number, number]; + renderCellValue: EuiDataGridCellProps['renderCellValue']; + onCellFocus: Function; + interactiveCellId: EuiDataGridCellProps['interactiveCellId']; + visibleRowIndex: number; + }; + +const DefaultColumnFormatter: EuiDataGridPopoverContent = ({ children }) => { + return {children}; +}; + +const EuiDataGridDataRow: FunctionComponent< + EuiDataGridDataRowProps +> = props => { + const { + columns, + schema, + popoverContents, + columnWidths, + defaultColumnWidth, + className, + renderCellValue, + rowIndex, + focusedCell, + onCellFocus, + interactiveCellId, + 'data-test-subj': _dataTestSubj, + visibleRowIndex, + ...rest + } = props; + + const classes = classnames('euiDataGridRow', className); + const dataTestSubj = classnames('dataGridRow', _dataTestSubj); + + 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 isFocused = + focusedCell[0] === i && focusedCell[1] === visibleRowIndex; + + return ( + + ); + })} +
+ ); +}; + +export { EuiDataGridDataRow }; diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx new file mode 100644 index 00000000000..db1e2650c35 --- /dev/null +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -0,0 +1,308 @@ +import React, { + HTMLAttributes, + forwardRef, + FunctionComponent, + useRef, + useEffect, + useState, +} from 'react'; +import classnames from 'classnames'; +import tabbable from 'tabbable'; +import { + EuiDataGridColumnWidths, + EuiDataGridColumn, + EuiDataGridSorting, +} from './data_grid_types'; +import { CommonProps, Omit } from '../common'; +import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; +import { htmlIdGenerator } from '../../services/accessibility'; +import { EuiScreenReaderOnly } from '../accessibility'; +import { EuiDataGridSchema } from './data_grid_schema'; +import { EuiDataGridDataRowProps } from './data_grid_data_row'; +import { keyCodes } from '../../services'; + +interface EuiDataGridHeaderRowPropsSpecificProps { + columns: EuiDataGridColumn[]; + columnWidths: EuiDataGridColumnWidths; + schema: EuiDataGridSchema; + defaultColumnWidth?: number | null; + setColumnWidth: (columnId: string, width: number) => void; + sorting?: EuiDataGridSorting; + focusedCell: EuiDataGridDataRowProps['focusedCell']; + setFocusedCell: EuiDataGridDataRowProps['onCellFocus']; + headerIsInteractive: boolean; +} + +type EuiDataGridHeaderRowProps = CommonProps & + HTMLAttributes & + EuiDataGridHeaderRowPropsSpecificProps; + +interface EuiDataGridHeaderCellProps + extends Omit { + column: EuiDataGridColumn; + index: number; +} +const EuiDataGridHeaderCell: FunctionComponent< + EuiDataGridHeaderCellProps +> = props => { + const { + column, + index, + columnWidths, + schema, + defaultColumnWidth, + setColumnWidth, + sorting, + focusedCell, + setFocusedCell, + headerIsInteractive, + } = props; + const { id, display } = column; + + const width = columnWidths[id] || defaultColumnWidth; + + const ariaProps: { + 'aria-sort'?: HTMLAttributes['aria-sort']; + 'aria-describedby'?: HTMLAttributes['aria-describedby']; + } = {}; + + let screenReaderId; + let sortString; + + if (sorting) { + const sortedColumnIds = new Set(sorting.columns.map(({ id }) => id)); + + if (sorting.columns.length === 1 && sortedColumnIds.has(id)) { + const sortDirection = sorting.columns[0].direction; + + let sortValue: HTMLAttributes['aria-sort'] = 'other'; + if (sortDirection === 'asc') { + sortValue = 'ascending'; + } else if (sortDirection === 'desc') { + sortValue = 'descending'; + } + + ariaProps['aria-sort'] = sortValue; + } else if (sorting.columns.length >= 2 && sortedColumnIds.has(id)) { + sortString = sorting.columns + .map(col => `Sorted by ${col.id} ${col.direction}`) + .join(' then '); + screenReaderId = htmlIdGenerator()(); + ariaProps['aria-describedby'] = screenReaderId; + } + } + + const columnType = schema[id] ? schema[id].columnType : null; + + const classes = classnames('euiDataGridHeaderCell', { + [`euiDataGridHeaderCell--${columnType}`]: columnType, + }); + + const headerRef = useRef(null); + const isFocused = focusedCell[0] === index && focusedCell[1] === -1; + const [isCellEntered, setIsCellEntered] = useState(false); + + useEffect(() => { + if (headerRef.current) { + function enableInteractives() { + const interactiveElements = headerRef.current!.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + for (let i = 0; i < interactiveElements.length; i++) { + interactiveElements[i].setAttribute('tabIndex', '0'); + } + } + + function disableInteractives() { + const tababbles = tabbable(headerRef.current!); + if (tababbles.length > 1) { + console.warn( + `EuiDataGridHeaderCell expects at most 1 tabbable element, ${ + tababbles.length + } found instead` + ); + } + for (let i = 0; i < tababbles.length; i++) { + const element = tababbles[i]; + element.setAttribute('data-euigrid-tab-managed', 'true'); + element.setAttribute('tabIndex', '-1'); + } + } + + if (isCellEntered) { + enableInteractives(); + const tabbables = tabbable(headerRef.current!); + if (tabbables.length > 0) { + tabbables[0].focus(); + } + } else { + disableInteractives(); + } + } + }, [isCellEntered]); + + useEffect(() => { + if (headerRef.current) { + if (isFocused) { + const interactives = headerRef.current.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + if (interactives.length === 1) { + setIsCellEntered(true); + } else { + headerRef.current.focus(); + } + } else { + setIsCellEntered(false); + } + + // focusin bubbles while focus does not, and this needs to react to children gaining focus + function onFocusIn(e: FocusEvent) { + if (headerIsInteractive === false) { + // header is not interactive, avoid focusing + requestAnimationFrame(() => headerRef.current!.blur()); + e.preventDefault(); + return false; + } else { + // take the focus + setFocusedCell([index, -1]); + } + } + + // focusout bubbles while blur does not, and this needs to react to the children losing focus + function onFocusOut() { + // wait for the next element to receive focus, then update interactives' state + requestAnimationFrame(() => { + if (headerRef.current) { + if (headerRef.current.contains(document.activeElement) === false) { + setIsCellEntered(false); + } + } + }); + } + + function onKeyUp(e: KeyboardEvent) { + switch (e.keyCode) { + case keyCodes.ENTER: { + e.preventDefault(); + setIsCellEntered(true); + break; + } + case keyCodes.ESCAPE: { + e.preventDefault(); + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); + break; + } + case keyCodes.F2: { + e.preventDefault(); + if (document.activeElement === headerRef.current) { + // move focus into cell's interactives + setIsCellEntered(true); + } else { + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); + } + break; + } + } + } + + // @ts-ignore-next line TS doesn't have focusin + headerRef.current.addEventListener('focusin', onFocusIn); + headerRef.current.addEventListener('focusout', onFocusOut); + headerRef.current.addEventListener('keyup', onKeyUp); + return () => { + // @ts-ignore-next line TS doesn't have focusin + headerRef.current!.removeEventListener('focusin', onFocusIn); + headerRef.current!.removeEventListener('focusout', onFocusOut); + headerRef.current!.removeEventListener('keyup', onKeyUp); + }; + } + }, [ + headerIsInteractive, + isFocused, + headerRef.current, + setIsCellEntered, + setFocusedCell, + ]); + + return ( +
+ {width ? ( + + ) : null} + +
{display || id}
+ {sorting && sorting.columns.length >= 2 && ( + +
{sortString}
+
+ )} +
+ ); +}; + +const EuiDataGridHeaderRow = forwardRef< + HTMLDivElement, + EuiDataGridHeaderRowProps +>((props, ref) => { + const { + columns, + schema, + columnWidths, + defaultColumnWidth, + className, + setColumnWidth, + sorting, + focusedCell, + setFocusedCell, + headerIsInteractive, + 'data-test-subj': _dataTestSubj, + ...rest + } = props; + + const classes = classnames('euiDataGridHeader', className); + const dataTestSubj = classnames('dataGridHeader', _dataTestSubj); + + return ( +
+ {columns.map((column, index) => ( + + ))} +
+ ); +}); + +export { EuiDataGridHeaderRow }; diff --git a/src/components/datagrid/data_grid_inmemory_renderer.tsx b/src/components/datagrid/data_grid_inmemory_renderer.tsx new file mode 100644 index 00000000000..655663fef90 --- /dev/null +++ b/src/components/datagrid/data_grid_inmemory_renderer.tsx @@ -0,0 +1,154 @@ +import React, { + Fragment, + FunctionComponent, + JSXElementConstructor, + useEffect, + useMemo, + useState, +} from 'react'; +import { createPortal, unstable_batchedUpdates } from 'react-dom'; +import { + EuiDataGridCellValueElementProps, + EuiDataGridCellProps, +} from './data_grid_cell'; +import { EuiDataGridColumn, EuiDataGridInMemory } from './data_grid_types'; + +interface EuiDataGridInMemoryRendererProps { + inMemory: EuiDataGridInMemory; + columns: EuiDataGridColumn[]; + rowCount: number; + renderCellValue: EuiDataGridCellProps['renderCellValue']; + onCellRender: ( + rowIndex: number, + column: EuiDataGridColumn, + value: string + ) => void; +} + +function noop() {} + +const _queue: Function[] = []; + +function processQueue() { + // the queued functions trigger react setStates which, if unbatched, + // each cause a full update->render->dom pass _per function_ + // instead, tell React to wait until all updates are finished before re-rendering + unstable_batchedUpdates(() => { + for (let i = 0; i < _queue.length; i++) { + _queue[i](); + } + _queue.length = 0; + }); +} + +function enqueue(fn: Function) { + if (_queue.length === 0) { + setTimeout(processQueue); + } + _queue.push(fn); +} + +function getElementText(element: HTMLElement) { + return 'innerText' in element + ? element.innerText + : // TS thinks element.innerText always exists, however it doesn't in jest/jsdom enviornment + // @ts-ignore-next-line + element.textContent || undefined; +} + +const ObservedCell: FunctionComponent<{ + renderCellValue: EuiDataGridInMemoryRendererProps['renderCellValue']; + onCellRender: EuiDataGridInMemoryRendererProps['onCellRender']; + i: number; + column: EuiDataGridColumn; + isExpandable: boolean; +}> = ({ renderCellValue, i, column, onCellRender, isExpandable }) => { + const [ref, setRef] = useState(); + + useEffect(() => { + if (ref) { + // this is part of React's component lifecycle, onCellRender->setState are automatically batched + onCellRender(i, column, getElementText(ref)); + const observer = new MutationObserver(() => { + // onMutation callbacks aren't in the component lifecycle, intentionally batch any effects + enqueue(onCellRender.bind(null, i, column, getElementText(ref))); + }); + observer.observe(ref, { + characterData: true, + subtree: true, + attributes: true, + childList: true, + }); + + return () => { + observer.disconnect(); + }; + } + }, [ref]); + + const CellElement = renderCellValue as JSXElementConstructor< + EuiDataGridCellValueElementProps + >; + + return ( +
+ +
+ ); +}; + +export const EuiDataGridInMemoryRenderer: FunctionComponent< + EuiDataGridInMemoryRendererProps +> = ({ inMemory, columns, rowCount, renderCellValue, onCellRender }) => { + const [documentFragment] = useState(() => document.createDocumentFragment()); + + const rows = useMemo(() => { + const rows = []; + + for (let i = 0; i < rowCount; i++) { + rows.push( + + {columns + .map(column => { + const skipThisColumn = + inMemory.skipColumns && + inMemory.skipColumns.indexOf(column.id) !== -1; + + if (skipThisColumn) { + return null; + } + + const isExpandable = + column.isExpandable !== undefined ? column.isExpandable : true; + + return ( + + ); + }) + .filter(cell => cell != null)} + + ); + } + + return rows; + }, [columns, rowCount, renderCellValue, onCellRender]); + + return createPortal( + {rows}, + (documentFragment as unknown) as Element + ); +}; diff --git a/src/components/datagrid/data_grid_schema.tsx b/src/components/datagrid/data_grid_schema.tsx new file mode 100644 index 00000000000..df1bc5c1bb8 --- /dev/null +++ b/src/components/datagrid/data_grid_schema.tsx @@ -0,0 +1,398 @@ +import React, { useMemo, ReactNode } from 'react'; +import { + EuiDataGridColumn, + EuiDataGridInMemoryValues, +} from './data_grid_types'; + +import { EuiI18n } from '../i18n'; + +import { palettes } from '../../services/color/eui_palettes'; +import { IconType } from '../icon'; + +export interface EuiDataGridSchemaDetector { + /** + * The name of this data type, matches #EuiDataGridColumn / schema `schema` + */ + type: string; + /** + * The function given the text value of a cell and returns a score of [0...1] of how well the value matches this data type + */ + detector: (value: string) => number; + /** + * A custom comparator function when performing in-memory sorting on this data type, takes `(a: string, b: string, direction: 'asc' | 'desc) => -1 | 0 | 1` + */ + comparator?: (a: string, b: string, direction: 'asc' | 'desc') => -1 | 0 | 1; + /** + * The icon used to visually represent this data type. Accepts any `EuiIcon IconType`. + */ + icon: IconType; + /** + * The color associated with this data type; it's used to color the icon + */ + color: string; + /** + * Text for how to represent an ascending sort of this data type, e.g. 'A -> Z' + */ + sortTextAsc: ReactNode; + /** + * Text for how to represent a descending sort of this data type, e.g. 'Z -> A' + */ + sortTextDesc: ReactNode; +} + +const numericChars = new Set([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '.', + '-', +]); +export const schemaDetectors: EuiDataGridSchemaDetector[] = [ + { + type: 'boolean', + detector(value) { + return value.toLowerCase() === 'true' || value.toLowerCase() === 'false' + ? 1 + : 0; + }, + comparator(a, b, direction) { + const aValue = a.toLowerCase() === 'true'; + const bValue = b.toLowerCase() === 'true'; + if (aValue < bValue) return direction === 'asc' ? 1 : -1; + if (aValue > bValue) return direction === 'asc' ? -1 : 1; + return 0; + }, + icon: 'invert', + color: palettes.euiPaletteColorBlind.colors[5], + sortTextAsc: ( + + ), + sortTextDesc: ( + + ), + }, + { + type: 'currency', + detector(value) { + 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; + }, + comparator: (a, b, direction) => { + const aChars = a.split('').filter(char => numericChars.has(char)); + const aValue = parseFloat(aChars.join('')); + + const bChars = b.split('').filter(char => numericChars.has(char)); + const bValue = parseFloat(bChars.join('')); + + if (aValue < bValue) return direction === 'asc' ? -1 : 1; + if (aValue > bValue) return direction === 'asc' ? 1 : -1; + return 0; + }, + icon: 'currency', + color: palettes.euiPaletteColorBlind.colors[0], + sortTextAsc: ( + + ), + sortTextDesc: ( + + ), + }, + { + type: 'datetime', + detector(value) { + // 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; + }, + icon: 'calendar', + color: palettes.euiPaletteColorBlind.colors[7], + sortTextAsc: ( + + ), + sortTextDesc: ( + + ), + }, + { + type: 'numeric', + detector(value) { + const matchLength = (value.match(/[%-(]*[\d,]+(\.\d*)?[%)]*/) || [''])[0] + .length; + return matchLength / value.length || 0; + }, + comparator: (a, b, direction) => { + const aChars = a.split('').filter(char => numericChars.has(char)); + const aValue = parseFloat(aChars.join('')); + + const bChars = b.split('').filter(char => numericChars.has(char)); + const bValue = parseFloat(bChars.join('')); + + if (aValue < bValue) return direction === 'asc' ? -1 : 1; + if (aValue > bValue) return direction === 'asc' ? 1 : -1; + return 0; + }, + icon: 'number', + color: palettes.euiPaletteColorBlind.colors[0], + sortTextAsc: ( + + ), + sortTextDesc: ( + + ), + }, + { + type: 'json', + detector(value: string) { + // does this look like it might be a JSON object? + const maybeArray = value[0] === '[' && value[value.length - 1] === ']'; + const maybeObject = value[0] === '{' && value[value.length - 1] === '}'; + if (!maybeArray && !maybeObject) return 0; + + try { + JSON.parse(value); + return 1; + } catch (e) { + return 0; + } + }, + comparator: (a, b, direction) => { + if (a.length > b.length) return direction === 'asc' ? 1 : -1; + if (a.length < b.length) return direction === 'asc' ? 1 : -1; + return 0; + }, + icon: 'visVega', + color: palettes.euiPaletteColorBlind.colors[3], + sortTextAsc: ( + + ), + sortTextDesc: ( + + ), + }, +]; + +export interface EuiDataGridSchema { + [columnId: string]: { columnType: string | null }; +} + +interface SchemaTypeScore { + type: string; + score: number; +} + +function scoreValueBySchemaType( + value: string, + schemaDetectors: EuiDataGridSchemaDetector[] = [] +) { + const scores: SchemaTypeScore[] = []; + + for (let i = 0; i < schemaDetectors.length; i++) { + const { type, detector } = schemaDetectors[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: EuiDataGridSchemaDetector[] | 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, schemaDetectors]); + return schema; +} + +export function getMergedSchema( + detectedSchema: EuiDataGridSchema, + columns: EuiDataGridColumn[] +) { + return useMemo(() => { + const mergedSchema = { ...detectedSchema }; + + for (let i = 0; i < columns.length; i++) { + const { id, schema } = columns[i]; + if (schema != null) { + if (detectedSchema.hasOwnProperty(id)) { + mergedSchema[id] = { ...detectedSchema[id], columnType: schema }; + } else { + mergedSchema[id] = { columnType: schema }; + } + } + } + + return mergedSchema; + }, [detectedSchema, columns]); +} + +// Given a provided schema, return the details for the schema +// Useful for grabbing the color or icon +export function getDetailsForSchema( + detectors: EuiDataGridSchemaDetector[], + providedSchema: string | null +) { + const results = detectors.filter(matches => { + return matches.type === providedSchema; + }); + + return results[0]; +} diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts new file mode 100644 index 00000000000..1c98dc4d7d5 --- /dev/null +++ b/src/components/datagrid/data_grid_types.ts @@ -0,0 +1,164 @@ +import { FunctionComponent, ReactNode } from 'react'; + +export interface EuiDataGridColumn { + /** + * The unique identifier for this column + */ + id: string; + /** + * A `ReactNode` used when rendering the column header. When providing complicated content, please make sure to utilize CSS to respect truncation as space allows. Check the docs example. + */ + display?: ReactNode; + /** + * A Schema to use for the column. Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] but can be expanded by defining your own #EuiDataGrid `schemaDetectors` (for in-memory detection). In general, it is advised to pass in a value here when you are sure of the schema ahead of time, so that you don't need to rely on the automatic detection. + */ + schema?: string; + /** + * Defauls to true. Defines shether or not the column's cells can be expanded with a popup onClick / keydown. + */ + isExpandable?: boolean; +} + +export interface EuiDataGridColumnVisibility { + /** + * An array of #EuiDataGridColumn `id`s dictating the order and visibility of columns. + */ + visibleColumns: string[]; + /** + * A callback for when a column's visibility or order is modified by the user. + */ + setVisibleColumns: (visibleColumns: string[]) => void; +} + +export interface EuiDataGridColumnWidths { + [key: string]: number; +} +// Types for styling options, passed down through the `gridStyle` prop +export type EuiDataGridStyleFontSizes = 's' | 'm' | 'l'; +export type EuiDataGridStyleBorders = 'all' | 'horizontal' | 'none'; +export type EuiDataGridStyleHeader = 'shade' | 'underline'; +export type EuiDataGridStyleRowHover = 'highlight' | 'none'; +export type EuiDataGridStyleCellPaddings = 's' | 'm' | 'l'; + +export interface EuiDataGridStyle { + /** + * Size of fonts used within the row and column cells + */ + fontSize?: EuiDataGridStyleFontSizes; + /** + * Border uses for the row and column cells + */ + border?: EuiDataGridStyleBorders; + /** + * If set to true, rows will alternate zebra striping for clarity + */ + stripes?: boolean; + /** + * Visual style for the column headers. Recommendation is to use the `underline` style in times when #EuiDataGrid `toolbarVisibility` is set to `false`. + */ + header?: EuiDataGridStyleHeader; + /** + * Will define what visual style to show on row hover + */ + rowHover?: EuiDataGridStyleRowHover; + /** + * Defines the padding with the row and column cells + */ + cellPadding?: EuiDataGridStyleCellPaddings; +} + +export interface EuiDataGridTooBarVisibilityOptions { + /** + * Allows the ability for the user to hide fields and sort columns + */ + showColumnSelector?: boolean; + /** + * Allows the ability for the user to set the grid density. If on, this merges against what is provided in #EuiDataGridStyle + */ + showStyleSelector?: boolean; + /** + * Allows the ability for the user to sort rows based upon column values + */ + showSortSelector?: boolean; + /** + * Allows user to be able to full screen the data grid. If set to `false` make sure your grid fits within a large enough panel to still show the other controls. + */ + showFullScreenSelector?: boolean; +} + +// ideally this would use a generic to enforce `pageSize` exists in `pageSizeOptions`, +// but TypeScript's default understanding of an array is number[] unless `as const` is used +// which defeats the generic's purpose & functionality as it would check for `number` in `number[]` +export interface EuiDataGridPaginationProps { + /** + * The index of the current page, starts at 0 for the first page + */ + pageIndex: number; + /** + * How many rows should initially be shown per page + */ + pageSize: number; + /** + * An array of page sizes the user can select from + */ + pageSizeOptions: number[]; + /** + * A callback for when the user changes the page size selection + */ + onChangeItemsPerPage: (itemsPerPage: number) => void; + /** + * A callback for when the current page index changes + */ + onChangePage: (pageIndex: number) => void; +} + +export interface EuiDataGridSorting { + /** + * A function that receives updated column sort details in response to user interactions in the toolbar controls + */ + onSort: (columns: EuiDataGridSorting['columns']) => void; + /** + * An array of the column ids currently being sorted and their sort direction. The array order determines the sort order. `{ id: 'A'; direction: 'asc' }` + */ + columns: Array<{ id: string; direction: 'asc' | 'desc' }>; +} + +export interface EuiDataGridInMemory { + /** + Given the data flow Sorting->Pagination: + Each step can be performed by service calls or in-memory by the grid. + However, we cannot allow any service calls after an in-memory operation. + E.g. if Pagination requires a service call the grid cannot perform + in-memory Sorting. This means a single value representing the + service / in-memory boundary can be used. Thus there are four states for in-memory's level: + * "enhancements" - no in-memory operations, but use the available data to enhance the grid + * "pagination" - only pagination is performed in-memory + * "sorting" - sorting & pagination is performed in-memory + */ + level: 'enhancements' | 'pagination' | 'sorting'; + /** + * An array of column ids for the in-memory processing to skip + */ + skipColumns?: string[]; +} + +export interface EuiDataGridInMemoryValues { + [key: string]: { [key: string]: string }; +} + +export interface EuiDataGridPopoverContentProps { + /** + * your `cellValueRenderer` as a ReactElement; allows wrapping the rendered content: `({children}) =>
{children}
` + */ + children: ReactNode; + /** + * div element the cell contents have been rendered into; useful for processing the rendered text + */ + cellContentsElement: HTMLDivElement; +} +export type EuiDataGridPopoverContent = FunctionComponent< + EuiDataGridPopoverContentProps +>; +export interface EuiDataGridPopoverContents { + [key: string]: EuiDataGridPopoverContent; +} diff --git a/src/components/datagrid/index.ts b/src/components/datagrid/index.ts new file mode 100644 index 00000000000..7da013fa837 --- /dev/null +++ b/src/components/datagrid/index.ts @@ -0,0 +1 @@ +export { EuiDataGrid } from './data_grid'; diff --git a/src/components/datagrid/style_selector.tsx b/src/components/datagrid/style_selector.tsx new file mode 100644 index 00000000000..0d2b7c82015 --- /dev/null +++ b/src/components/datagrid/style_selector.tsx @@ -0,0 +1,128 @@ +import React, { ReactElement, useState } from 'react'; +import { EuiDataGridStyle } from './data_grid_types'; +import { EuiI18n } from '../i18n'; +// @ts-ignore-next-line +import { EuiPopover } from '../popover'; +// @ts-ignore-next-line +import { EuiButtonEmpty, EuiButtonGroup } from '../button'; + +export const startingStyles: EuiDataGridStyle = { + cellPadding: 'm', + fontSize: 'm', + border: 'all', + stripes: false, + rowHover: 'highlight', + header: 'shade', +}; + +const densityStyles: { [key: string]: Partial } = { + expanded: { + fontSize: 'l', + cellPadding: 'l', + }, + normal: { + fontSize: 'm', + cellPadding: 'm', + }, + compact: { + fontSize: 's', + cellPadding: 's', + }, +}; + +export const useStyleSelector = ( + initialStyles: EuiDataGridStyle +): [ReactElement, EuiDataGridStyle] => { + // track styles specified by the user at run time + const [userGridStyles, setUserGridStyles] = useState({}); + + const [isOpen, setIsOpen] = useState(false); + + // These are the available options. They power the gridDensity hook and also the options in the render + const densityOptions: string[] = ['expanded', 'normal', 'compact']; + + // Normal is the default density + const [gridDensity, _setGridDensity] = useState(densityOptions[1]); + const setGridDensity = (density: string) => { + _setGridDensity(density); + setUserGridStyles(densityStyles[density]); + }; + + // merge the developer-specified styles with any user overrides + const gridStyles = { + ...initialStyles, + ...userGridStyles, + }; + + const styleSelector = ( + setIsOpen(false)} + anchorPosition="downCenter" + ownFocus + panelPaddingSize="s" + panelClassName="euiDataGridColumnSelectorPopover" + button={ + setIsOpen(!isOpen)}> + + + }> + + {([ + buttonLegend, + labelExpanded, + labelNormal, + labelCompact, + ]: string[]) => ( + + )} + + + ); + + return [styleSelector, gridStyles]; +}; diff --git a/src/components/drag_and_drop/draggable.tsx b/src/components/drag_and_drop/draggable.tsx index 87398886a51..74544e25979 100644 --- a/src/components/drag_and_drop/draggable.tsx +++ b/src/components/drag_and_drop/draggable.tsx @@ -56,7 +56,11 @@ export const EuiDraggable: FunctionComponent = ({ const { cloneItems } = useContext(EuiDroppableContext); return ( - + {(provided, snapshot) => { const classes = classNames( 'euiDraggable', diff --git a/src/components/drag_and_drop/services.ts b/src/components/drag_and_drop/services.ts index 083178ce9e4..fc5a9c6f6fe 100644 --- a/src/components/drag_and_drop/services.ts +++ b/src/components/drag_and_drop/services.ts @@ -4,12 +4,12 @@ interface DropResult { [droppableId: string]: any[]; } -export const euiDragDropReorder = ( - list: [], +export const euiDragDropReorder = ( + list: T, startIndex: number, endIndex: number -): Array<{}> => { - const result = Array.from(list); +): T => { + const result = [...list] as T; const [removed] = result.splice(startIndex, 1); result.splice(endIndex, 0, removed); diff --git a/src/components/focus_trap/focus_trap.tsx b/src/components/focus_trap/focus_trap.tsx index 534aac640b2..24d142dc557 100644 --- a/src/components/focus_trap/focus_trap.tsx +++ b/src/components/focus_trap/focus_trap.tsx @@ -3,6 +3,7 @@ import React, { FunctionComponent, MouseEvent as ReactMouseEvent, EventHandler, + CSSProperties, } from 'react'; import FocusLock, { Props as ReactFocusLockProps } from 'react-focus-lock'; // eslint-disable-line import/named @@ -35,6 +36,7 @@ export type FocusTarget = HTMLElement | string | (() => HTMLElement); interface EuiFocusTrapProps { clickOutsideDisables?: boolean; initialFocus?: FocusTarget; + style?: CSSProperties; } type Props = CommonProps & ReactFocusLockProps & EuiFocusTrapProps; @@ -52,11 +54,11 @@ export class EuiFocusTrap extends Component { preventFocusExit = false; componentDidMount() { - this.setInitalFocus(this.props.initialFocus); + this.setInitialFocus(this.props.initialFocus); } // Programmatically sets focus on a nested DOM node; optional - setInitalFocus = (initialFocus?: FocusTarget) => { + setInitialFocus = (initialFocus?: FocusTarget) => { let node = initialFocus instanceof HTMLElement ? initialFocus : null; if (typeof initialFocus === 'string') { node = document.querySelector(initialFocus as string); @@ -109,6 +111,7 @@ export class EuiFocusTrap extends Component { clickOutsideDisables = false, disabled = false, returnFocus = true, + style, ...rest } = this.props; const isDisabled = disabled || this.state.hasBeenDisabledByClick; @@ -122,11 +125,15 @@ export class EuiFocusTrap extends Component { isDisabled={isDisabled} onOutsideClick={this.handleOutsideClick}> - {children} + + {children} + ) : ( - {children} + + {children} + ); } } diff --git a/src/components/form/_variables.scss b/src/components/form/_variables.scss index c556fec274e..6f2a7728650 100644 --- a/src/components/form/_variables.scss +++ b/src/components/form/_variables.scss @@ -18,6 +18,10 @@ $euiSwitchHeightCompressed: $euiSize !default; $euiSwitchWidthCompressed: $euiSize * 1.75 !default; $euiSwitchThumbSizeCompressed: $euiSwitchHeightCompressed !default; +$euiSwitchHeightMini: $euiSwitchHeight * .5 !default; +$euiSwitchWidthMini: $euiSwitchWidth * .5 !default; +$euiSwitchThumbSizeMini: $euiSwitchHeightMini !default; + // Coloring $euiFormBackgroundColor: tintOrShade($euiColorLightestShade, 60%, 40%) !default; $euiFormBackgroundDisabledColor: darken($euiColorLightestShade, 2%) !default; diff --git a/src/components/form/super_select/__snapshots__/super_select.test.js.snap b/src/components/form/super_select/__snapshots__/super_select.test.js.snap index 6f5de5a0603..7526b7e4281 100644 --- a/src/components/form/super_select/__snapshots__/super_select.test.js.snap +++ b/src/components/form/super_select/__snapshots__/super_select.test.js.snap @@ -179,8 +179,11 @@ exports[`EuiSuperSelect props custom display is propagated to dropdown 1`] = ` data-focus-lock-disabled="disabled" >