diff --git a/src-docs/src/services/routes/routes.js b/src-docs/src/services/routes/routes.js index 24cde0b8c0a6..5ee04b7e423d 100644 --- a/src-docs/src/services/routes/routes.js +++ b/src-docs/src/services/routes/routes.js @@ -122,6 +122,9 @@ import { StepsExample } import { TableExample } from '../../views/table/table_example'; +import { TablePlusExample } + from '../../views/table_plus/table_plus_example'; + import { TabsExample } from '../../views/tabs/tabs_example'; @@ -218,6 +221,7 @@ const components = [ SpacerExample, StepsExample, TableExample, + TablePlusExample, TabsExample, TextExample, TitleExample, diff --git a/src-docs/src/views/table_plus/table_plus.js b/src-docs/src/views/table_plus/table_plus.js new file mode 100644 index 000000000000..c24924df8061 --- /dev/null +++ b/src-docs/src/views/table_plus/table_plus.js @@ -0,0 +1,399 @@ +import React, { + Component, +} from 'react'; + +import { + EuiBadge, + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiHealth, + EuiIcon, + EuiLink, + EuiPopover, + EuiTablePlus, +} from '../../../../src/components'; + +import { + LEFT_ALIGNMENT, + RIGHT_ALIGNMENT, +} from '../../../../src/services'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + itemIdToOpenActionsPopoverMap: {}, + }; + + this.items = [{ + id: 0, + title: 'A very long line which will wrap on narrower screens and NOT become truncated and replaced by an ellipsis', + type: 'user', + dateCreated: 'Tue Dec 28 2016', + magnitude: 1, + health: 'healthy', + }, { + id: 1, + title: { + value: 'A very long line which will not wrap on narrower screens and instead will become truncated and replaced by an ellipsis', + truncateText: true, + }, + type: 'user', + dateCreated: 'Tue Dec 01 2016', + magnitude: 1, + health: 'healthy', + }, { + id: 2, + title: { + value: 'Boomerang', + isLink: true, + }, + type: 'user', + dateCreated: Tue Dec 01 2016   New!, + magnitude: 10, + health: 'warning', + }, { + id: 3, + title: { + value: 'Celebration', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 16 2016', + magnitude: 100, + health: 'healthy', + }, { + id: 4, + title: { + value: 'Dog', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 13 2016', + magnitude: 1000, + health: 'warning', + }, { + id: 5, + title: { + value: 'Dragon', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'healthy', + }, { + id: 6, + title: { + value: 'Bear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'danger', + }, { + id: 7, + title: { + value: 'Dinosaur', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'warning', + }, { + id: 8, + title: { + value: 'Spider', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'warning', + }, { + id: 9, + title: { + value: 'Bugbear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'healthy', + }, { + id: 10, + title: { + value: 'Bear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'danger', + }, { + id: 11, + title: { + value: 'Dinosaur', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'warning', + }, { + id: 12, + title: { + value: 'Spider', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'healthy', + }, { + id: 13, + title: { + value: 'Bugbear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: 'danger', + }]; + + this.columns = [{ + id: 'type', + align: LEFT_ALIGNMENT, + width: '24px', + }, { + id: 'title', + content: 'Title', + align: LEFT_ALIGNMENT, + isSortable: true, + }, { + id: 'health', + content: 'Health', + align: LEFT_ALIGNMENT, + }, { + id: 'dateCreated', + content: 'Date created', + align: LEFT_ALIGNMENT, + isSortable: true, + }, { + id: 'magnitude', + content: 'Orders of magnitude', + align: RIGHT_ALIGNMENT, + isSortable: true, + }, { + id: 'actions', + content: '', + align: RIGHT_ALIGNMENT, + width: '32px', + }]; + + this.columnIdToCellProviderMap = { + type: (EuiTablePlusCell, cell, column) => ( + + + + ), + title: (EuiTablePlusCell, cell, column) => { + if (typeof cell === 'string') { + return ( + + {cell} + + ); + } + + const { + isLink, + value, + truncateText, + } = cell; + + let child; + if (isLink) { + child = ( + + {value} + + ); + } else { + child = value; + } + + return ( + + {child} + + ); + }, + health: (EuiTablePlusCell, cell, column) => { + const healthToColorMap = { + healthy: 'success', + danger: 'danger', + warning: 'warning', + }; + + return ( + + {cell} + + ); + }, + actions: (EuiTablePlusCell, cell, column, row) => ( + + this.togglePopover(row.id)} + /> + )} + isOpen={this.isPopoverOpen(row.id)} + closePopover={() => this.closePopover(row.id)} + panelPaddingSize="none" + anchorPosition="leftCenter" + > + { this.closePopover(row.id); }} + > + Edit + + ), ( + { this.closePopover(row.id); }} + > + Share + + ), ( + { this.closePopover(row.id); }} + > + Delete + + ), + ]} + /> + + + ), + }; + } + + togglePopover = itemId => { + this.setState(previousState => { + const newItemIdToOpenActionsPopoverMap = { + ...previousState.itemIdToOpenActionsPopoverMap, + [itemId]: !previousState.itemIdToOpenActionsPopoverMap[itemId], + }; + + return { + itemIdToOpenActionsPopoverMap: newItemIdToOpenActionsPopoverMap, + }; + }); + }; + + closePopover = itemId => { + this.setState(previousState => { + const newItemIdToOpenActionsPopoverMap = { + ...previousState.itemIdToOpenActionsPopoverMap, + [itemId]: false, + }; + + return { + itemIdToOpenActionsPopoverMap: newItemIdToOpenActionsPopoverMap, + }; + }); + }; + + isPopoverOpen = itemId => { + return this.state.itemIdToOpenActionsPopoverMap[itemId]; + }; + + doesRowMatchSearch = (row, searchTerm) => { + const title = typeof row.title === 'string' ? row.title : row.title.value; + const normalizedTerm = searchTerm.toLowerCase().trim(); + const normalizedTitle = title.toLowerCase().trim(); + return normalizedTitle.indexOf(normalizedTerm) !== -1; + }; + + renderCell = (EuiTablePlusCell, cell, column, row) => { + if (this.columnIdToCellProviderMap[column.id]) { + return this.columnIdToCellProviderMap[column.id](EuiTablePlusCell, cell, column, row); + } + + return ( + + {cell} + + ); + } + + render() { + return ( + column.title.toLowerCase(), + isAscending: true, + }, { + name: 'dateCreated', + getValue: column => column.dateCreated.toLowerCase(), + isAscending: true, + }, { + name: 'magnitude', + getValue: column => column.magnitude.toLowerCase(), + isAscending: true, + }]} + /> + ); + } +} diff --git a/src-docs/src/views/table_plus/table_plus_example.js b/src-docs/src/views/table_plus/table_plus_example.js new file mode 100644 index 000000000000..bf3ba6a0021d --- /dev/null +++ b/src-docs/src/views/table_plus/table_plus_example.js @@ -0,0 +1,35 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, +} from '../../../../src/components'; + +import TablePlus from './table_plus'; +const tablePlusSource = require('!!raw-loader!./table_plus'); +const tablePlusHtml = renderToHtml(TablePlus); + +export const TablePlusExample = { + title: 'TablePlus', + sections: [{ + title: 'TablePlus', + source: [{ + type: GuideSectionTypes.JS, + code: tablePlusSource, + }, { + type: GuideSectionTypes.HTML, + code: tablePlusHtml, + }], + text: ( +

+ Description needed: how to use the EuiTablePlus component. +

+ ), + demo: , + }], +}; diff --git a/src/components/index.js b/src/components/index.js index 5cde1aee0411..3fa4f021cec6 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -215,6 +215,10 @@ export { TooltipTrigger } from './tooltip'; +export { + EuiTablePlus, +} from './table_plus'; + export { EuiTitle, } from './title'; diff --git a/src/components/table_plus/__snapshots__/table_plus.test.js.snap b/src/components/table_plus/__snapshots__/table_plus.test.js.snap new file mode 100644 index 000000000000..5966d4159c67 --- /dev/null +++ b/src/components/table_plus/__snapshots__/table_plus.test.js.snap @@ -0,0 +1,152 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiTablePlus is rendered 1`] = ` +
+
+
+ + + + + + + + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+`; diff --git a/src/components/table_plus/index.js b/src/components/table_plus/index.js new file mode 100644 index 000000000000..58cc6cbe41c1 --- /dev/null +++ b/src/components/table_plus/index.js @@ -0,0 +1,3 @@ +export { + EuiTablePlus, +} from './table_plus'; diff --git a/src/components/table_plus/table_plus.js b/src/components/table_plus/table_plus.js new file mode 100644 index 000000000000..86e202737001 --- /dev/null +++ b/src/components/table_plus/table_plus.js @@ -0,0 +1,331 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiButton, +} from '../button'; + +import { + EuiCheckbox, + EuiFieldSearch, +} from '../form'; + +import { + EuiFlexGroup, + EuiFlexItem, +} from '../flex'; + +import { + EuiSpacer, +} from '../spacer'; + +import { + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableHeaderCellCheckbox, + EuiTablePagination, + EuiTableRow, + EuiTableRowCell, + EuiTableRowCellCheckbox, +} from '../table'; + +import { + Pager, + SortableProperties, +} from '../../services'; + +export class EuiTablePlus extends Component { + static propTypes = { + id: PropTypes.string.isRequired, + className: PropTypes.string, + searchFilterer: PropTypes.func, + initialSortedColumn: PropTypes.string, + columns: PropTypes.array.isRequired, + rows: PropTypes.array.isRequired, + rowCellRenderer: PropTypes.func.isRequired, + sortablePropertiesConfig: PropTypes.array, + } + + constructor(props) { + super(props); + + const { + initialSortedColumn, + sortablePropertiesConfig, + rows, + } = props; + + this.state = { + rowIdToSelectedMap: {}, + sortedColumn: initialSortedColumn || props.columns[0].id, + rowsPerPage: 20, + filteredRows: rows + }; + + this.sortableProperties = sortablePropertiesConfig + ? new SortableProperties(sortablePropertiesConfig, this.state.sortedColumn) + : undefined; + + this.pager = new Pager(rows.length, this.state.rowsPerPage); + this.state.firstRowIndex = this.pager.getFirstItemIndex(); + this.state.lastRowIndex = this.pager.getLastItemIndex(); + } + + onChangeRowsPerPage = rowsPerPage => { + this.pager.setItemsPerPage(rowsPerPage); + this.setState({ + rowsPerPage, + firstRowIndex: this.pager.getFirstItemIndex(), + lastRowIndex: this.pager.getLastItemIndex(), + }); + } + + onChangePage = pageIndex => { + this.pager.goToPageIndex(pageIndex); + this.setState({ + firstRowIndex: this.pager.getFirstItemIndex(), + lastRowIndex: this.pager.getLastItemIndex(), + }); + }; + + onSort = prop => { + this.sortableProperties.sortOn(prop); + + this.setState({ + sortedColumn: prop, + }); + } + + getVisibleRowIds = () => { + // If there are no rows. + if (this.state.firstRowIndex === -1) { + return []; + } + + const { rows } = this.props; + const rowIds = []; + + for (let rowIndex = this.state.firstRowIndex; rowIndex <= this.state.lastRowIndex; rowIndex++) { + const row = rows[rowIndex]; + rowIds.push(row.id); + } + + return rowIds; + } + + toggleRow = rowId => { + this.setState(previousState => { + const newRowIdToSelectedMap = { + ...previousState.rowIdToSelectedMap, + [rowId]: !previousState.rowIdToSelectedMap[rowId], + }; + + return { + rowIdToSelectedMap: newRowIdToSelectedMap, + }; + }); + } + + toggleAll = () => { + const rowIds = this.getVisibleRowIds(); + const allSelected = this.areAllRowsSelected(); + const newRowIdToSelectedMap = {}; + rowIds.forEach(rowId => newRowIdToSelectedMap[rowId] = !allSelected); + + this.setState({ + rowIdToSelectedMap: newRowIdToSelectedMap, + }); + } + + isRowSelected = rowId => { + return this.state.rowIdToSelectedMap[rowId]; + } + + areAllRowsSelected = () => { + const rowIds = this.getVisibleRowIds(); + const indexOfUnselectedRow = rowIds.findIndex(rowId => !this.isRowSelected(rowId)); + return indexOfUnselectedRow === -1; + } + + areAnyRowsSelected = () => { + return Object.keys(this.state.rowIdToSelectedMap).findIndex(id => { + return this.state.rowIdToSelectedMap[id]; + }) !== -1; + } + + onSearch = e => { + const filteredRows = this.props.rows.filter(row => this.props.searchFilterer(row, e.target.value)); + this.pager.setTotalItems(filteredRows.length); + this.setState({ + filteredRows, + firstRowIndex: this.pager.getFirstItemIndex(), + lastRowIndex: this.pager.getLastItemIndex(), + }); + } + + renderHeaderCells(columns) { + const customColumns = columns.map((column, columnIndex) => { + const { + id, + width, + isSortable, + content, + align, + ...rest + } = column; + + return ( + + {typeof content === 'function' ? content(column, columnIndex) : content} + + ); + }); + + return [( + + + + )].concat(customColumns); + } + + renderRows(rows, columns, rowCellRenderer) { + const renderRow = row => { + const customCells = columns.map(column => { + const cell = row[column.id]; + return rowCellRenderer(EuiTableRowCell, cell, column, row); + }); + + const cells = [( + + + + )].concat(customCells); + + return ( + + {cells} + + ); + }; + + const renderedRows = []; + + // If we have rows. + if (this.state.firstRowIndex !== -1) { + for (let rowIndex = this.state.firstRowIndex; rowIndex <= this.state.lastRowIndex; rowIndex++) { + const item = rows[rowIndex]; + renderedRows.push(renderRow(item)); + } + } + + return renderedRows; + } + + render() { + const { + id, // eslint-disable-line no-unused-vars + className, + searchFilterer, + columns, + rows, // eslint-disable-line no-unused-vars + rowCellRenderer, + initialSortedColumn, // eslint-disable-line no-unused-vars + sortablePropertiesConfig, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + let bulkActions; + + if (this.areAnyRowsSelected() > 0) { + bulkActions = ( + + Delete selected + + ); + } + + let search; + + if (searchFilterer) { + search = ( + + + + ); + } + + return ( +
+ + {bulkActions} + {search} + + + + + + + {this.renderHeaderCells(columns)} + + + + {this.renderRows(this.state.filteredRows, columns, rowCellRenderer)} + + + + + + +
+ ); + } +} diff --git a/src/components/table_plus/table_plus.test.js b/src/components/table_plus/table_plus.test.js new file mode 100644 index 000000000000..27d045d3b9af --- /dev/null +++ b/src/components/table_plus/table_plus.test.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test'; + +import { EuiTablePlus } from './table_plus'; + +describe('EuiTablePlus', () => { + test('is rendered', () => { + const component = render( + {}} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/src/services/paging/pager.js b/src/services/paging/pager.js index 5342ff5ee3eb..28a312321dfd 100644 --- a/src/services/paging/pager.js +++ b/src/services/paging/pager.js @@ -58,8 +58,8 @@ export class Pager { if (this.totalItems <= 0) { this.totalPages = 0; this.currentPageIndex = 0; - this.firstItemIndex = 0; - this.lastItemIndex = 0; + this.firstItemIndex = -1; + this.lastItemIndex = -1; return; } diff --git a/src/services/paging/pager.test.js b/src/services/paging/pager.test.js index 9613fed141de..8f8eff04a1d9 100644 --- a/src/services/paging/pager.test.js +++ b/src/services/paging/pager.test.js @@ -140,14 +140,14 @@ describe('Pager', () => { describe('behavior', () => { describe('when there are no items', () => { - test('getFirstItemIndex defaults to 0', () => { + test('getFirstItemIndex defaults to -1', () => { const pager = new Pager(0, 20); - expect(pager.getFirstItemIndex()).toBe(0); + expect(pager.getFirstItemIndex()).toBe(-1); }); - test('getLastItemIndex defaults to 0', () => { + test('getLastItemIndex defaults to -1', () => { const pager = new Pager(0, 20); - expect(pager.getLastItemIndex()).toBe(0); + expect(pager.getLastItemIndex()).toBe(-1); }); }); });