From 060a523d83ec861efaed3c9be462a18abd3b8790 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 7 Feb 2018 17:33:23 -0800 Subject: [PATCH] Add EuiBasicTable. --- .../components/guide_section/guide_section.js | 2 +- src-docs/src/routes.js | 4 + .../views/basic_table/basic_table_example.js | 69 +++ .../src/views/basic_table/full_featured.js | 319 ++++++++++ .../views/basic_table/rendering_columns.js | 114 ++++ .../collapsed_row_actions.test.js.snap | 40 ++ .../custom_row_action.test.js.snap | 11 + .../default_row_action.test.js.snap | 42 ++ .../expanded_row_actions.test.js.snap | 44 ++ src/components/basic_table/basic_table.js | 566 ++++++++++++++++++ .../basic_table/collapsed_row_actions.js | 118 ++++ .../basic_table/collapsed_row_actions.test.js | 51 ++ .../basic_table/custom_row_action.js | 56 ++ .../basic_table/custom_row_action.test.js | 40 ++ .../basic_table/default_row_action.js | 118 ++++ .../basic_table/default_row_action.test.js | 79 +++ .../basic_table/expanded_row_actions.js | 50 ++ .../basic_table/expanded_row_actions.test.js | 50 ++ src/components/basic_table/index.js | 3 + src/components/basic_table/pagination_bar.js | 46 ++ src/components/index.js | 4 + src/services/sort/comparators.js | 55 +- src/services/sort/sort_direction.js | 21 +- 23 files changed, 1865 insertions(+), 37 deletions(-) create mode 100644 src-docs/src/views/basic_table/basic_table_example.js create mode 100644 src-docs/src/views/basic_table/full_featured.js create mode 100644 src-docs/src/views/basic_table/rendering_columns.js create mode 100644 src/components/basic_table/__snapshots__/collapsed_row_actions.test.js.snap create mode 100644 src/components/basic_table/__snapshots__/custom_row_action.test.js.snap create mode 100644 src/components/basic_table/__snapshots__/default_row_action.test.js.snap create mode 100644 src/components/basic_table/__snapshots__/expanded_row_actions.test.js.snap create mode 100644 src/components/basic_table/basic_table.js create mode 100644 src/components/basic_table/collapsed_row_actions.js create mode 100644 src/components/basic_table/collapsed_row_actions.test.js create mode 100644 src/components/basic_table/custom_row_action.js create mode 100644 src/components/basic_table/custom_row_action.test.js create mode 100644 src/components/basic_table/default_row_action.js create mode 100644 src/components/basic_table/default_row_action.test.js create mode 100644 src/components/basic_table/expanded_row_actions.js create mode 100644 src/components/basic_table/expanded_row_actions.test.js create mode 100644 src/components/basic_table/index.js create mode 100644 src/components/basic_table/pagination_bar.js diff --git a/src-docs/src/components/guide_section/guide_section.js b/src-docs/src/components/guide_section/guide_section.js index 2e79cae3771..6c48f976e69 100644 --- a/src-docs/src/components/guide_section/guide_section.js +++ b/src-docs/src/components/guide_section/guide_section.js @@ -230,7 +230,7 @@ export class GuideSection extends Component { const title = _euiObjectType === 'type' ? {componentName} : - {componentName}; + {componentName}; let descriptionElement; diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 3ad5641e4c8..090b33d52c7 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -40,6 +40,9 @@ import { AvatarExample } import { BadgeExample } from './views/badge/badge_example'; +import { BasicTableExample } + from './views/basic_table/basic_table_example'; + import { BottomBarExample } from './views/bottom_bar/bottom_bar_example'; @@ -250,6 +253,7 @@ const components = [ ].map(example => createExample(example)); const patterns = [ + BasicTableExample, ].map(example => createExample(example)); const sandboxes = [{ diff --git a/src-docs/src/views/basic_table/basic_table_example.js b/src-docs/src/views/basic_table/basic_table_example.js new file mode 100644 index 00000000000..123e476356b --- /dev/null +++ b/src-docs/src/views/basic_table/basic_table_example.js @@ -0,0 +1,69 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, + EuiBasicTable, +} from '../../../../src/components'; + +import FullFeatured from './full_featured'; +const fullFeaturedSource = require('!!raw-loader!./full_featured'); +const fullFeaturedHtml = renderToHtml(FullFeatured); + +import RenderingColumns from './rendering_columns'; +const renderingColumnsSource = require('!!raw-loader!./rendering_columns'); +const renderingColumnsHtml = renderToHtml(RenderingColumns); + +export const BasicTableExample = { + title: 'BasicTable', + sections: [ + { + title: 'Sorting, pagination, and row-selection', + source: [ + { + type: GuideSectionTypes.JS, + code: fullFeaturedSource, + }, + { + type: GuideSectionTypes.HTML, + code: fullFeaturedHtml, + } + ], + props: { EuiBasicTable }, + demo: + }, { + title: 'Rendering columns', + source: [ + { + type: GuideSectionTypes.JS, + code: renderingColumnsSource, + }, + { + type: GuideSectionTypes.HTML, + code: renderingColumnsHtml, + } + ], + text: ( +
+

+ You can specify a dataType property on your column definitions + to choose a default format with which to render a column’s cells. +

+ +

+ If you want to customize how columns are rendered, you can specify a + render property on the column definitions to + customize the content of a column’s cells. +

+
+ ), + props: { EuiBasicTable }, + demo: + }, + ] +}; diff --git a/src-docs/src/views/basic_table/full_featured.js b/src-docs/src/views/basic_table/full_featured.js new file mode 100644 index 00000000000..a109ff556a0 --- /dev/null +++ b/src-docs/src/views/basic_table/full_featured.js @@ -0,0 +1,319 @@ +import React, { + Component, +} from 'react'; +import uuid from 'uuid/v1'; +import { times } from 'lodash'; + +import { + EuiButton, + EuiBasicTable, + EuiHealth, + EuiSwitch, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, +} from '../../../../src/components'; + +import { + formatDate, + Random, + Comparators +} from '../../../../src/services'; + +export default class extends Component { + constructor(props) { + super(props); + + // In a real use-case, this data can either be stored remotely or locally. + const random = new Random(); + this.rows = times(20, index => ({ + id: index, + firstName: random.oneOf('Martijn', 'Elissa', 'Clinton', 'Igor', 'Karl', 'Drew', 'Honza', 'Rashid', 'Jordan'), + lastName: random.oneOf('van Groningen', 'Weve', 'Gormley', 'Motov', 'Minarik', 'Raines', 'Král', 'Khan', 'Sissel'), + nickname: random.oneOf('martijnvg', 'elissaw', 'clintongormley', 'imotov', 'karmi', 'drewr', 'HonzaKral', 'rashidkpc', 'whack'), + dateOfBirth: random.date({ min: new Date(1971, 0, 0), max: new Date(1990, 0, 0) }), + country: random.oneOf('us', 'nl', 'cz', 'za', 'au'), + online: random.boolean(), + })); + + this.state = { + selectedIds: [], + hasPagination: true, + hasSorting: true, + hasSelection: true, + hasMultipleRecordActions: true, + pagination: { + index: 0, + size: 5, + pageSizeOptions: [3, 5, 8], + }, + sorting: { + direction: 'asc', + field: 'firstName', + }, + totalRowCount: this.rows.length, + }; + + this.state.visibleRows = this.getVisibleRows(); + } + + getVisibleRows() { + const { + sorting, + pagination, + } = this.state; + + let sortedRows = this.rows; + + if (sorting) { + sortedRows = sortedRows.sort(Comparators.property(sorting.field, Comparators.default(sorting.direction))); + } + + // Return all rows. + if (!pagination) { + return sortedRows; + } + + // Return just the rows on the page. + const firstItemIndex = pagination.index * pagination.size; + return sortedRows.slice(firstItemIndex, Math.min(firstItemIndex + pagination.size, sortedRows.length)); + } + + onSelectionChanged = selection => { + const selectedIds = selection.map(item => item.id); + this.setState({ + selectedIds, + }); + }; + + onTableChange = ({ pagination, sorting }) => { + // Overwrite old state with new state. + this.setState(({ pagination: prevPagination, sorting: prevSorting }) => ({ + pagination: { + ...prevPagination, + ...pagination, + }, + sorting: { + ...prevSorting, + ...sorting, + }, + })); + }; + + deleteRow(rowToDelete) { + const i = this.rows.findIndex((row) => row.id === rowToDelete.id); + if (i !== -1) { + this.rows.splice(i, 1); + } + + this.setState({ + totalRowCount: this.rows.length, + }); + } + + deleteSelectedRow = () => { + this.state.selectedIds.forEach(id => { + const i = this.rows.findIndex((row) => row.id === id); + if (i !== -1) { + this.rows.splice(i, 1); + } + }); + + this.setState({ + totalRowCount: this.rows.length, + }); + }; + + cloneRow(rowToClone) { + const i = this.rows.findIndex((row) => row.id === rowToClone.id); + const clone = { ...rowToClone, id: uuid() }; + this.rows.splice(i, 0, clone); + + this.setState({ + totalRowCount: this.rows.length, + }); + } + + changeRowOnlineStatus(rowToUpdate, online) { + const row = this.rows.find((row) => row.id === rowToUpdate.id); + if (row) { + row.online = online; + } + + // TODO: Just set rows instead? + this.setState({ + totalRowCount: this.rows.length, + }); + } + + toggleFeature(feature) { + this.setState(prevState => ({ + [feature]: !prevState[feature], + })); + } + + renderControls() { + const { + hasPagination, + hasSelection, + hasSorting, + hasMultipleRecordActions, + selectedIds, + } = this.state; + + let deleteButton; + + if (selectedIds.length) { + const label = selectedIds.length > 1 ? `Delete ${selectedIds.length} people` : `Delete 1 row`; + deleteButton = ( + + + {label} + + + ); + } + + return ( + + { deleteButton } + + + + + + + + + + + + + + + + + ); + } + + render() { + const { + totalRowCount, + hasSelection, + hasSorting, + hasPagination, + hasMultipleRecordActions, + } = this.state; + + const columns = [{ + field: 'firstName', + name: 'First Name', + description: `Row's given name`, + dataType: 'string', + sortable: true, + }, { + field: 'lastName', + name: 'Last Name', + description: `Row's family name`, + dataType: 'string', + }, { + field: 'nickname', + name: 'Nickname', + description: `Row's nickname / online handle`, + render: value => ( + + {value} + + ) + }, { + field: 'dateOfBirth', + name: 'Date of Birth', + description: `Row's date of birth`, + render: value => formatDate(value, 'D MMM YYYY'), + sortable: true, + dataType: 'date', + }, { + field: 'online', + name: 'Online', + description: `Is this row currently online?`, + render: (value) => { + const color = value ? 'success' : 'danger'; + const content = value ? 'Online' : 'Offline'; + return {content}; + }, + sortable: true, + }, { + name: '', + // TODO: Move controls to their own example page. + actions: hasMultipleRecordActions ? [ + { + name: 'Clone', + description: 'Clone this row', + icon: 'copy', + onClick: (row) => this.cloneRow(row) + }, + { + name: 'Delete', + description: 'Delete this row', + icon: 'trash', + color: 'danger', + onClick: (row) => this.deleteRow(row) + }, + ] : [ + { + name: 'Delete', + description: 'Delete this row', + icon: 'trash', + type: 'icon', + color: 'danger', + onClick: (row) => this.deleteRow(row) + } + ] + }]; + + const selection = hasSelection ? { + isSelectable: (record) => record.online, + isSlectableMessage: row => !row.online ? `${row.firstName} is offline` : undefined, + onSelectionChanged: this.onSelectionChanged, + } : undefined; + + const pagination = hasPagination ? this.state.pagination : undefined; + const sorting = hasSorting ? this.state.sorting : undefined; + + return ( +
+ {this.renderControls()} + + + + row.id} + columns={columns} + rows={this.getVisibleRows()} + totalRowCount={totalRowCount} + selection={selection} + pagination={pagination} + sorting={sorting} + onChange={this.onTableChange} + /> +
+ ); + } +} diff --git a/src-docs/src/views/basic_table/rendering_columns.js b/src-docs/src/views/basic_table/rendering_columns.js new file mode 100644 index 00000000000..aa84b8f92fa --- /dev/null +++ b/src-docs/src/views/basic_table/rendering_columns.js @@ -0,0 +1,114 @@ +import React, { Component } from 'react'; +import { times } from 'lodash'; + +import { + Random +} from '../../../../src/services'; + +import { + EuiBasicTable, + EuiSwitch, + EuiIcon, + EuiLink, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + const random = new Random(); + + this.state = { + rows: times(5, index => ({ + id: index, + string: random.oneOf('martijnvg', 'elissaw', 'clintongormley', 'imotov', 'karmi', 'drewr', 'HonzaKral', 'rashidkpc', 'whack'), + number: random.integer({ min: 0, max: 2000000 }), + boolean: random.boolean(), + date: random.date({ min: new Date(1971, 0, 0), max: new Date(1990, 0, 0) }), + online: random.boolean(), + })), + }; + } + + onRowOnlineStatusChange(rowId, online) { + this.setState(prevState => { + const newRows = prevState.rows.slice(); + const row = newRows.find(row => row.id === rowId); + if (row) { + row.online = online; + } + + return { + rows: newRows, + }; + }); + } + + render() { + const { + rows, + } = this.state; + + const columns = [{ + field: 'string', + name: 'string', + dataType: 'string', + }, { + field: 'number', + name: 'number', + dataType: 'number' + }, { + field: 'boolean', + name: 'boolean', + dataType: 'boolean' + }, { + field: 'date', + name: 'date', + dataType: 'date' + }, { + field: 'string', + name: 'Custom link', + description: `Row's nickname / online handle`, + render: value => ( + + {value} + + ), + }, { + field: 'online', + name: 'Custom status', + align: 'right', + description: `Is this row is currently online?`, + render: (online, row) => { + const onChange = (event) => { + this.onRowOnlineStatusChange(row.id, event.target.checked); + }; + + return ( + + ); + } + }, { + name: 'Custom icon', + width: '100px', + align: 'right', + render: (row) => { + const color = row.online ? 'success' : 'subdued'; + const title = row.online ? 'Online' : 'Offline'; + return ; + }, + }]; + + return ( + + ); + } +} diff --git a/src/components/basic_table/__snapshots__/collapsed_row_actions.test.js.snap b/src/components/basic_table/__snapshots__/collapsed_row_actions.test.js.snap new file mode 100644 index 00000000000..1d356bd9662 --- /dev/null +++ b/src/components/basic_table/__snapshots__/collapsed_row_actions.test.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollapsedRowActions render 1`] = ` + + } + closePopover={[Function]} + id="id-actions" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + popoverRef={[Function]} +> + + default1 + , + , + ] + } + /> + +`; diff --git a/src/components/basic_table/__snapshots__/custom_row_action.test.js.snap b/src/components/basic_table/__snapshots__/custom_row_action.test.js.snap new file mode 100644 index 00000000000..7ade6e63f0a --- /dev/null +++ b/src/components/basic_table/__snapshots__/custom_row_action.test.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomRowAction render 1`] = ` +
+`; diff --git a/src/components/basic_table/__snapshots__/default_row_action.test.js.snap b/src/components/basic_table/__snapshots__/default_row_action.test.js.snap new file mode 100644 index 00000000000..d03588b83ca --- /dev/null +++ b/src/components/basic_table/__snapshots__/default_row_action.test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultRowAction render - button 1`] = ` + + action1 + +`; + +exports[`DefaultRowAction render - icon 1`] = ` + +`; diff --git a/src/components/basic_table/__snapshots__/expanded_row_actions.test.js.snap b/src/components/basic_table/__snapshots__/expanded_row_actions.test.js.snap new file mode 100644 index 00000000000..040b1fc4c19 --- /dev/null +++ b/src/components/basic_table/__snapshots__/expanded_row_actions.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExpandedRowActions render 1`] = ` +Array [ + , + , +] +`; diff --git a/src/components/basic_table/basic_table.js b/src/components/basic_table/basic_table.js new file mode 100644 index 00000000000..0e9dbbbcd38 --- /dev/null +++ b/src/components/basic_table/basic_table.js @@ -0,0 +1,566 @@ +import React, { + Component, +} from 'react'; +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import { + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableHeaderCellCheckbox, + EuiTableRow, + EuiTableRowCell, + EuiTableRowCellCheckbox, +} from '../table'; + +import { EuiCheckbox } from '../form/checkbox'; + +import { + formatAuto, + formatBoolean, + formatDate, + formatNumber, + formatText, + LEFT_ALIGNMENT, + RIGHT_ALIGNMENT, + SortDirection, +} from '../../services'; + +import { PaginationBar } from './pagination_bar'; +import { CollapsedRowActions } from './collapsed_row_actions'; +import { ExpandedRowActions } from './expanded_row_actions'; + +const dataTypesProfiles = { + auto: { + align: LEFT_ALIGNMENT, + render: value => formatAuto(value) + }, + string: { + align: LEFT_ALIGNMENT, + render: value => formatText(value) + }, + number: { + align: RIGHT_ALIGNMENT, + render: value => formatNumber(value), + }, + boolean: { + align: LEFT_ALIGNMENT, + render: value => formatBoolean(value), + }, + date: { + align: LEFT_ALIGNMENT, + render: value => formatDate(value), + } +}; + +const DATA_TYPES = Object.keys(dataTypesProfiles); + +export class EuiBasicTable extends Component { + static propTypes = { + getRowId: PropTypes.func, + columns: PropTypes.array, + rows: PropTypes.array, + totalRowCount: PropTypes.number, + selection: PropTypes.shape({ + onSelectionChanged: PropTypes.func, + isSelectable: PropTypes.func, + isSelectableMessage: PropTypes.func, + }), + pagination: PropTypes.shape({ + size: PropTypes.number, + index: PropTypes.number, + pageSizeOptions: PropTypes.arrayOf(PropTypes.number), + }), + sorting: PropTypes.shape({ + direction: PropTypes.string, + field: PropTypes.string, + }), + onChange: PropTypes.func, + }; + + static defaultProps = { + getRowId: row => row.id, + }; + + constructor(props) { + super(props); + + this.state = { + hoverRowId: null, + selectedRows: [] + }; + } + + changeSelection(selectedRows) { + const { selection } = this.props; + + if (!selection) { + return; + } + + this.setState({ selectedRows }); + + if (selection.onSelectionChanged) { + selection.onSelectionChanged(selectedRows); + } + } + + clearSelection() { + this.changeSelection([]); + } + + onPageSizeChange = size => { + this.clearSelection(); + + this.props.onChange({ + pagination: { + // When page size changes, we take the user back to the first page. + index: 0, + size, + }, + }); + }; + + onPageChange = (index) => { + const { + pagination, + } = this.props; + + this.clearSelection(); + + this.props.onChange({ + pagination: { + index, + size: pagination.size, + }, + }); + }; + + onColumnSortChange(column) { + const { + pagination, + sorting, + } = this.props; + + this.clearSelection(); + + let direction = SortDirection.ASC; + + if (sorting && sorting.field === column.field) { + direction = SortDirection.reverse(sorting.direction); + } + + this.props.onChange({ + // Reset the page. + pagination: !pagination ? undefined : { + index: 0, + size: pagination.size, + }, + sorting: { + field: column.field, + direction, + }, + }); + } + + onRowHover(rowId) { + this.setState({ hoverRowId: rowId }); + } + + clearRowHover() { + this.setState({ hoverRowId: null }); + } + + componentWillReceiveProps(nextProps) { + const { + selection, + getRowId, + } = this.props; + + // Don't call changeSelection here or else we can get into an infinite loop: + // changeSelection calls props.onSelectionChanged on owner -> + // owner sets its state -> we receive new props, calling componentWillReceiveProps -> ad infinitum + if (!selection) { + return; + } + + this.setState(prevState => { + // Remove any rows which don't exist any more. + const newSelection = prevState.selectedRows.filter(selectedRow => ( + nextProps.rows.findIndex(row => getRowId(row) === getRowId(selectedRow)) !== -1 + )); + + return { + selection: newSelection, + }; + }); + } + + render() { + const { + className, + getRowId, // eslint-disable-line no-unused-vars + columns, // eslint-disable-line no-unused-vars + rows, // eslint-disable-line no-unused-vars + selection, // eslint-disable-line no-unused-vars + pagination, // eslint-disable-line no-unused-vars + sorting, // eslint-disable-line no-unused-vars + onChange, // eslint-disable-line no-unused-vars + totalRowCount, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + const table = this.renderTable(); + const paginationBar = this.renderPaginationBar(); + + return ( +
+ {table} + {paginationBar} +
+ ); + } + + renderTable() { + const { + rows, + } = this.props; + + const head = this.renderTableHead(); + + const renderedRows = rows.map((row, index) => { + return this.renderTableRowRow(row, index); + }); + + return ( + + {head} + {renderedRows} + + ); + } + + renderTableHead() { + const { + columns, + rows, + selection, + sorting, + } = this.props; + + const headers = []; + + if (selection) { + const selectableRows = rows.filter(row => !selection.isSelectable || selection.isSelectable(row)); + + const checked = + this.state.selectedRows + && (selectableRows.length !== 0) + && (this.state.selectedRows.length === selectableRows.length); + + const onChange = (event) => { + if (event.target.checked) { + this.changeSelection(selectableRows); + } else { + this.changeSelection([]); + } + }; + + headers.push( + + + + ); + } + + columns.forEach((column, index) => { + // actions column + if (column.actions) { + headers.push( + + {column.name} + + ); + return; + } + + const align = this.resolveColumnAlign(column); + + // computed column + if (!column.field) { + headers.push( + + {column.name} + + ); + return; + } + + // field data column + const sortDirection = this.resolveColumnSortDirection(column, sorting); + const onSort = this.resolveColumnOnSort(column); + const isSorted = !!sortDirection; + const isSortAscending = SortDirection.isAsc(sortDirection); + + headers.push( + + {column.name} + + ); + }); + + return {headers}; + } + + resolveColumnAlign(column) { + if (column.align) { + return column.align; + } + const dataType = column.dataType || 'auto'; + const profile = dataTypesProfiles[dataType]; + if (!profile) { + throw new Error(`Unknown dataType [${dataType}]. The supported data types are [${DATA_TYPES.join(', ')}]`); + } + return profile.align; + } + + resolveColumnSortDirection(column, sorting) { + if (!sorting) { + return; + } + + if (!column.sortable) { + return; + } + + if (sorting.field === column.field) { + return sorting.direction; + } + } + + resolveColumnOnSort(column) { + if (column.sortable) { + if (!this.props.onChange) { + throw new Error(` + The table is sortable on column [${column.field}] but + [onChange] wasn't provided. This callback must be provided to enable sorting. + `); + } + return () => this.onColumnSortChange(column); + } + } + + renderTableRowRow(row, rowIndex) { + const { + getRowId, + selection, + columns, + } = this.props; + + const rowId = getRowId(row); + const isSelected = this.state.selectedRows && !!this.state.selectedRows.find(selectedRow => { + return this.props.getRowId(selectedRow) === rowId; + }); + + const cells = []; + + if (selection) { + cells.push(this.renderTableRowSelectionCell(rowId, row, isSelected)); + } + + columns.forEach((column, columnIndex) => { + if (column.actions) { + cells.push(this.renderTableRowActionsCell(rowId, row, column.actions, columnIndex)); + } else if (column.field) { + cells.push(this.renderTableRowFieldDataCell(rowId, row, column, columnIndex)); + } else { + cells.push(this.renderTableRowComputedCell(rowId, row, column, columnIndex)); + } + }); + + const onMouseOver = () => this.onRowHover(rowId); + const onMouseOut = () => this.clearRowHover(); + return ( + + {cells} + + ); + } + + renderTableRowFieldDataCell(rowId, row, column, index) { + const key = `_data_column_${column.field}_${rowId}_${index}`; + const align = this.resolveColumnAlign(column); + const textOnly = !column.render; + const value = _.get(row, column.field); + const contentRenderer = this.resolveContentRenderer(column); + const content = contentRenderer(value, row); + return ( + + {content} + + ); + } + + renderTableRowComputedCell(rowId, row, column, index) { + const key = `_computed_column_${rowId}_${index}`; + const align = this.resolveColumnAlign(column); + const renderContent = this.resolveContentRenderer(column); + const content = renderContent(row); + return ( + + {content} + + ); + } + + resolveContentRenderer(column) { + if (column.render) { + return column.render; + } + + const dataType = column.dataType || 'auto'; + const profile = dataTypesProfiles[dataType]; + + if (!profile) { + throw new Error(`Unknown dataType [${dataType}]. The supported data types are [${DATA_TYPES.join(', ')}]`); + } + + return profile.render; + } + + renderTableRowSelectionCell(rowId, row, isSelected) { + const { + getRowId, + selection, + } = this.props; + + const key = `_selection_column_${rowId}`; + const disabled = selection.isSelectable && !selection.isSelectable(row); + const title = selection.isSelectableMessage && selection.isSelectableMessage(row); + const onChange = (event) => { + if (event.target.checked) { + this.changeSelection([...this.state.selectedRows, row]); + } else { + this.changeSelection(this.state.selectedRows.reduce((selectedRows, selectedRow) => { + if (getRowId(selectedRow) !== rowId) { + selectedRows.push(selectedRow); + } + return selectedRows; + }, [])); + } + }; + + return ( + + + + ); + } + + renderTableRowActionsCell(rowId, row, actions, columnIndex) { + const visible = this.state.hoverRowId === rowId; + + const actionEnabled = (action) => + this.state.selectedRows.length === 0 && (!action.isEnabled || action.isEnabled(row)); + + let actualActions = actions; + if (actions.length > 1) { + + // if we have more than 1 action, we don't show them all in the cell, instead we + // put them all in a popover tool. This effectively means we can only have a maximum + // of one tool per row (it's either and normal action, or it's a popover that shows multiple actions) + // + // here we create a single custom action that triggers the popover with all the actions + + actualActions = [ + { + name: 'Actions', + render: (row) => { + return ( + + ); + } + } + ]; + } + + const tools = ( + + ); + + const key = `row_actions_${rowId}_${columnIndex}`; + return ( + + {tools} + + ); + } + + renderPaginationBar() { + const { + pagination, + rows, + totalRowCount, + } = this.props; + + if (pagination) { + return ( + + ); + } + } + +} diff --git a/src/components/basic_table/collapsed_row_actions.js b/src/components/basic_table/collapsed_row_actions.js new file mode 100644 index 00000000000..19b1a845918 --- /dev/null +++ b/src/components/basic_table/collapsed_row_actions.js @@ -0,0 +1,118 @@ +import React, { + Component, +} from 'react'; +import { EuiContextMenuItem, EuiContextMenuPanel } from '../context_menu'; +import { EuiPopover } from '../popover'; +import { EuiButtonIcon } from '../button'; + +export class CollapsedRowActions extends Component { + constructor(props) { + super(props); + this.state = { popoverOpen: false }; + } + + togglePopover = () => { + this.setState(prevState => ({ popoverOpen: !prevState.popoverOpen })); + }; + + closePopover = () => { + this.setState({ popoverOpen: false }); + }; + + onPopoverBlur = () => { + // This timeout is required to make sure we process the onBlur events after the initial + // event cycle. Reference: + // https://medium.com/@jessebeach/dealing-with-focus-and-blur-in-a-composite-widget-in-react-90d3c3b49a9b + window.requestAnimationFrame(() => { + if (!this.popoverDiv.contains(document.activeElement)) { + this.props.onBlur(); + } + }); + }; + + registerPopoverDiv = (popoverDiv) => { + if (!this.popoverDiv) { + this.popoverDiv = popoverDiv; + this.popoverDiv.addEventListener('focusout', this.onPopoverBlur); + } + }; + + componentWillUnmount() { + if (this.popoverDiv) { + this.popoverDiv.removeEventListener('focusout', this.onPopoverBlur); + } + } + + render() { + const { + actions, + rowId, + row, + actionEnabled, + onFocus, + } = this.props; + + const isOpen = this.state.popoverOpen; + + let allDisabled = true; + + const items = actions.reduce((items, action, index) => { + const key = `action_${rowId}_${index}`; + const isAvailable = action.isAvailable ? action.isAvailable(row) : true; + + if (!isAvailable) { + return items; + } + + const isEnabled = actionEnabled(action); + allDisabled = allDisabled && !isEnabled; + + if (action.render) { + const item = action.render(row, isEnabled); + items.push( + + {item} + + ); + } else { + items.push( + { action.onClick(row); }} + > + {action.name} + + ); + } + + return items; + }, []); + + const popoverButton = ( + + ); + + return ( + + + + ); + } +} diff --git a/src/components/basic_table/collapsed_row_actions.test.js b/src/components/basic_table/collapsed_row_actions.test.js new file mode 100644 index 00000000000..bbb08cfabe8 --- /dev/null +++ b/src/components/basic_table/collapsed_row_actions.test.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { CollapsedRowActions } from './collapsed_row_actions'; + +describe('CollapsedRowActions', () => { + + test('render', () => { + + const props = { + actions: [ + { + name: 'default1', + description: 'default 1', + onClick: () => { + } + }, + { + name: 'custom1', + description: 'custom 1', + render: () => { + } + } + ], + visible: true, + rowId: 'id', + row: { id: 'xyz' }, + model: { + data: { + rows: [], + totalRowCount: 0 + }, + criteria: { + page: { + size: 5, + index: 0 + } + } + }, + actionEnabled: () => true, + onFocus: () => {} + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/custom_row_action.js b/src/components/basic_table/custom_row_action.js new file mode 100644 index 00000000000..1d31499b38c --- /dev/null +++ b/src/components/basic_table/custom_row_action.js @@ -0,0 +1,56 @@ +import React, { + Component, + cloneElement, +} from 'react'; + +export class CustomRowAction extends Component { + constructor(props) { + super(props); + this.state = { hasFocus: false }; + + // while generally considered an anti-pattern, here we require + // to do that as the onFocus/onBlur events of the action controls + // may trigger while this component is unmounted. An alternative + // (at least the workarounds suggested by react is to unregister + // the onFocus/onBlur listeners from the action controls... this + // unfortunately will lead to unecessarily complex code... so we'll + // stick to this approach for now) + this.mounted = false; + } + + componentWillMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + onFocus = () => { + if (this.mounted) { + this.setState({ hasFocus: true }); + } + }; + + onBlur = () => { + if (this.mounted) { + this.setState({ hasFocus: false }); + } + }; + + hasFocus = () => { + return this.state.hasFocus; + }; + + render() { + const { action, enabled, visible, row } = this.props; + const tool = action.render(row, enabled); + const clonedTool = cloneElement(tool, { onFocus: this.onFocus, onBlur: this.onBlur }); + const style = this.hasFocus() || visible ? { opacity: 1 } : { opacity: 0 }; + return ( +
+ {clonedTool} +
+ ); + } +} diff --git a/src/components/basic_table/custom_row_action.test.js b/src/components/basic_table/custom_row_action.test.js new file mode 100644 index 00000000000..4977a4a9abd --- /dev/null +++ b/src/components/basic_table/custom_row_action.test.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { CustomRowAction } from './custom_row_action'; + +describe('CustomRowAction', () => { + + test('render', () => { + + const props = { + action: { + name: 'custom1', + description: 'custom 1', + render: () => 'test' + }, + enabled: true, + visible: true, + row: { id: 'xyz' }, + model: { + data: { + rows: [], + totalRowCount: 0 + }, + criteria: { + page: { + size: 5, + index: 0 + } + } + } + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/default_row_action.js b/src/components/basic_table/default_row_action.js new file mode 100644 index 00000000000..c05635ee615 --- /dev/null +++ b/src/components/basic_table/default_row_action.js @@ -0,0 +1,118 @@ +import React, { Component } from 'react'; +import { isString } from '../../services/predicate'; +import { EuiButton, EuiButtonIcon } from '../button'; + +const defaults = { + color: 'primary' +}; + +export class DefaultRowAction extends Component { + constructor(props) { + super(props); + this.state = { hasFocus: false }; + + // while generally considered an anti-pattern, here we require + // to do that as the onFocus/onBlur events of the action controls + // may trigger while this component is unmounted. An alternative + // (at least the workarounds suggested by react is to unregister + // the onFocus/onBlur listeners from the action controls... this + // unfortunately will lead to unecessarily complex code... so we'll + // stick to this approach for now) + this.mounted = false; + } + + componentWillMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + onFocus = () => { + if (this.mounted) { + this.setState({ hasFocus: true }); + } + }; + + onBlur = () => { + if (this.mounted) { + this.setState({ hasFocus: false }); + } + }; + + hasFocus = () => { + return this.state.hasFocus; + }; + + render() { + const { + action, + enabled, + visible, + row, + } = this.props; + + if (!action.onClick) { + throw new Error(`Cannot render row action [${action.name}]. Missing required 'onClick' callback. If you want + to provide a custom action control, make sure to define the 'render' callback`); + } + + const onClick = () => action.onClick(row); + const color = this.resolveActionColor(); + const icon = this.resolveActionIcon(); + const style = this.hasFocus() || visible ? { opacity: 1 } : { opacity: 0 }; + + if (action.type === 'icon') { + if (!icon) { + throw new Error(`Cannot render row action [${action.name}]. It is configured to render as an icon but no + icon is provided. Make sure to set the 'icon' property of the action`); + } + return ( + + ); + } + + return ( + + {action.name} + + ); + } + + resolveActionIcon() { + const { action, row } = this.props; + if (action.icon) { + return isString(action.icon) ? action.icon : action.icon(row); + } + } + + resolveActionColor() { + const { action, row } = this.props; + if (action.color) { + return isString(action.color) ? action.color : action.color(row); + } + return defaults.color; + } +} diff --git a/src/components/basic_table/default_row_action.test.js b/src/components/basic_table/default_row_action.test.js new file mode 100644 index 00000000000..9ffd3547e38 --- /dev/null +++ b/src/components/basic_table/default_row_action.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { DefaultRowAction } from './default_row_action'; +import { Random } from '../../services/random'; + +const random = new Random(); + +describe('DefaultRowAction', () => { + + test('render - button', () => { + + const props = { + action: { + name: 'action1', + description: 'action 1', + type: random.oneOf(undefined, 'button', 'foobar'), + onClick: () => {} + }, + enabled: true, + visible: true, + row: { id: 'xyz' }, + model: { + data: { + rows: [], + totalRowCount: 0 + }, + criteria: { + page: { + size: 5, + index: 0 + } + } + } + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + + test('render - icon', () => { + + const props = { + action: { + name: 'action1', + description: 'action 1', + type: 'icon', + icon: 'trash', + onClick: () => {} + }, + enabled: true, + visible: true, + row: { id: 'xyz' }, + model: { + data: { + rows: [], + totalRowCount: 0 + }, + criteria: { + page: { + size: 5, + index: 0 + } + } + } + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/expanded_row_actions.js b/src/components/basic_table/expanded_row_actions.js new file mode 100644 index 00000000000..cbab736920a --- /dev/null +++ b/src/components/basic_table/expanded_row_actions.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { DefaultRowAction } from './default_row_action'; +import { CustomRowAction } from './custom_row_action'; + +export const ExpandedRowActions = ({ + actions, + visible, + rowId, + row, + model, + actionEnabled, +}) => { + return actions.reduce((tools, action, index) => { + const isAvailable = action.isAvailable ? action.isAvailable(row, model) : true; + + if (!isAvailable) { + return tools; + } + + const enabled = actionEnabled(action); + const key = `row_action_${rowId}_${index}`; + if (action.render) { + // custom action has a render function + tools.push( + + ); + } else { + tools.push( + + ); + } + return tools; + }, []); +}; diff --git a/src/components/basic_table/expanded_row_actions.test.js b/src/components/basic_table/expanded_row_actions.test.js new file mode 100644 index 00000000000..4dbdbb8d49e --- /dev/null +++ b/src/components/basic_table/expanded_row_actions.test.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { ExpandedRowActions } from './expanded_row_actions'; + +describe('ExpandedRowActions', () => { + + test('render', () => { + + const props = { + actions: [ + { + name: 'default1', + description: 'default 1', + onClick: () => { + } + }, + { + name: 'custom1', + description: 'custom 1', + render: () => { + } + } + ], + visible: true, + rowId: 'id', + row: { id: 'xyz' }, + model: { + data: { + rows: [], + totalRowCount: 0 + }, + criteria: { + page: { + size: 5, + index: 0 + } + } + }, + actionEnabled: () => true + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/index.js b/src/components/basic_table/index.js new file mode 100644 index 00000000000..c90f19eb43a --- /dev/null +++ b/src/components/basic_table/index.js @@ -0,0 +1,3 @@ +export { + EuiBasicTable, +} from './basic_table'; diff --git a/src/components/basic_table/pagination_bar.js b/src/components/basic_table/pagination_bar.js new file mode 100644 index 00000000000..6ebf43401cd --- /dev/null +++ b/src/components/basic_table/pagination_bar.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { EuiSpacer } from '../spacer'; +import { EuiTablePagination } from '../table'; + +const defaults = { + pageSizeOptions: [5, 10, 20] +}; + +export const PaginationBar = ({ + pagination, + rows, + totalRowCount, + onPageSizeChange, + onPageChange, +}) => { + if (!pagination) { + throw new Error('Missing pagination'); + } + + // if (!onDataCriteriaChange) { + // throw new Error(` + // The table of rows is provided with a paginated model but [onDataCriteriaChange] is + // not configured. This callback must be implemented to handle pagination changes + // `); + // } + + const pageSizeOptions = pagination.pageSizeOptions ? + pagination.pageSizeOptions : + defaults.pageSizeOptions; + + const pageCount = Math.ceil((totalRowCount || rows.length) / pagination.size); + + return ( +
+ + +
+ ); +}; diff --git a/src/components/index.js b/src/components/index.js index 6eab2ddb288..51107ba0b51 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -25,6 +25,10 @@ export { EuiBadge, } from './badge'; +export { + EuiBasicTable, +} from './basic_table'; + export { EuiCallOut, } from './call_out'; diff --git a/src/services/sort/comparators.js b/src/services/sort/comparators.js index 8671b13c617..fa28024beda 100644 --- a/src/services/sort/comparators.js +++ b/src/services/sort/comparators.js @@ -1,32 +1,35 @@ import { SortDirection } from './sort_direction'; -export const Comparators = Object.freeze({ - - default: (direction = SortDirection.ASC) => { - return (v1, v2) => { - if (v1 === v2) { - return 0; - } - const result = v1 > v2 ? 1 : -1; - return SortDirection.isAsc(direction) ? result : -1 * result; - }; - }, +const defaultComparator = (direction = SortDirection.ASC) => { + return (v1, v2) => { + if (v1 === v2) { + return 0; + } + const result = v1 > v2 ? 1 : -1; + return SortDirection.isAsc(direction) ? result : -1 * result; + }; +}; - reverse: (comparator) => { - return (v1, v2) => comparator(v2, v1); - }, +const reverse = (comparator) => { + return (v1, v2) => comparator(v2, v1); +}; - value(valueCallback, comparator = undefined) { - if (!comparator) { - comparator = this.default(SortDirection.ASC); - } - return (o1, o2) => { - return comparator(valueCallback(o1), valueCallback(o2)); - }; - }, +const value = (valueCallback, comparator = undefined) => { + if (!comparator) { + comparator = defaultComparator(SortDirection.ASC); + } + return (o1, o2) => { + return comparator(valueCallback(o1), valueCallback(o2)); + }; +}; - property(prop, comparator = undefined) { - return this.value(value => value[prop], comparator); - }, +const property = (prop, comparator = undefined) => { + return value(value => value[prop], comparator); +}; -}); +export const Comparators = { + 'default': defaultComparator, + reverse, + value, + property, +}; diff --git a/src/services/sort/sort_direction.js b/src/services/sort/sort_direction.js index f4e3ed2a785..a8a3d83768c 100644 --- a/src/services/sort/sort_direction.js +++ b/src/services/sort/sort_direction.js @@ -1,14 +1,15 @@ import PropTypes from 'prop-types'; -export const SortDirection = Object.freeze({ - ASC: 'asc', - DESC: 'desc', - isAsc(direction) { - return direction === this.ASC; - }, - reverse(direction) { - return this.isAsc(direction) ? this.DESC : this.ASC; - } -}); +const ASC = 'asc'; +const DESC = 'desc'; +const isAsc = direction => direction === ASC; +const reverse = direction => isAsc(direction) ? DESC : ASC; + +export const SortDirection = { + ASC, + DESC, + isAsc, + reverse, +}; export const SortDirectionType = PropTypes.oneOf([ SortDirection.ASC, SortDirection.DESC ]);