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 ]);