diff --git a/package.json b/package.json
index a332ef5fec2..e7daa86d038 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"jquery": "^3.2.1",
"keymirror": "^0.1.1",
"lodash": "^4.17.4",
+ "numeral": "^2.0.6",
"prop-types": "^15.6.0",
"react-ace": "^5.5.0",
"serve": "^6.3.1",
@@ -52,7 +53,6 @@
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"chokidar": "^1.7.0",
- "eslint-plugin-jest": "^21.6.2",
"circular-dependency-plugin": "^4.3.0",
"css-loader": "^0.28.7",
"enzyme": "^3.1.0",
@@ -62,6 +62,7 @@
"eslint-import-resolver-webpack": "^0.8.3",
"eslint-plugin-babel": "^4.1.2",
"eslint-plugin-import": "^2.8.0",
+ "eslint-plugin-jest": "^21.6.2",
"eslint-plugin-jsx-a11y": "^6.0.2",
"eslint-plugin-mocha": "^4.11.0",
"eslint-plugin-prefer-object-spread": "^1.2.1",
@@ -72,6 +73,7 @@
"jest": "^22.0.6",
"jest-cli": "^22.0.6",
"lodash": "^4.17.4",
+ "moment": "2.13.0",
"node-sass": "^4.5.3",
"npm-run": "^4.1.2",
"postcss-cli": "^4.1.1",
@@ -97,6 +99,7 @@
"yo": "^2.0.0"
},
"peerDependencies": {
+ "moment": "^2.13.0",
"react": "^16.2.0 || ^16.2"
}
}
diff --git a/src-docs/src/components/guide_section/guide_section.js b/src-docs/src/components/guide_section/guide_section.js
index cdd1636e7c3..2e79cae3771 100644
--- a/src-docs/src/components/guide_section/guide_section.js
+++ b/src-docs/src/components/guide_section/guide_section.js
@@ -22,6 +22,7 @@ import {
EuiText,
EuiTextColor,
EuiTitle,
+ EuiLink
} from '../../../../src/components';
@@ -139,7 +140,7 @@ export class GuideSection extends Component {
}
const docgenInfo = Array.isArray(component.__docgenInfo) ? component.__docgenInfo[0] : component.__docgenInfo;
- const { description, props } = docgenInfo;
+ const { _euiObjectType, description, props } = docgenInfo;
if (!props && !description) {
return;
@@ -169,6 +170,37 @@ export class GuideSection extends Component {
const humanizedType = humanizeType(type);
+ function markup(text) {
+ const regex = /(#[a-zA-Z]+)|(`[^`]+`)/g;
+ return text.split(regex).map(token => {
+ if (!token) {
+ return '';
+ }
+ if (token.startsWith('#')) {
+ const id = token.substring(1);
+ const onClick = () => {
+ document.getElementById(id).scrollIntoView();
+ };
+ return {id};
+ }
+ if (token.startsWith('`')) {
+ const code = token.substring(1, token.length - 1);
+ return {code};
+ }
+ return token;
+
+ });
+ }
+
+ const typeMarkup = markup(humanizedType);
+ const descriptionMarkup = markup(propDescription);
+ let defaultValueMarkup = '';
+ if (defaultValue) {
+ defaultValueMarkup = [ {defaultValue.value} ];
+ if (defaultValue.comment) {
+ defaultValueMarkup.push(`(${defaultValue.comment})`);
+ }
+ }
const cells = [
(
@@ -176,15 +208,15 @@ export class GuideSection extends Component {
), (
- {humanizedType}
+ {typeMarkup}
), (
- {defaultValue ? {defaultValue.value} : ''}
+ {defaultValueMarkup}
), (
- {propDescription}
+ {descriptionMarkup}
)
];
@@ -196,6 +228,10 @@ export class GuideSection extends Component {
);
});
+ const title = _euiObjectType === 'type' ?
+ {componentName} :
+ {componentName};
+
let descriptionElement;
if (description) {
@@ -241,7 +277,7 @@ export class GuideSection extends Component {
return [
,
- Props for {componentName}
,
+ {title}
,
,
descriptionElement,
table,
diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js
index b6328069af4..3ad5641e4c8 100644
--- a/src-docs/src/routes.js
+++ b/src-docs/src/routes.js
@@ -136,6 +136,9 @@ import { StepsExample }
import { TableExample }
from './views/table/table_example';
+import { TableOfRecordsExample }
+ from './views/table_of_records/table_of_records_example';
+
import { TabsExample }
from './views/tabs/tabs_example';
@@ -238,6 +241,7 @@ const components = [
SpacerExample,
StepsExample,
TableExample,
+ TableOfRecordsExample,
TabsExample,
TextExample,
TitleExample,
diff --git a/src-docs/src/views/table_of_records/column_data_types.js b/src-docs/src/views/table_of_records/column_data_types.js
new file mode 100644
index 00000000000..f42e79bde14
--- /dev/null
+++ b/src-docs/src/views/table_of_records/column_data_types.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import { times } from 'lodash';
+
+import {
+ Random
+} from '../../../../src/services';
+
+import {
+ EuiTableOfRecords,
+} from '../../../../src/components';
+
+const random = new Random();
+
+const records = times(5, (index) => {
+ return {
+ id: index,
+ string: random.oneOf('Martijn', 'Elissa', 'Clinton', 'Igor', 'Karl', 'Drew', 'Honza', 'Rashid', 'Jordan'),
+ 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) })
+ };
+});
+
+const model = {
+ data: { records }
+};
+
+const config = {
+ recordId: 'id',
+ 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'
+ },
+ ],
+};
+
+export default () => {
+ return ;
+};
diff --git a/src-docs/src/views/table_of_records/full_featured.js b/src-docs/src/views/table_of_records/full_featured.js
new file mode 100644
index 00000000000..9fc7ef9732d
--- /dev/null
+++ b/src-docs/src/views/table_of_records/full_featured.js
@@ -0,0 +1,299 @@
+import React, {
+ Component,
+} from 'react';
+import uuid from 'uuid/v1';
+import { times } from 'lodash';
+
+import {
+ EuiTableOfRecords,
+ EuiHealth,
+ EuiSwitch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLink,
+ EuiSpacer,
+} from '../../../../src/components';
+
+import {
+ formatDate,
+ Random,
+ Comparators
+} from '../../../../src/services';
+
+const random = new Random();
+
+const people = times(20, (index) => {
+ return {
+ 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()
+ };
+});
+
+function loadPage(pageIndex, pageSize, sort) {
+ let list = people;
+ if (sort) {
+ list = people.sort(Comparators.property(sort.field, Comparators.default(sort.direction)));
+ }
+ if (!pageIndex && !pageSize) {
+ return {
+ index: 0,
+ size: list.length,
+ items: list,
+ totalRecordCount: list.length
+ };
+ }
+ const from = pageIndex * pageSize;
+ const items = list.slice(from, Math.min(from + pageSize, list.length));
+ return {
+ index: pageIndex,
+ size: pageSize,
+ items,
+ totalRecordCount: list.length
+ };
+}
+
+export default class PeopleTable extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ features: {
+ pagination: true,
+ sorting: true,
+ selection: true,
+ multipleRecordActions: true
+ },
+ ...this.computeTableState({
+ page: {
+ index: 0,
+ size: 5,
+ },
+ })
+ };
+ }
+
+ computeTableState(criteria) {
+ const page = criteria.page ?
+ loadPage(criteria.page.index, criteria.page.size, criteria.sort) :
+ loadPage(undefined, undefined, criteria.sort);
+ return {
+ data: {
+ records: page.items,
+ totalRecordCount: page.totalRecordCount
+ },
+ criteria: {
+ page: {
+ index: page.index,
+ size: page.size
+ },
+ sort: criteria.sort
+ }
+ };
+ }
+
+ onDataCriteriaChange(criteria) {
+ this.setState(this.computeTableState(criteria));
+ }
+
+ deletePerson(personToDelete) {
+ const i = people.findIndex((person) => person.id === personToDelete.id);
+ if (i >= 0) {
+ people.splice(i, 1);
+ }
+ this.onDataCriteriaChange(this.state.criteria);
+ }
+
+ clonePerson(personToClone) {
+ const i = people.findIndex((person) => person.id === personToClone.id);
+ const clone = { ...personToClone, id: uuid() };
+ people.splice(i, 0, clone);
+ this.onDataCriteriaChange(this.state.criteria);
+ }
+
+ changePersonOnlineStatus(personToUpdate, online) {
+ const person = people.find((person) => person.id === personToUpdate.id);
+ if (person) {
+ person.online = online;
+ }
+ this.onDataCriteriaChange(this.state.criteria);
+ }
+
+ toggleFeature(feature) {
+ this.setState(prevState => ({
+ features: {
+ ...prevState.features,
+ [feature]: !prevState.features[feature]
+ }
+ }));
+ }
+
+ render() {
+ const { features } = this.state;
+
+ const config = {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'firstName',
+ name: 'First Name',
+ description: `Person's given name`,
+ dataType: 'string',
+ sortable: features.sorting,
+ },
+ {
+ field: 'lastName',
+ name: 'Last Name',
+ description: `Person's family name`,
+ dataType: 'string'
+ },
+ {
+ field: 'nickname',
+ name: 'Nickname',
+ description: `Person's nickname / online handle`,
+ render: value => (
+
+ {value}
+
+ )
+ },
+ {
+ field: 'dateOfBirth',
+ name: 'Date of Birth',
+ description: `Person's date of birth`,
+ render: value => formatDate(value, 'D MMM YYYY'),
+ sortable: features.sorting,
+ dataType: 'date'
+ },
+ {
+ field: 'online',
+ name: 'Online',
+ description: `Is this person currently online?`,
+ render: (value) => {
+ const color = value ? 'success' : 'danger';
+ const content = value ? 'Online' : 'Offline';
+ return {content};
+ },
+ sortable: features.sorting
+ },
+ {
+ name: '',
+ actions: features.multipleRecordActions ? [
+ {
+ name: 'Clone',
+ description: 'Clone this person',
+ icon: 'copy',
+ onClick: (person) => this.clonePerson(person)
+ },
+ {
+ name: 'Delete',
+ description: 'Delete this person',
+ icon: 'trash',
+ color: 'danger',
+ onClick: (person) => this.deletePerson(person)
+ },
+ // uncomment once context menu officially supports checkbox elements
+ // see https://github.com/elastic/eui/issues/336
+ // {
+ // name: 'Online/Offline',
+ // description: 'toggles the online/offline state of the person',
+ // render: (person, model, enabled) => {
+ // const onChange = (event) => this.changePersonOnlineStatus(person, event.target.checked);
+ // return (
+ //
+ // );
+ // }
+ // }
+ ] : [
+ {
+ name: 'Delete',
+ description: 'Delete this person',
+ icon: 'trash',
+ type: 'icon',
+ color: 'danger',
+ onClick: (person) => this.deletePerson(person)
+ }
+ ]
+ }
+ ],
+
+ pagination: features.pagination ? {
+ pageSizeOptions: [3, 5, 8]
+ } : undefined,
+
+ selection: features.selection ? {
+ selectable: (record) => record.online,
+ selectableMessage: person => !person.online ? `${person.firstName} is offline` : undefined
+ } : undefined,
+
+ onDataCriteriaChange: (criteria) => this.onDataCriteriaChange(criteria)
+ };
+
+ const {
+ data,
+ criteria: {
+ page,
+ sort,
+ },
+ } = this.state;
+
+ const model = {
+ data,
+ criteria: {
+ page: features.pagination ? page : undefined,
+ sort: features.sorting ? sort : undefined,
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src-docs/src/views/table_of_records/implicit_record_action.js b/src-docs/src/views/table_of_records/implicit_record_action.js
new file mode 100644
index 00000000000..6ef27a99035
--- /dev/null
+++ b/src-docs/src/views/table_of_records/implicit_record_action.js
@@ -0,0 +1,166 @@
+import React from 'react';
+import { times } from 'lodash';
+
+import {
+ EuiTableOfRecords,
+ EuiSwitch,
+ EuiIcon,
+ EuiLink,
+} from '../../../../src/components';
+
+import {
+ formatDate,
+ Random,
+ Comparators
+} from '../../../../src/services';
+
+const random = new Random();
+
+const people = times(20, (index) => {
+ return {
+ 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()
+ };
+});
+
+function loadPage(pageIndex, pageSize, sort) {
+ let list = people;
+ if (sort) {
+ list = people.sort(Comparators.property(sort.field, Comparators.default(sort.direction)));
+ }
+ const from = pageIndex * pageSize;
+ const items = list.slice(from, Math.min(from + pageSize, list.length));
+ return {
+ index: pageIndex,
+ size: pageSize,
+ items,
+ totalRecordCount: list.length
+ };
+}
+
+export default class PeopleTable extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = this.computeState({
+ page: {
+ index: 0,
+ size: 5
+ }
+ });
+ }
+
+ computeState(criteria, selection = []) {
+ const page = loadPage(criteria.page.index, criteria.page.size, criteria.sort);
+ return {
+ data: {
+ records: page.items,
+ totalRecordCount: page.totalRecordCount
+ },
+ criteria: {
+ page: {
+ index: page.index,
+ size: page.size
+ },
+ sort: criteria.sort
+ },
+ selection
+ };
+ }
+
+ onDataCriteriaChange(criteria) {
+ this.setState((prevState) => this.computeState(criteria, prevState.selection));
+ }
+
+ onPersonOnlineStatusChange(personId, online) {
+ const person = people.find(person => person.id === personId);
+ if (person) {
+ person.online = online;
+ }
+ this.onDataCriteriaChange(this.state.criteria);
+ }
+
+ onSelectionChanged(selection) {
+ this.setState({ selection });
+ }
+
+ render() {
+
+ const config = {
+ recordId: 'id',
+ columns: [
+ {
+ width: '30px',
+ align: 'right',
+ render: (person) => {
+ const color = person.online ? 'success' : 'subdued';
+ const title = person.online ? 'Online' : 'Offline';
+ return ;
+ }
+ },
+ {
+ field: 'firstName',
+ name: 'First Name',
+ description: `Person's given name`,
+ dataType: 'string',
+ sortable: true
+ },
+ {
+ field: 'lastName',
+ name: 'Last Name',
+ description: `Person's family name`,
+ dataType: 'string'
+ },
+ {
+ field: 'nickname',
+ name: 'Nickname',
+ description: `Person's nickname / online handle`,
+ render: value => (
+
+ {value}
+
+ )
+ },
+ {
+ field: 'dateOfBirth',
+ name: 'Date of Birth',
+ description: `Person's date of birth`,
+ render: value => formatDate(value, 'D MMM YYYY'),
+ sortable: true
+ },
+ {
+ field: 'online',
+ name: 'Online',
+ description: `Is this person is currently online?`,
+ render: (online, person) => {
+ const disabled = this.state.selection.length !== 0;
+ const onChange = (event) => {
+ this.onPersonOnlineStatusChange(person.id, event.target.checked);
+ };
+ return ;
+ },
+ sortable: true
+ }
+ ],
+ pagination: {
+ pageSizeOptions: [3, 5, 8]
+ },
+
+ selection: {
+ selectable: (record) => record.online,
+ selectableMessage: person => !person.online ? `${person.firstName} is offline` : undefined,
+ onSelectionChanged: (selection) => this.onSelectionChanged(selection)
+ },
+
+ onDataCriteriaChange: (criteria) => this.onDataCriteriaChange(criteria, this.state.selection)
+
+ };
+
+ return ;
+ }
+}
diff --git a/src-docs/src/views/table_of_records/props_info.js b/src-docs/src/views/table_of_records/props_info.js
new file mode 100644
index 00000000000..86bae89994d
--- /dev/null
+++ b/src-docs/src/views/table_of_records/props_info.js
@@ -0,0 +1,369 @@
+export const propsInfo = {
+
+ EuiTableOfRecords: {
+ __docgenInfo: {
+ props: {
+ config: {
+ description: 'Configures the features and behaviour of the table',
+ required: true,
+ type: { name: '#Config' }
+ },
+ model: {
+ description: 'Defines the data model of this table',
+ required: true,
+ type: { name: '#Model' }
+ }
+ }
+ }
+ },
+
+ Model: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ data: {
+ description: 'Holds the data for the table',
+ required: true,
+ type: { name: '#ModelData' }
+ },
+ criteria: {
+ description: 'Defines the criteria to which the data adheres',
+ required: false,
+ type: { name: '#ModelCriteria' }
+ }
+ }
+ }
+ },
+
+
+ ModelData: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ records: {
+ description: 'An array of the records to be displayed',
+ required: true,
+ type: { name: 'object[]' }
+ },
+ totalRecordCount: {
+ description: 'The total number of records that match the criteria',
+ required: false,
+ type: { name: 'number' }
+ }
+ }
+ }
+ },
+
+ ModelCriteria: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ page: {
+ description: 'If the data records represents a page into a bigger set, this describes this page',
+ required: false,
+ type: { name: '#ModelCriteriaPage' }
+ },
+ sort: {
+ description: 'If the data records are sorted, this describes the sort criteria',
+ required: false,
+ type: { name: '#ModelCriteriaSort' }
+ }
+ }
+ }
+ },
+
+ ModelCriteriaPage: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ size: {
+ description: 'The maximum number of records per page',
+ required: true,
+ type: { name: 'number' }
+ },
+ index: {
+ description: 'The page (zero-based) index',
+ required: false,
+ type: { name: 'number' }
+ }
+ }
+ }
+ },
+
+ ModelCriteriaSort: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ field: {
+ description: 'The field the data is sorted on',
+ required: true,
+ type: { name: 'string' }
+ },
+ direction: {
+ description: 'The direction of the sort',
+ required: false,
+ type: { name: '"asc" | "desc"' }
+ }
+ }
+ }
+ },
+
+ Config: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ columns: {
+ description: 'Defines the table columns',
+ required: true,
+ type: { name: '(#FieldDataColumn | #ComputedColumn | #ActionsColumn)[]' }
+ },
+ onDataCriteriaChange: {
+ description: 'A callback to handle changes in the data criteria',
+ required: false,
+ type: { name: '(criteria: #ModelCriteria) => void' }
+ },
+ selection: {
+ description: 'Configuring selection',
+ required: false,
+ type: { name: '#ConfigSelection' }
+ },
+ pagination: {
+ description: 'Configuring pagination',
+ required: false,
+ type: { name: '#ConfigPagination' }
+ }
+ }
+ }
+ },
+
+ ConfigSelection: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ onSelectionChanged: {
+ description: 'A callback that will be called whenever the record selection changes',
+ required: false,
+ type: { name: '(selectedRecords) => void' }
+ },
+ selectable: {
+ description: 'A callback that is called per record to indicate whether it is selectable',
+ required: false,
+ type: { name: '(record, model) => void' }
+ }
+ }
+ }
+ },
+
+ ConfigPagination: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ props: {
+ pageSizeOptions: {
+ description: 'Configures the page size dropdown options',
+ required: false,
+ defaultValue: { value: '[5, 10, 20]' },
+ type: { name: 'number[]' }
+ }
+ }
+ }
+ },
+
+ FieldDataColumn: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ description: `Describes a column that displays a value derived of one of the Record's fields`,
+ props: {
+ field: {
+ description: 'A field of the record (may be a nested field)',
+ required: true,
+ type: { name: 'string' }
+ },
+ name: {
+ description: 'The display name of the column',
+ required: true,
+ type: { name: 'string' }
+ },
+ description: {
+ description: 'A description of the column (will be presented as a title over the column header',
+ required: false,
+ type: { name: 'string' }
+ },
+ dataType: {
+ description: 'Describes the data types of the displayed value (serves as a rendering hint for the table)',
+ required: false,
+ defaultValue: { value: '"auto"' },
+ type: { name: '"auto" | string" | "number" | "date" | "boolean"' }
+ },
+ width: {
+ description: 'A CSS width property. Hints for the required width of the column',
+ required: false,
+ type: { name: 'string (e.g. "30%", "100px", etc..)' }
+ },
+ sortable: {
+ description: 'Defines whether the user can sort on this column',
+ required: false,
+ defaultValue: { value: 'false' },
+ type: { name: 'boolean' }
+ },
+ align: {
+ description: 'Defines the horizontal alignment of the column',
+ required: false,
+ defaultValue: { value: '"right"', comment: 'May change when "dataType" is defined' },
+ type: { name: '"left" | "right"' }
+ },
+ truncateText: {
+ description: `Indicates whether this column should truncate its content when it doesn't fit`,
+ required: false,
+ defaultValue: { value: 'false' },
+ type: { name: 'boolean' }
+ },
+ render: {
+ description: `Describe a custom renderer function for the content`,
+ required: false,
+ type: { name: '(value, record) => PropTypes.node' }
+ }
+ }
+ }
+ },
+
+ ComputedColumn: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ description: `Describes a column for computed values`,
+ props: {
+ render: {
+ description: `A function that computes the value for each record and renders it`,
+ required: true,
+ type: { name: '(record, model) => PropTypes.node' }
+ },
+ name: {
+ description: 'The display name of the column',
+ required: false,
+ type: { name: 'string' }
+ },
+ description: {
+ description: 'A description of the column (will be presented as a title over the column header',
+ required: false,
+ type: { name: 'string' }
+ },
+ width: {
+ description: 'A CSS width property. Hints for the required width of the column',
+ required: false,
+ type: { name: 'string (e.g. "30%", "100px", etc..)' }
+ },
+ truncateText: {
+ description: `Indicates whether this column should truncate its content when it doesn't fit`,
+ required: false,
+ defaultValue: { value: 'false' },
+ type: { name: 'boolean' }
+ }
+ }
+ }
+ },
+
+ ActionsColumn: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ description: `Describes a column that holds action controls (e.g. Buttons)`,
+ props: {
+ actions: {
+ description: `An array of actions to associate per record`,
+ required: true,
+ type: { name: '(#DefaultRecordAction | #CustomRecordAction)[]' }
+ },
+ name: {
+ description: 'The display name of the column',
+ required: false,
+ type: { name: 'string' }
+ },
+ description: {
+ description: 'A description of the column (will be presented as a title over the column header',
+ required: false,
+ type: { name: 'string' }
+ },
+ width: {
+ description: 'A CSS width property. Hints for the required width of the column',
+ required: false,
+ type: { name: 'string (e.g. "30%", "100px", etc..)' }
+ }
+ }
+ }
+ },
+
+ DefaultRecordAction: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ description: `Describes an action that is displayed as a button`,
+ props: {
+ name: {
+ description: 'The display name of the action (will be the button caption',
+ required: true,
+ type: { name: 'string' }
+ },
+ description: {
+ description: 'Describes the action (will be the button title)',
+ required: true,
+ type: { name: 'string' }
+ },
+ onClick: {
+ description: 'A handler function to execute the action',
+ required: true,
+ type: { name: '(record, model) => void' }
+ },
+ type: {
+ description: 'The type of action',
+ required: false,
+ defaultValue: { value: '"button"' },
+ type: { name: '"button" | "icon"' }
+ },
+ available: {
+ description: 'A callback function that determines whether the action is available',
+ required: false,
+ defaultValue: { value: '() => true' },
+ type: { name: '(record, model) => boolean' }
+ },
+ enabled: {
+ description: 'A callback function that determines whether the action is enabled',
+ required: false,
+ defaultValue: { value: '() => true' },
+ type: { name: '(record, model) => boolean' }
+ },
+ icon: {
+ description: 'Associates an icon with the button',
+ required: false,
+ type: { name: 'string (must be one of the supported icon types)' }
+ },
+ color: {
+ description: 'Defines the color of the button',
+ required: false,
+ type: { name: 'string (must be one of the supported button colors)' }
+ }
+ }
+ }
+ },
+
+ CustomRecordAction: {
+ __docgenInfo: {
+ _euiObjectType: 'type',
+ description: `Describes a custom action`,
+ props: {
+ render: {
+ description: 'The function that renders the action. Note that the returned node is ' +
+ 'expected to have`onFocus` and `onBlur` functions',
+ required: true,
+ type: { name: '(record, model, enabled) => PropTypes.node' }
+ },
+ available: {
+ description: 'A callback that defines whether the action is available',
+ required: false,
+ type: { name: '(record, model) => boolean' }
+ },
+ enabled: {
+ description: 'A callback that defines whether the action is enabled',
+ required: false,
+ type: { name: '(record, model) => boolean' }
+ }
+ }
+ }
+ },
+};
diff --git a/src-docs/src/views/table_of_records/table_of_records_example.js b/src-docs/src/views/table_of_records/table_of_records_example.js
new file mode 100644
index 00000000000..b97516ae607
--- /dev/null
+++ b/src-docs/src/views/table_of_records/table_of_records_example.js
@@ -0,0 +1,177 @@
+import React from 'react';
+
+import { renderToHtml } from '../../services';
+
+import {
+ GuideSectionTypes,
+} from '../../components';
+
+import {
+ EuiCode, EuiText, EuiTitle, EuiCallOut, EuiSpacer
+} from '../../../../src/components';
+
+import FullFeatured from './full_featured';
+const fullFeaturedSource = require('!!raw-loader!./full_featured');
+const fullFeaturedHtml = renderToHtml(FullFeatured);
+
+import ImplicitRecordActionsTable from './implicit_record_action';
+const implicitRecordActionSource = require('!!raw-loader!./implicit_record_action');
+const implicitRecordActionHtml = renderToHtml(ImplicitRecordActionsTable);
+
+import ColumnDataTypes from './column_data_types';
+import { propsInfo } from './props_info';
+const columnRenderersSource = require('!!raw-loader!./column_data_types');
+const columnRenderersHtml = renderToHtml(ColumnDataTypes);
+
+export const TableOfRecordsExample = {
+ title: 'TableOfRecords',
+ intro: (
+
+
+ TableOfRecords
+
+
+ EuiTableOfRecords is a high level component that aims to simplify and unifiy the way
+ one creates a table of... (wait for it....) records!!!
+
+
+ The goal of a high level components is to make the consumer not think about design or UX/UI behaviour.
+ Instead, the consumer only need to define the functional requirements - features of the component (in
+ this case, table), the data, and the type of interaction the user should have with it. Through high level
+ components, Eui can promote best/common UI practices and patterns.
+
+ High level components are as stateless as they can possibly be. Meaning, all the management of the data
+ (e.g. where is it coming from, how is it loaded, how is it filtered, etc...) is expected to be done
+ externally to this component. Typically one would use a container component to wrap around this component
+ that will either manage this state internally, or use other state stores (e.g. such as Redux).
+
+
+
+ Think of a record in a data store - typically it represents an entity with a very clear
+ schema and is something that can be presented in a tabular form.
+
+
+
+ The EuiTableOfRecords accepts two required properties:
+
+
+ -
+ config - This is the configuration of the table. It provides all information the
+ table needs to render its records.
+
+ -
+ model - This object provides the table two things - the data (the records that
+ should be shown), and the criteria of the data. The criteria effectively describes what data is
+ show and how it was collected.
+
+
+
+
+ ),
+ sections: [
+ {
+ title: 'Full Featured Example',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: fullFeaturedSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: fullFeaturedHtml,
+ }
+ ],
+ text: (
+
+
+ The following example shows ToR in its full glory:
+
+
+ -
+ Pagination - enabled when the provided model specifies the pagination criteria (i.e.
+ model.criteria.page.index and model.criteria.page.size)
+
+ -
+ Sorting - enabled when any of the columns is configured to be sortable: true
+
+ -
+ Selection - enabled when the selection is configured on the config (i.e.
+ config.selection.onSelectionChanged?, selectable? and
+ config.selection.selectableMessage?)
+
+ -
+ Custom Column Rendering - You can customize how the data is displayed in each column by
+ specifying a renderer for that column. You can also specify the data type for that column (this will serve
+ as a rendering hint for the table, such that it'll choose the most suitable rendering defaults for the
+ data type).
+
+ -
+ Record Level Actions - Actions that are associated with each records. A single action
+ will be rendered at the last column of each row and will appear when hovering over the row. Multiple
+ actions will "folded" into a menu popup.
+
+
+
+ ),
+ props: propsInfo,
+ demo:
+ },
+ {
+ title: 'Computed Columns and "Implicit" Record Actions',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: implicitRecordActionSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: implicitRecordActionHtml,
+ }
+ ],
+ text: (
+
+
+ Event though the table tries to dictate our common design patterns rules, at times these rules
+ need to be broken for good reaosns. For example, there can be a valid use case for having muliple
+ controls visible all the time (that is, not collapsed into a popover).
+
+
+ You can achieve this by using custom columnRenderers. The following example, enables switching the online/offline
+ status of a person. Also note how listening to selection state changes enables us to follow the design
+ guidelines and disable these switches when selection is on.
+
+
+ As a bonus, we show how you can define a computed column that is not associated with any
+ specific record key and simply renders content that is computed/derived out of the record itself (here we
+ added a small little icon column that shows the user icon that is colored based on the person's
+ online status).
+
+
+ ),
+ demo:
+ },
+ {
+ title: 'Column data types',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: columnRenderersSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: columnRenderersHtml,
+ }
+ ],
+ text: (
+
+
+ You can specify a dataType property in your column configuration
+ which will be used as the default format for rendering the data for each cell in
+ that column.
+
+
+ ),
+ demo:
+ }
+ ]
+};
diff --git a/src/components/context_menu/context_menu_panel.js b/src/components/context_menu/context_menu_panel.js
index 71d862212a3..08a20a8b8df 100644
--- a/src/components/context_menu/context_menu_panel.js
+++ b/src/components/context_menu/context_menu_panel.js
@@ -36,13 +36,13 @@ export class EuiContextMenuPanel extends Component {
items: PropTypes.array,
showNextPanel: PropTypes.func,
showPreviousPanel: PropTypes.func,
- initialFocusedItemIndex: PropTypes.number,
- }
+ initialFocusedItemIndex: PropTypes.number
+ };
static defaultProps = {
hasFocus: true,
items: [],
- }
+ };
constructor(props) {
super(props);
@@ -217,6 +217,23 @@ export class EuiContextMenuPanel extends Component {
}
}
+ shouldComponentUpdate(nextProps, nextState) {
+ // Prevent calling `this.updateFocus()` below if we don't have to.
+ if (nextProps.hasFocus !== this.props.hasFocus) {
+ return true;
+ }
+
+ if (nextState.isTransitioning !== this.state.isTransitioning) {
+ return true;
+ }
+
+ if (nextState.focusedItemIndex !== this.state.focusedItemIndex) {
+ return true;
+ }
+
+ return false;
+ }
+
componentDidUpdate() {
this.updateFocus();
}
diff --git a/src/components/form/switch/switch.js b/src/components/form/switch/switch.js
index 3b5046aaab2..b621cb234f0 100644
--- a/src/components/form/switch/switch.js
+++ b/src/components/form/switch/switch.js
@@ -62,4 +62,5 @@ EuiSwitch.propTypes = {
label: PropTypes.node,
checked: PropTypes.bool,
onChange: PropTypes.func,
+ disabled: PropTypes.bool
};
diff --git a/src/components/index.js b/src/components/index.js
index a52fbf455ff..6eab2ddb288 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -213,6 +213,10 @@ export {
EuiTableRowCellCheckbox,
} from './table';
+export {
+ EuiTableOfRecords
+} from './table_of_records';
+
export {
EuiTab,
EuiTabs,
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 2688bceff63..c434c312365 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -129,6 +129,7 @@ export class EuiPopover extends Component {
closePopover,
panelClassName,
panelPaddingSize,
+ popoverRef,
...rest
} = this.props;
@@ -181,6 +182,7 @@ export class EuiPopover extends Component {
{cloneElement(button, {
@@ -205,6 +207,7 @@ EuiPopover.propTypes = {
anchorPosition: PropTypes.oneOf(ANCHOR_POSITIONS),
panelClassName: PropTypes.string,
panelPaddingSize: PropTypes.oneOf(SIZES),
+ popoverRef: PropTypes.func
};
EuiPopover.defaultProps = {
diff --git a/src/components/table_of_records/__snapshots__/collapsed_record_actions.test.js.snap b/src/components/table_of_records/__snapshots__/collapsed_record_actions.test.js.snap
new file mode 100644
index 00000000000..191c044c053
--- /dev/null
+++ b/src/components/table_of_records/__snapshots__/collapsed_record_actions.test.js.snap
@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CollapsedRecordActions render 1`] = `
+
+ }
+ closePopover={[Function]}
+ id="id-actions"
+ isOpen={false}
+ ownFocus={false}
+ panelPaddingSize="none"
+ popoverRef={[Function]}
+>
+
+ default1
+ ,
+ ,
+ ]
+ }
+ />
+
+`;
diff --git a/src/components/table_of_records/__snapshots__/custom_record_action.test.js.snap b/src/components/table_of_records/__snapshots__/custom_record_action.test.js.snap
new file mode 100644
index 00000000000..e0a9b03ae1e
--- /dev/null
+++ b/src/components/table_of_records/__snapshots__/custom_record_action.test.js.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CustomRecordAction render 1`] = `
+
+`;
diff --git a/src/components/table_of_records/__snapshots__/default_record_action.test.js.snap b/src/components/table_of_records/__snapshots__/default_record_action.test.js.snap
new file mode 100644
index 00000000000..ee11eed574c
--- /dev/null
+++ b/src/components/table_of_records/__snapshots__/default_record_action.test.js.snap
@@ -0,0 +1,42 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DefaultRecordAction render - button 1`] = `
+
+ action1
+
+`;
+
+exports[`DefaultRecordAction render - icon 1`] = `
+
+`;
diff --git a/src/components/table_of_records/__snapshots__/expanded_record_actions.test.js.snap b/src/components/table_of_records/__snapshots__/expanded_record_actions.test.js.snap
new file mode 100644
index 00000000000..c21085714b0
--- /dev/null
+++ b/src/components/table_of_records/__snapshots__/expanded_record_actions.test.js.snap
@@ -0,0 +1,72 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExpandedRecordActions render 1`] = `
+Array [
+ ,
+ ,
+]
+`;
diff --git a/src/components/table_of_records/__snapshots__/pagination_bar.test.js.snap b/src/components/table_of_records/__snapshots__/pagination_bar.test.js.snap
new file mode 100644
index 00000000000..7a8e8d39904
--- /dev/null
+++ b/src/components/table_of_records/__snapshots__/pagination_bar.test.js.snap
@@ -0,0 +1,45 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PaginationBar render - custom page size options 1`] = `
+
+
+
+
+`;
+
+exports[`PaginationBar render 1`] = `
+
+
+
+
+`;
diff --git a/src/components/table_of_records/__snapshots__/table_of_records.test.js.snap b/src/components/table_of_records/__snapshots__/table_of_records.test.js.snap
new file mode 100644
index 00000000000..ee3e0ccbda4
--- /dev/null
+++ b/src/components/table_of_records/__snapshots__/table_of_records.test.js.snap
@@ -0,0 +1,1851 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EuiTableOfRecords basic - empty 1`] = `
+
+
+
+
+ Name
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords basic - with records 1`] = `
+
+
+
+
+ Name
+
+
+
+
+
+ name1
+
+
+
+
+ name2
+
+
+
+
+ name3
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination - 2nd page 1`] = `
+
+
+
+
+ Name
+
+
+
+
+
+ name1
+
+
+
+
+ name2
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination 1`] = `
+
+
+
+
+ Name
+
+
+
+
+
+ name1
+
+
+
+
+ name2
+
+
+
+
+ name3
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination and selection 1`] = `
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+ name1
+
+
+
+
+
+
+
+ name2
+
+
+
+
+
+
+
+ name3
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination, selection and sorting 1`] = `
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+ name1
+
+
+
+
+
+
+
+ name2
+
+
+
+
+
+
+
+ name3
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination, selection, sorting and a single record action 1`] = `
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+
+ name1
+
+
+
+
+
+
+
+
+
+
+ name2
+
+
+
+
+
+
+
+
+
+
+ name3
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination, selection, sorting and column dataType 1`] = `
+
+
+
+
+
+
+
+ Count
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+ 2
+
+
+
+
+
+
+
+ 3
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination, selection, sorting and column renderer 1`] = `
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+ NAME1
+
+
+
+
+
+
+
+ NAME2
+
+
+
+
+
+
+
+ NAME3
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination, selection, sorting and multiple record actions 1`] = `
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+
+ name1
+
+
+
+
+
+
+
+
+
+
+ name2
+
+
+
+
+
+
+
+
+
+
+ name3
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with pagination, selection, sorting, column renderer and column dataType 1`] = `
+
+
+
+
+
+
+
+ Count
+
+
+
+
+
+
+
+
+ x
+
+
+
+
+
+
+
+ xx
+
+
+
+
+
+
+
+ xxx
+
+
+
+
+
+
+`;
+
+exports[`EuiTableOfRecords with sorting 1`] = `
+
+
+
+
+ Name
+
+
+
+
+
+ name1
+
+
+
+
+ name2
+
+
+
+
+ name3
+
+
+
+
+
+`;
diff --git a/src/components/table_of_records/_index.scss b/src/components/table_of_records/_index.scss
new file mode 100644
index 00000000000..0bf270dde0f
--- /dev/null
+++ b/src/components/table_of_records/_index.scss
@@ -0,0 +1 @@
+@import 'table_of_records';
diff --git a/src/components/table_of_records/_table_of_records.scss b/src/components/table_of_records/_table_of_records.scss
new file mode 100644
index 00000000000..589d61068cf
--- /dev/null
+++ b/src/components/table_of_records/_table_of_records.scss
@@ -0,0 +1,3 @@
+.euiTableOfRecords {
+
+}
diff --git a/src/components/table_of_records/collapsed_record_actions.js b/src/components/table_of_records/collapsed_record_actions.js
new file mode 100644
index 00000000000..0971980f3d8
--- /dev/null
+++ b/src/components/table_of_records/collapsed_record_actions.js
@@ -0,0 +1,108 @@
+import React from 'react';
+import { EuiContextMenuItem, EuiContextMenuPanel } from '../context_menu';
+import { EuiPopover } from '../popover';
+import { EuiButtonIcon } from '../button';
+
+export class CollapsedRecordActions extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = { popoverOpen: false };
+ }
+
+ togglePopover = () => {
+ this.setState(prevState => ({ popoverOpen: !prevState.popoverOpen }));
+ };
+
+ closePopover = () => {
+ this.setState({ popoverOpen: false });
+ };
+
+ onPopoverBlur = () => {
+ // you must be asking... WTF? I know... but 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, recordId, record, model, actionEnabled, onFocus } = this.props;
+
+ const isOpen = this.state.popoverOpen;
+
+ let allDisabled = true;
+ const items = actions.reduce((items, action, index) => {
+ const key = `action_${recordId}_${index}`;
+ const available = action.available ? action.available(record, model) : true;
+ if (!available) {
+ return items;
+ }
+ const enabled = actionEnabled(action);
+ allDisabled = allDisabled && !enabled;
+ if (action.render) {
+ const item = action.render(record, model, enabled);
+ items.push(
+
+ {item}
+
+ );
+ } else {
+ items.push(
+
+ {action.name}
+
+ );
+ }
+ return items;
+ }, []);
+
+ const popoverButton = (
+
+ );
+
+ return (
+
+
+
+ );
+ }
+}
diff --git a/src/components/table_of_records/collapsed_record_actions.test.js b/src/components/table_of_records/collapsed_record_actions.test.js
new file mode 100644
index 00000000000..db36e1da597
--- /dev/null
+++ b/src/components/table_of_records/collapsed_record_actions.test.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import { shallow } from 'enzyme/build/index';
+import { CollapsedRecordActions } from './collapsed_record_actions';
+
+describe('CollapsedRecordActions', () => {
+
+ test('render', () => {
+
+ const props = {
+ actions: [
+ {
+ name: 'default1',
+ description: 'default 1',
+ onClick: () => {
+ }
+ },
+ {
+ name: 'custom1',
+ description: 'custom 1',
+ render: () => {
+ }
+ }
+ ],
+ visible: true,
+ recordId: 'id',
+ record: { id: 'xyz' },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 0
+ },
+ criteria: {
+ page: {
+ size: 5,
+ index: 0
+ }
+ }
+ },
+ actionEnabled: () => true,
+ onFocus: () => {}
+ };
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+
+ });
+
+});
diff --git a/src/components/table_of_records/custom_record_action.js b/src/components/table_of_records/custom_record_action.js
new file mode 100644
index 00000000000..9e390332b02
--- /dev/null
+++ b/src/components/table_of_records/custom_record_action.js
@@ -0,0 +1,54 @@
+import React, { cloneElement } from 'react';
+
+export class CustomRecordAction extends React.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, record, model } = this.props;
+ const tool = action.render(record, model, 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/table_of_records/custom_record_action.test.js b/src/components/table_of_records/custom_record_action.test.js
new file mode 100644
index 00000000000..4e2da17f5f6
--- /dev/null
+++ b/src/components/table_of_records/custom_record_action.test.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { shallow } from 'enzyme/build/index';
+import { CustomRecordAction } from './custom_record_action';
+
+describe('CustomRecordAction', () => {
+
+ test('render', () => {
+
+ const props = {
+ action: {
+ name: 'custom1',
+ description: 'custom 1',
+ render: () => 'test'
+ },
+ enabled: true,
+ visible: true,
+ record: { id: 'xyz' },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 0
+ },
+ criteria: {
+ page: {
+ size: 5,
+ index: 0
+ }
+ }
+ }
+ };
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+
+ });
+
+});
diff --git a/src/components/table_of_records/default_record_action.js b/src/components/table_of_records/default_record_action.js
new file mode 100644
index 00000000000..fe2e2cb9247
--- /dev/null
+++ b/src/components/table_of_records/default_record_action.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import { isString } from 'lodash';
+import { EuiButton, EuiButtonIcon } from '../button';
+
+const defaults = {
+ color: 'primary'
+};
+
+export class DefaultRecordAction extends React.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, record, model } = this.props;
+ if (!action.onClick) {
+ throw new Error(`Cannot render record 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(record, model);
+ 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 record 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, record, model } = this.props;
+ if (action.icon) {
+ return isString(action.icon) ? action.icon : action.icon(record, model);
+ }
+ }
+
+ resolveActionColor() {
+ const { action, record, model } = this.props;
+ if (action.color) {
+ return isString(action.color) ? action.color : action.color(record, model);
+ }
+ return defaults.color;
+ }
+}
diff --git a/src/components/table_of_records/default_record_action.test.js b/src/components/table_of_records/default_record_action.test.js
new file mode 100644
index 00000000000..58a100b7f3f
--- /dev/null
+++ b/src/components/table_of_records/default_record_action.test.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import { shallow } from 'enzyme/build/index';
+import { DefaultRecordAction } from './default_record_action';
+import { Random } from '../../services/random';
+
+const random = new Random();
+
+describe('DefaultRecordAction', () => {
+
+ test('render - button', () => {
+
+ const props = {
+ action: {
+ name: 'action1',
+ description: 'action 1',
+ type: random.oneOf(undefined, 'button', 'foobar'),
+ onClick: () => {}
+ },
+ enabled: true,
+ visible: true,
+ record: { id: 'xyz' },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 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,
+ record: { id: 'xyz' },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 0
+ },
+ criteria: {
+ page: {
+ size: 5,
+ index: 0
+ }
+ }
+ }
+ };
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+
+ });
+
+});
diff --git a/src/components/table_of_records/expanded_record_actions.js b/src/components/table_of_records/expanded_record_actions.js
new file mode 100644
index 00000000000..8429e80daea
--- /dev/null
+++ b/src/components/table_of_records/expanded_record_actions.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import { DefaultRecordAction } from './default_record_action';
+import { CustomRecordAction } from './custom_record_action';
+
+export const ExpandedRecordActions = ({ actions, visible, recordId, record, model, actionEnabled }) => {
+
+ return actions.reduce((tools, action, index) => {
+ const available = action.available ? action.available(record, model) : true;
+ if (!available) {
+ return tools;
+ }
+ const enabled = actionEnabled(action);
+ const key = `record_action_${recordId}_${index}`;
+ if (action.render) {
+ // custom action has a render function
+ tools.push(
+
+ );
+ } else {
+ tools.push(
+
+ );
+ }
+ return tools;
+ }, []);
+};
diff --git a/src/components/table_of_records/expanded_record_actions.test.js b/src/components/table_of_records/expanded_record_actions.test.js
new file mode 100644
index 00000000000..c4a337b43d7
--- /dev/null
+++ b/src/components/table_of_records/expanded_record_actions.test.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import { shallow } from 'enzyme/build/index';
+import { ExpandedRecordActions } from './expanded_record_actions';
+
+describe('ExpandedRecordActions', () => {
+
+ test('render', () => {
+
+ const props = {
+ actions: [
+ {
+ name: 'default1',
+ description: 'default 1',
+ onClick: () => {
+ }
+ },
+ {
+ name: 'custom1',
+ description: 'custom 1',
+ render: () => {
+ }
+ }
+ ],
+ visible: true,
+ recordId: 'id',
+ record: { id: 'xyz' },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 0
+ },
+ criteria: {
+ page: {
+ size: 5,
+ index: 0
+ }
+ }
+ },
+ actionEnabled: () => true
+ };
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+
+ });
+
+});
diff --git a/src/components/table_of_records/index.js b/src/components/table_of_records/index.js
new file mode 100644
index 00000000000..0da08b7f8d0
--- /dev/null
+++ b/src/components/table_of_records/index.js
@@ -0,0 +1,3 @@
+export {
+ EuiTableOfRecords
+} from './table_of_records';
diff --git a/src/components/table_of_records/pagination_bar.js b/src/components/table_of_records/pagination_bar.js
new file mode 100644
index 00000000000..7ae0adcafc0
--- /dev/null
+++ b/src/components/table_of_records/pagination_bar.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import { EuiSpacer } from '../spacer';
+import { EuiTablePagination } from '../table';
+
+const defaults = {
+ pageSizeOptions: [5, 10, 20]
+};
+
+export const PaginationBar = ({ config, model, onPageSizeChange, onPageChange }) => {
+ if (!model.criteria || !model.criteria.page) {
+ throw new Error(`The table of records is configured to show pagination but the provided
+ model is missing page criteria. Make sure the page criteria (index and size) is specified
+ under model.criteria.page`);
+ }
+ if (!config.onDataCriteriaChange) {
+ throw new Error(`The table of records is provided with a paginated model but [onDataCriteriaChange] is
+ not configured. This callback must be implemented to handle pagination changes`);
+ }
+ const pageSizeOptions = config.pagination.pageSizeOptions ?
+ config.pagination.pageSizeOptions :
+ defaults.pageSizeOptions;
+ const totalRecordCount = model.data.totalRecordCount || model.data.records.length;
+ const pageCount = Math.ceil(totalRecordCount / model.criteria.page.size);
+ return (
+
+
+
+
+ );
+};
diff --git a/src/components/table_of_records/pagination_bar.test.js b/src/components/table_of_records/pagination_bar.test.js
new file mode 100644
index 00000000000..bdf6021f5a9
--- /dev/null
+++ b/src/components/table_of_records/pagination_bar.test.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { requiredProps } from '../../test';
+import { shallow } from 'enzyme/build/index';
+import { PaginationBar } from './pagination_bar';
+
+describe('PaginationBar', () => {
+
+ test('render', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description'
+ }
+ ],
+ pagination: {},
+ onDataCriteriaChange: () => {}
+ },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 0
+ },
+ criteria: {
+ page: {
+ size: 5,
+ index: 0
+ }
+ }
+ },
+ onPageSizeChange: () => {},
+ onPageChange: () => {}
+ };
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+
+ });
+
+ test('render - custom page size options', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description'
+ }
+ ],
+ pagination: {
+ pageSizeOptions: [1, 2, 3]
+ },
+ onDataCriteriaChange: () => {}
+ },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 0
+ },
+ criteria: {
+ page: {
+ size: 5,
+ index: 0
+ }
+ }
+ },
+ onPageSizeChange: () => {},
+ onPageChange: () => {}
+ };
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+
+ });
+
+});
diff --git a/src/components/table_of_records/table_of_records.js b/src/components/table_of_records/table_of_records.js
new file mode 100644
index 00000000000..35b97962a23
--- /dev/null
+++ b/src/components/table_of_records/table_of_records.js
@@ -0,0 +1,553 @@
+import React from 'react';
+import _ from 'lodash';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import {
+ EuiTable, EuiTableBody, EuiTableHeader, EuiTableHeaderCell, EuiTableHeaderCellCheckbox,
+ EuiTableRow, EuiTableRowCell, EuiTableRowCellCheckbox
+} from '../table';
+import { EuiCheckbox } from '../form/checkbox';
+import { ICON_TYPES } from '../icon';
+import { COLORS as BUTTON_ICON_COLORS } from '../button/button_icon/button_icon';
+import {
+ formatAuto,
+ formatBoolean,
+ formatDate,
+ formatNumber,
+ formatText,
+ LEFT_ALIGNMENT, RIGHT_ALIGNMENT,
+ SortDirection, PropertySortType
+} from '../../services';
+import { PaginationBar } from './pagination_bar';
+import { CollapsedRecordActions } from './collapsed_record_actions';
+import { ExpandedRecordActions } from './expanded_record_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);
+
+const DefaultRecordActionType = PropTypes.shape({
+ type: PropTypes.oneOf([ 'icon', 'button' ]), // default is 'button'
+ name: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired, // (record, model) => void,
+ available: PropTypes.func, // (record, model) => boolean;
+ enabled: PropTypes.func, // (record, model) => boolean;
+ icon: PropTypes.oneOfType([ // required when type is 'icon'
+ PropTypes.oneOf(ICON_TYPES),
+ PropTypes.func // (record, model) => oneOf(ICON_TYPES)
+ ]),
+ color: PropTypes.oneOfType([
+ PropTypes.oneOf(BUTTON_ICON_COLORS),
+ PropTypes.func // (record, model) => oneOf(ICON_BUTTON_COLORS)
+ ])
+});
+
+const CustomRecordActionType = PropTypes.shape({
+ render: PropTypes.func.isRequired, // (record, model, enabled) => PropTypes.node;
+ available: PropTypes.func, // (record, model) => boolean;
+ enabled: PropTypes.func // (record, model) => boolean;
+});
+
+const SupportedRecordActionType = PropTypes.oneOfType([
+ DefaultRecordActionType,
+ CustomRecordActionType
+]);
+
+const FieldDataColumnType = PropTypes.shape({
+ field: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ dataType: PropTypes.oneOf(DATA_TYPES),
+ width: PropTypes.string,
+ sortable: PropTypes.bool,
+ align: PropTypes.oneOf([LEFT_ALIGNMENT, RIGHT_ALIGNMENT]),
+ truncateText: PropTypes.bool,
+ render: PropTypes.func // ((value, record) => PropTypes.node (also see [services/value_renderer] for basic implementations)
+});
+
+const ComputedColumnType = PropTypes.shape({
+ render: PropTypes.func.isRequired, // (record) => PropTypes.node
+ name: PropTypes.string,
+ description: PropTypes.string,
+ width: PropTypes.string,
+ truncateText: PropTypes.bool
+});
+
+const ActionsColumnType = PropTypes.shape({
+ actions: PropTypes.arrayOf(SupportedRecordActionType).isRequired,
+ name: PropTypes.string,
+ description: PropTypes.string,
+ width: PropTypes.string
+});
+
+const ColumnType = PropTypes.oneOfType([FieldDataColumnType, ComputedColumnType, ActionsColumnType]);
+
+const PaginationType = PropTypes.shape({
+ pageSizeOptions: PropTypes.arrayOf(PropTypes.number)
+});
+
+const SelectionType = PropTypes.shape({
+ onSelectionChanged: PropTypes.func, // (selection: Record[]) => void;,
+ selectable: PropTypes.func // (record, model) => boolean;
+});
+
+const RecordIdType = PropTypes.oneOfType([
+ PropTypes.string, // the name of the record id property
+ PropTypes.func // (record) => string
+]);
+
+const ConfigType = PropTypes.shape({
+ // when string, it's treated as the id property name
+ // when function it needs to have the following signature: (record) => string
+ recordId: RecordIdType.isRequired,
+ columns: PropTypes.arrayOf(ColumnType).isRequired,
+ onDataCriteriaChange: PropTypes.func,
+ selection: SelectionType,
+ pagination: PaginationType
+});
+
+const ModelType = PropTypes.shape({
+ data: PropTypes.shape({
+ records: PropTypes.array.isRequired,
+ totalRecordCount: PropTypes.number
+ }).isRequired,
+ criteria: PropTypes.shape({
+ page: PropTypes.shape({
+ index: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired
+ }),
+ sort: PropertySortType
+ })
+});
+
+const EuiTableOfRecordsPropTypes = {
+ config: ConfigType.isRequired,
+ model: ModelType.isRequired,
+ className: PropTypes.string
+};
+
+export class EuiTableOfRecords extends React.Component {
+
+ static propTypes = EuiTableOfRecordsPropTypes;
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ hoverRecordId: null,
+ selection: []
+ };
+ }
+
+ recordId(record) {
+ const id = this.props.config.recordId;
+ return _.isString(id) ? record[id] : id(record);
+ }
+
+ changeSelection(selection) {
+ if (!this.props.config.selection) {
+ return;
+ }
+ this.setState({ selection });
+ if (this.props.config.selection.onSelectionChanged) {
+ this.props.config.selection.onSelectionChanged(selection);
+ }
+ }
+
+ clearSelection() {
+ this.changeSelection([]);
+ }
+
+ onPageSizeChange(size) {
+ this.clearSelection();
+ const criteria = {
+ ...this.props.model.criteria,
+ page: {
+ ...this.props.model.criteria.page,
+ index: 0, // when page size changes, we take the user back to the first page
+ size
+ }
+ };
+ this.props.config.onDataCriteriaChange(criteria);
+ }
+
+ onPageChange(index) {
+ this.clearSelection();
+ const criteria = {
+ ...this.props.model.criteria,
+ page: {
+ ...this.props.model.criteria.page,
+ index
+ }
+ };
+ this.props.config.onDataCriteriaChange(criteria);
+ }
+
+ onColumnSortChange(column) {
+ this.clearSelection();
+ const currentCriteria = this.props.model.criteria;
+ let direction = SortDirection.ASC;
+ if (currentCriteria && currentCriteria.sort && currentCriteria.sort.field === column.field) {
+ direction = SortDirection.reverse(currentCriteria.sort.direction);
+ }
+ const criteria = {
+ ...currentCriteria,
+ // resetting the page if the criteria has one
+ page: !currentCriteria.page ? undefined : {
+ index: 0,
+ size: currentCriteria.page.size
+ },
+ sort: {
+ field: column.field,
+ direction
+ }
+ };
+ this.props.config.onDataCriteriaChange(criteria);
+ }
+
+ onRecordHover(recordId) {
+ this.setState({ hoverRecordId: recordId });
+ }
+
+ clearRecordHover() {
+ this.setState({ hoverRecordId: null });
+ }
+
+ render() {
+ const { className, config, model, ...rest } = this.props;
+
+ const classes = classNames(
+ 'euiRecordsTable',
+ className
+ );
+
+ const table = this.renderTable(config, model);
+ const paginationBar = this.renderPaginationBar(config, model);
+
+ return (
+
+ {table}
+ {paginationBar}
+
+ );
+ }
+
+ renderTable(config, model) {
+ const head = this.renderTableHead(config, model);
+ const body = this.renderTableBody(config, model);
+ return {head}{body};
+ }
+
+ renderTableHead(config, model) {
+
+ const headers = [];
+
+ if (config.selection) {
+ const checked = this.state.selection && this.state.selection.length > 0;
+ const onChange = (event) => {
+ if (event.target.checked) {
+ const selectableRecords = model.data.records.filter((record) =>
+ !config.selection.selectable || config.selection.selectable(record, model));
+ this.changeSelection(selectableRecords);
+ } else {
+ this.changeSelection([]);
+ }
+ };
+ headers.push(
+
+
+
+ );
+ }
+
+ config.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, config, model);
+ const onSort = this.resolveColumnOnSort(column, config);
+ 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, config, model) {
+ const modelCriteriaSort = model.criteria ? model.criteria.sort : undefined;
+ if (column.sortable && modelCriteriaSort && modelCriteriaSort.field === column.field) {
+ return modelCriteriaSort.direction;
+ }
+ }
+
+ resolveColumnOnSort(column, config) {
+ if (column.sortable) {
+ if (!config.onDataCriteriaChange) {
+ throw new Error(`The table of records is configured to be sortable on column [${column.field}] but
+ [onDataCriteriaChange] is not configured. This callback must be implemented to handle to handle the
+ sort requests`);
+ }
+ return () => this.onColumnSortChange(column);
+ }
+ }
+
+ renderTableBody(config, model) {
+ const rows = model.data.records.map((record, index) => {
+ return this.renderTableRecordRow(record, config, model, index);
+ });
+ return {rows};
+ }
+
+ renderTableRecordRow(record, config, model, rowIndex) {
+ const recordId = this.recordId(record);
+ const selected = this.state.selection && !!this.state.selection.find(selectedRecord => {
+ return this.recordId(selectedRecord) === recordId;
+ });
+
+ const cells = [];
+
+ if (config.selection) {
+ cells.push(this.renderTableRecordSelectionCell(recordId, record, config, model, selected));
+ }
+
+ config.columns.forEach((column, columnIndex) => {
+ if (column.actions) {
+ cells.push(this.renderTableRecordActionsCell(recordId, record, column.actions, config, model, columnIndex));
+ } else if (column.field) {
+ cells.push(this.renderTableRecordFieldDataCell(recordId, record, column, columnIndex));
+ } else {
+ cells.push(this.renderTableRecordComputedCell(recordId, record, column, model, columnIndex));
+ }
+ });
+
+ const onMouseOver = () => this.onRecordHover(recordId);
+ const onMouseOut = () => this.clearRecordHover();
+ return (
+
+ {cells}
+
+ );
+ }
+
+ renderTableRecordFieldDataCell(recordId, record, column, index) {
+ const key = `_data_column_${column.field}_${recordId}_${index}`;
+ const align = this.resolveColumnAlign(column);
+ const textOnly = !column.render;
+ const value = _.get(record, column.field);
+ const contentRenderer = this.resolveContentRenderer(column);
+ const content = contentRenderer(value, record);
+ return (
+
+ {content}
+
+ );
+ }
+
+ renderTableRecordComputedCell(recordId, record, column, model, index) {
+ const key = `_computed_column_${recordId}_${index}`;
+ const align = this.resolveColumnAlign(column);
+ const contentRenderer = this.resolveContentRenderer(column);
+ const content = contentRenderer(record, model);
+ 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;
+ }
+
+ renderTableRecordSelectionCell(recordId, record, config, model, selected) {
+ const key = `_selection_column_${recordId}`;
+ const checked = selected;
+ const disabled = config.selection.selectable && !config.selection.selectable(record);
+ const title = config.selection.selectableMessage && config.selection.selectableMessage(record);
+ const onChange = (event) => {
+ if (event.target.checked) {
+ this.changeSelection([...this.state.selection, record]);
+ } else {
+ this.changeSelection(this.state.selection.reduce((selection, selectedRecord) => {
+ if (this.recordId(selectedRecord) !== recordId) {
+ selection.push(selectedRecord);
+ }
+ return selection;
+ }, []));
+ }
+ };
+
+ return (
+
+
+
+ );
+ }
+
+ renderTableRecordActionsCell(recordId, record, actions, config, model, columnIndex) {
+
+ const visible = this.state.hoverRecordId === recordId;
+
+ const actionEnabled = (action) =>
+ this.state.selection.length === 0 && (!action.enabled || action.enabled(record, model));
+
+ 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 configured actions
+
+ actualActions = [
+ {
+ name: 'Actions',
+ render: (record, model) => {
+ return (
+
+ );
+ }
+ }
+ ];
+ }
+
+ const tools = (
+
+ );
+
+ const key = `record_actions_${recordId}_${columnIndex}`;
+ return (
+
+ {tools}
+
+ );
+ }
+
+ renderPaginationBar(config, model) {
+ if (config.pagination) {
+ return (
+
+ );
+ }
+ }
+
+}
diff --git a/src/components/table_of_records/table_of_records.test.js b/src/components/table_of_records/table_of_records.test.js
new file mode 100644
index 00000000000..5cd988c4645
--- /dev/null
+++ b/src/components/table_of_records/table_of_records.test.js
@@ -0,0 +1,548 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { requiredProps } from '../../test';
+
+import { EuiTableOfRecords } from './table_of_records';
+
+describe('EuiTableOfRecords', () => {
+
+ test('basic - empty', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description'
+ }
+ ]
+ },
+ model: {
+ data: {
+ records: [],
+ totalRecordCount: 0
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component)
+ .toMatchSnapshot();
+ });
+
+ test('basic - with records', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description'
+ }
+ ]
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 3
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description'
+ }
+ ],
+ pagination: {},
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination - 2nd page', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description'
+ }
+ ],
+ pagination: {},
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 1,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with sorting', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description',
+ sortable: true
+ }
+ ],
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 3
+ },
+ criteria: {
+ sort: { field: 'name', direction: 'asc' }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination and selection', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description'
+ }
+ ],
+ pagination: {},
+ selection: {
+ onSelectionChanged: () => undefined
+ },
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination, selection and sorting', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description',
+ sortable: true
+ }
+ ],
+ pagination: {},
+ selection: {
+ onSelectionChanged: () => undefined
+ },
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination, selection, sorting and column renderer', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description',
+ sortable: true,
+ render: (name) => name.toUpperCase()
+ }
+ ],
+ pagination: {},
+ selection: {
+ onSelectionChanged: () => undefined
+ },
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination, selection, sorting and column dataType', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'count',
+ name: 'Count',
+ description: 'description',
+ sortable: true,
+ dataType: 'number'
+ }
+ ],
+ pagination: {},
+ selection: {
+ onSelectionChanged: () => undefined
+ },
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', count: 1 },
+ { id: '2', count: 2 },
+ { id: '3', count: 3 }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ // here we want to verify that the column renderer takes precedence over the column data type
+ test('with pagination, selection, sorting, column renderer and column dataType', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'count',
+ name: 'Count',
+ description: 'description',
+ sortable: true,
+ dataType: 'number',
+ render: (count) => 'x'.repeat(count)
+ }
+ ],
+ pagination: {},
+ selection: {
+ onSelectionChanged: () => undefined
+ },
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', count: 1 },
+ { id: '2', count: 2 },
+ { id: '3', count: 3 }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination, selection, sorting and a single record action', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description',
+ sortable: true
+ },
+ {
+ actions: [
+ {
+ type: 'button',
+ name: 'Edit',
+ description: 'edit',
+ onClick: () => undefined
+ }
+ ]
+ }
+ ],
+ pagination: {},
+ selection: {
+ onSelectionChanged: () => undefined
+ },
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('with pagination, selection, sorting and multiple record actions', () => {
+
+ const props = {
+ ...requiredProps,
+ config: {
+ recordId: 'id',
+ columns: [
+ {
+ field: 'name',
+ name: 'Name',
+ description: 'description',
+ sortable: true
+ },
+ {
+ actions: [
+ {
+ type: 'button',
+ name: 'Edit',
+ description: 'edit',
+ onClick: () => undefined
+ },
+ {
+ type: 'button',
+ name: 'Delete',
+ description: 'delete',
+ onClick: () => undefined
+ }
+ ]
+ }
+ ],
+ pagination: {},
+ selection: {
+ onSelectionChanged: () => undefined
+ },
+ onDataCriteriaChange: () => undefined
+ },
+ model: {
+ data: {
+ records: [
+ { id: '1', name: 'name1' },
+ { id: '2', name: 'name2' },
+ { id: '3', name: 'name3' }
+ ],
+ totalRecordCount: 5
+ },
+ criteria: {
+ page: {
+ index: 0,
+ size: 3
+ }
+ }
+ }
+ };
+
+
+ const component = shallow(
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+});
diff --git a/src/index.js b/src/index.js
index 80e3bea6755..bb4687878b9 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,2 +1,3 @@
export * from './components';
export * from './services';
+export * from './utils';
diff --git a/src/services/format/format_auto.js b/src/services/format/format_auto.js
new file mode 100644
index 00000000000..7cb57493dd1
--- /dev/null
+++ b/src/services/format/format_auto.js
@@ -0,0 +1,34 @@
+import { isArray, isBoolean, isDate, isNaN, isNil, isNumber, isString } from 'lodash';
+import { formatBoolean } from './format_boolean';
+import { formatDate } from './format_date';
+import { formatNumber } from './format_number';
+import { formatText } from './format_text';
+
+export const formatAuto = value => {
+ if (isNil(value) || isNaN(value)) {
+ return '';
+ }
+
+ if (isString(value)) {
+ return formatText(value);
+ }
+
+ if (isDate(value)) {
+ return formatDate(value);
+ }
+
+ if (isBoolean(value)) {
+ return formatBoolean(value);
+ }
+
+ if (isNumber(value)) {
+ return formatNumber(value);
+ }
+
+ if (isArray(value)) {
+ return Array.isArray(value) ? value.map(item => formatAuto(item)).join(', ') : formatAuto(value);
+ }
+
+ // TODO not sure if we want that.. the (+) is that we show something, the (-) is that it's very technical
+ return JSON.stringify(value);
+};
diff --git a/src/services/format/format_auto.test.js b/src/services/format/format_auto.test.js
new file mode 100644
index 00000000000..7f3037e7381
--- /dev/null
+++ b/src/services/format/format_auto.test.js
@@ -0,0 +1,31 @@
+import { formatAuto } from './format_auto';
+
+describe('formatAuto', () => {
+ test('boolean value', () => {
+ expect(formatAuto(true)).toBe('Yes');
+ expect(formatAuto(false)).toBe('No');
+ });
+
+ test('numeric value', () => {
+ expect(formatAuto(1234.567)).toBe('1234.567');
+ });
+
+ test('string value', () => {
+ expect(formatAuto('value')).toBe('value');
+ });
+
+ test('date value', () => {
+ const value = new Date(1999, 0, 1, 2, 3, 4, 5);
+ expect(formatAuto(value)).toBe('1 Jan 1999 02:03');
+ });
+
+ test('array of dates', () => {
+ const dates = [ new Date(1999, 0, 1, 2, 3, 4, 5) ];
+ expect(formatAuto(dates)).toBe('1 Jan 1999 02:03');
+ });
+
+ test('object value', () => {
+ const obj = { key: 'value' };
+ expect(formatAuto(obj)).toBe(`{\"key\":\"value\"}`);
+ });
+});
diff --git a/src/services/format/format_boolean.js b/src/services/format/format_boolean.js
new file mode 100644
index 00000000000..9defe2c31e3
--- /dev/null
+++ b/src/services/format/format_boolean.js
@@ -0,0 +1,9 @@
+import { isNil } from 'lodash';
+
+export const formatBoolean = (value, { yes = 'Yes', no = 'No', nil = '' } = {}) => {
+ if (isNil(value)) {
+ return nil;
+ }
+
+ return value ? yes : no;
+};
diff --git a/src/services/format/format_boolean.test.js b/src/services/format/format_boolean.test.js
new file mode 100644
index 00000000000..9f3ba31d5c3
--- /dev/null
+++ b/src/services/format/format_boolean.test.js
@@ -0,0 +1,14 @@
+import { formatBoolean } from './format_boolean';
+
+describe('formatBoolean', () => {
+ test('no config', () => {
+ expect(formatBoolean(true)).toBe('Yes');
+ expect(formatBoolean(false)).toBe('No');
+ });
+
+ test('with config', () => {
+ const config = { yes: 'Aye', no: 'Nay' };
+ expect(formatBoolean(true, config)).toBe('Aye');
+ expect(formatBoolean(false, config)).toBe('Nay');
+ });
+});
diff --git a/src/services/format/format_date.js b/src/services/format/format_date.js
new file mode 100644
index 00000000000..64d736e22f6
--- /dev/null
+++ b/src/services/format/format_date.js
@@ -0,0 +1,72 @@
+import { isNil, isFunction, isString } from 'lodash';
+import moment from 'moment';
+
+const calendar = (value, options = {}) => {
+ const refTime = options.refTime || null;
+ return moment(value).calendar(refTime, options);
+};
+
+export const dateFormatAliases = {
+ date: 'D MMM YYYY',
+ longDate: 'DD MMMM YYYY',
+ shortDate: 'D MMM YY',
+ dateTime: 'D MMM YYYY HH:mm',
+ longDateTime: 'DD MMMM YYYY HH:mm:ss',
+ shortDateTime: 'D MMM YY HH:mm',
+ dobShort: 'Do MMM YY',
+ dobLong: 'Do MMMM YYYY',
+ iso8601: 'YYYY-MM-DDTHH:mm:ss.SSSZ',
+ calendar,
+ calendarDateTime: (value, options) => {
+ return calendar(value, {
+ sameDay: '[Today at] H:mmA',
+ nextDay: '[Tomorrow at] H:mmA',
+ nextWeek: 'dddd [at] H:mmA',
+ lastDay: '[Yesterday at] H:mmA',
+ lastWeek: '[Last] dddd [at] H:mmA',
+ sameElse: 'Do MMM YYYY [at] H:mmA',
+ ...options
+ });
+ },
+ calendarDate: (value, options) => {
+ return calendar(value, {
+ sameDay: '[Today]',
+ nextDay: '[Tomorrow]',
+ nextWeek: 'dddd',
+ lastDay: '[Yesterday]',
+ lastWeek: '[Last] dddd',
+ sameElse: 'Do MMM YYYY',
+ ...options
+ });
+ }
+};
+
+export const formatDate = (value, dateFormatKeyOrConfig = 'dateTime') => {
+ if (isString(dateFormatKeyOrConfig)) {
+ if (isNil(value)) {
+ return '';
+ }
+
+ const dateFormat = dateFormatAliases[dateFormatKeyOrConfig] || dateFormatKeyOrConfig;
+
+ return moment(value).format(dateFormat);
+ }
+
+ const {
+ format = 'dateTime',
+ nil = '',
+ options,
+ } = dateFormatKeyOrConfig;
+
+ const dateFormat = dateFormatAliases[format] || format;
+
+ if (isNil(value)) {
+ return nil;
+ }
+
+ if (isFunction(dateFormat)) {
+ return dateFormat(value, options);
+ }
+
+ return moment(value).format(dateFormat);
+};
diff --git a/src/services/format/format_date.test.js b/src/services/format/format_date.test.js
new file mode 100644
index 00000000000..c0f1c59ad43
--- /dev/null
+++ b/src/services/format/format_date.test.js
@@ -0,0 +1,130 @@
+import { formatDate } from './format_date';
+import moment from 'moment';
+
+describe('formatDate', () => {
+ // 1st January 1999 02:03:04.005
+ const value = new Date(1999, 0, 1, 2, 3, 4, 5);
+
+ test('no config - date value', () => {
+ expect(formatDate(value)).toBe('1 Jan 1999 02:03');
+ });
+
+ test('no config - number value', () => {
+ expect(formatDate(value.getTime())).toBe('1 Jan 1999 02:03');
+ });
+
+ test('no config - string value', () => {
+ expect(formatDate(value.toISOString())).toBe('1 Jan 1999 02:03');
+ });
+
+ test('no config - no value', () => {
+ expect(formatDate()).toBe('');
+ });
+
+ test('with config - no value', () => {
+ expect(formatDate(undefined, { nil: '-' })).toBe('-');
+ });
+
+ test('with config - "date" format', () => {
+ expect(formatDate(value, 'date')).toBe('1 Jan 1999');
+ });
+
+ test('with config - "longDate" format', () => {
+ expect(formatDate(value, 'longDate')).toBe('01 January 1999');
+ });
+
+ test('with config - "shortDate" format', () => {
+ expect(formatDate(value, 'shortDate')).toBe('1 Jan 99');
+ });
+
+ test('with config - "dateTime" format', () => {
+ expect(formatDate(value, 'dateTime')).toBe('1 Jan 1999 02:03');
+ });
+
+ test('with config - "longDateTime" format', () => {
+ expect(formatDate(value, 'longDateTime')).toBe('01 January 1999 02:03:04');
+ });
+
+ test('with config - "shortDateTime" format', () => {
+ expect(formatDate(value, 'shortDateTime')).toBe('1 Jan 99 02:03');
+ });
+
+ test('with config - "iso8601" format', () => {
+ expect(formatDate(value, 'iso8601')).toBe(`1999-01-01T02:03:04.005${formatTimezoneOffset(value.getTimezoneOffset())}`);
+ });
+
+ test('with config - "calendarDate" format', () => {
+ const options = {
+ refTime: value, // 1st January 1999 02:03:04.005 (Friday)
+ };
+
+ const oneMonthFromNow = moment(options.refTime).add(1, 'month').toDate();
+ expect(formatDate(oneMonthFromNow, { format: 'calendarDate', options })).toBe(`1st Feb 1999`);
+
+ const twoDaysFromNow = moment(options.refTime).add(2, 'day').toDate();
+ expect(formatDate(twoDaysFromNow, { format: 'calendarDate', options })).toBe(`Sunday`);
+
+ const oneDayFromNow = moment(options.refTime).add(1, 'day').toDate();
+ expect(formatDate(oneDayFromNow, { format: 'calendarDate', options })).toBe(`Tomorrow`);
+
+ const anMinuteAgo = moment(options.refTime).subtract(1, 'minute').toDate();
+ expect(formatDate(anMinuteAgo, { format: 'calendarDate', options })).toBe(`Today`);
+
+ const oneDayAgo = moment(options.refTime).subtract(1, 'day').toDate();
+ expect(formatDate(oneDayAgo, { format: 'calendarDate', options })).toBe(`Yesterday`);
+
+ const twoDaysWeekAgo = moment(options.refTime).subtract(2, 'day').toDate();
+ expect(formatDate(twoDaysWeekAgo, { format: 'calendarDate', options })).toBe(`Last Wednesday`);
+
+ const oneMonthAgo = moment(options.refTime).subtract(1, 'month').toDate();
+ expect(formatDate(oneMonthAgo, { format: 'calendarDate', options })).toBe(`1st Dec 1998`);
+ });
+
+ test('with config - "calendarDateTime" format', () => {
+ const options = {
+ refTime: value, // 1st January 1999 02:03:04.005
+ };
+
+ const oneMonthFromNow = moment(options.refTime).add(1, 'month').toDate();
+ expect(formatDate(oneMonthFromNow, { format: 'calendarDateTime', options })).toBe(`1st Feb 1999 at 2:03AM`);
+
+ const twoDaysFromNow = moment(options.refTime).add(2, 'day').toDate();
+ expect(formatDate(twoDaysFromNow, { format: 'calendarDateTime', options })).toBe(`Sunday at 2:03AM`);
+
+ const oneDayFromNow = moment(options.refTime).add(1, 'day').toDate();
+ expect(formatDate(oneDayFromNow, { format: 'calendarDateTime', options })).toBe(`Tomorrow at 2:03AM`);
+
+ const anMinuteAgo = moment(options.refTime).subtract(1, 'minute').toDate();
+ expect(formatDate(anMinuteAgo, { format: 'calendarDateTime', options })).toBe(`Today at 2:02AM`);
+
+ const oneDayAgo = moment(options.refTime).subtract(1, 'day').toDate();
+ expect(formatDate(oneDayAgo, { format: 'calendarDateTime', options })).toBe(`Yesterday at 2:03AM`);
+
+ const twoDaysWeekAgo = moment(options.refTime).subtract(2, 'day').toDate();
+ expect(formatDate(twoDaysWeekAgo, { format: 'calendarDateTime', options })).toBe(`Last Wednesday at 2:03AM`);
+
+ const oneMonthAgo = moment(options.refTime).subtract(1, 'month').toDate();
+ expect(formatDate(oneMonthAgo, { format: 'calendarDateTime', options })).toBe(`1st Dec 1998 at 2:03AM`);
+ });
+
+ test('with config - custom format', () => {
+ expect(formatDate(value, 'YYYY-MM-DD')).toBe(`1999-01-01`);
+ });
+});
+
+function formatTimezoneOffset(offset) {
+ if (offset === 0) {
+ return '+00:00';
+ }
+ const sign = offset > 0 ? '-' : '+';
+ offset = Math.abs(offset);
+ let hrs = Math.floor(offset / 60);
+ if (hrs < 9) {
+ hrs = `0${hrs}`;
+ }
+ let mins = offset - hrs * 60;
+ if (mins < 9) {
+ mins = `0${mins}`;
+ }
+ return `${sign}${hrs}:${mins}`;
+}
diff --git a/src/services/format/format_number.js b/src/services/format/format_number.js
new file mode 100644
index 00000000000..65196e31eab
--- /dev/null
+++ b/src/services/format/format_number.js
@@ -0,0 +1,32 @@
+import numeral from 'numeral';
+import { isNil, isString } from 'lodash';
+
+const numberFormatAliases = {
+ decimal1: '0,0.0',
+ decimal2: '0,0.00',
+ decimal3: '0,0.000',
+ ordinal: '0o',
+ integer: '0,0'
+};
+
+export const formatNumber = (value, numberFormatOrConfig = {}) => {
+ let format;
+ let nil = '';
+ let round;
+
+ if (isString(numberFormatOrConfig)) {
+ format = numberFormatOrConfig;
+ } else {
+ format = numberFormatOrConfig.format;
+ nil = numberFormatOrConfig.nil || '';
+ round = numberFormatOrConfig.round;
+ }
+
+ if (!format) {
+ return isNil(value) ? nil : value.toString();
+ }
+
+ const roundingFunc = round ? Math.round : Math.floor;
+ const numberFormat = numberFormatAliases[format] || format;
+ return isNil(value) ? nil : numeral(value).format(numberFormat, roundingFunc);
+};
diff --git a/src/services/format/format_number.test.js b/src/services/format/format_number.test.js
new file mode 100644
index 00000000000..b499e124ff9
--- /dev/null
+++ b/src/services/format/format_number.test.js
@@ -0,0 +1,44 @@
+import { formatNumber } from './format_number';
+
+describe('formatNumber', () => {
+ const value = 1234.5678;
+
+ test('no config', () => {
+ expect(formatNumber(value)).toBe(value.toString());
+ });
+
+ test('with config - "decimal1" format', () => {
+ expect(formatNumber(value, 'decimal1')).toBe('1,234.5');
+ });
+
+ test('with config - "decimal1" format - rounded', () => {
+ expect(formatNumber(value, { format: 'decimal1', round: true })).toBe('1,234.6');
+ });
+
+ test('with config - "decimal2" format', () => {
+ expect(formatNumber(value, 'decimal2')).toBe('1,234.56');
+ });
+
+ test('with config - "decimal2" format - rounded', () => {
+ expect(formatNumber(value, { format: 'decimal2', round: true })).toBe('1,234.57');
+ });
+
+ test('with config - "decimal3" format', () => {
+ expect(formatNumber(value, 'decimal3')).toBe('1,234.567');
+ });
+
+ test('with config - "decimal3" format - rounded', () => {
+ expect(formatNumber(value, { format: 'decimal3', round: true })).toBe('1,234.568');
+ });
+
+ test('with config - "ordinal" format', () => {
+ expect(formatNumber(1, 'ordinal')).toBe('1st');
+ expect(formatNumber(132, 'ordinal')).toBe('132nd');
+ expect(formatNumber(89, 'ordinal')).toBe('89th');
+ expect(formatNumber(23, 'ordinal')).toBe('23rd');
+ });
+
+ test('with config - "integer" format', () => {
+ expect(formatNumber(value, 'integer')).toBe('1,234');
+ });
+});
diff --git a/src/services/format/format_text.js b/src/services/format/format_text.js
new file mode 100644
index 00000000000..fecf1ea21fb
--- /dev/null
+++ b/src/services/format/format_text.js
@@ -0,0 +1,5 @@
+import { isNil } from 'lodash';
+
+export const formatText = (value, { nil = '' } = {}) => {
+ return isNil(value) ? nil : value.toString();
+};
diff --git a/src/services/format/format_text.test.js b/src/services/format/format_text.test.js
new file mode 100644
index 00000000000..21da638bb10
--- /dev/null
+++ b/src/services/format/format_text.test.js
@@ -0,0 +1,21 @@
+import { formatText } from './format_text';
+
+describe('formatText', () => {
+ test('no config - not nil value', () => {
+ expect(formatText('abc')).toBe('abc');
+ expect(formatText(1)).toBe('1');
+ const now = Date.now();
+ expect(formatText(new Date(now))).toBe(new Date(now).toString());
+ expect(formatText({ /* simple object */})).toBe('[object Object]');
+ });
+
+ test('no config - nil value', () => {
+ expect(formatText()).toBe('');
+ expect(formatText(null)).toBe('');
+ });
+
+ test('with config - nil value', () => {
+ expect(formatText(undefined, { nil: '-' })).toBe('-');
+ expect(formatText(null, { nil: '-' })).toBe('-');
+ });
+});
diff --git a/src/services/format/index.js b/src/services/format/index.js
new file mode 100644
index 00000000000..833568ea9c0
--- /dev/null
+++ b/src/services/format/index.js
@@ -0,0 +1,5 @@
+export { formatAuto } from './format_auto';
+export { formatBoolean } from './format_boolean';
+export { formatDate } from './format_date';
+export { formatNumber } from './format_number';
+export { formatText } from './format_text';
diff --git a/src/services/index.js b/src/services/index.js
index c51a5a0817d..738c598dee9 100644
--- a/src/services/index.js
+++ b/src/services/index.js
@@ -20,7 +20,15 @@ export {
} from './color';
export {
- Pager,
+ formatAuto,
+ formatBoolean,
+ formatDate,
+ formatNumber,
+ formatText,
+} from './format';
+
+export {
+ Pager
} from './paging';
export {
@@ -32,9 +40,17 @@ export {
} from './security';
export {
+ PropertySortType,
+ SortDirectionType,
+ SortDirection,
SortableProperties,
+ Comparators,
} from './sort';
export {
noOverflowPlacement,
} from './overflow';
+
+export {
+ Random
+} from './random';
diff --git a/src/services/random.js b/src/services/random.js
new file mode 100644
index 00000000000..d967d8ef7c9
--- /dev/null
+++ b/src/services/random.js
@@ -0,0 +1,42 @@
+import { isNil } from 'lodash';
+
+const defaultRand = Math.random;
+
+export class Random {
+
+ constructor(rand = defaultRand) {
+ this._rand = rand;
+ }
+
+ boolean() {
+ return this._rand() > 0.5;
+ }
+
+ number(options = {}) {
+ const min = isNil(options.min) ? Number.MIN_VALUE : options.min;
+ const max = isNil(options.max) ? Number.MAX_VALUE : options.max;
+ const delta = this._rand() * (max - min);
+ return min + delta;
+ }
+
+ integer(options = {}) {
+ const min = Math.ceil(isNil(options.min) ? Number.MIN_VALUE : options.min);
+ const max = Math.floor(isNil(options.max) ? Number.MAX_VALUE : options.max);
+ const delta = Math.floor(this._rand() * (max - min + 1));
+ return min + delta;
+ }
+
+ oneOf(...options) {
+ return options[Math.floor(this._rand() * options.length)];
+ }
+
+ date(options = {}) {
+ const min = isNil(options.min) ? new Date(0) : options.min;
+ const max = isNil(options.max) ? Date.now() : options.max;
+ const minMls = min.getTime();
+ const maxMls = max.getTime();
+ const time = this.integer({ min: minMls, max: maxMls });
+ return new Date(time);
+ }
+
+}
diff --git a/src/services/sort/comparators.js b/src/services/sort/comparators.js
new file mode 100644
index 00000000000..a122f781769
--- /dev/null
+++ b/src/services/sort/comparators.js
@@ -0,0 +1,28 @@
+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;
+ };
+ },
+
+ reverse: (comparator) => {
+ return (v1, v2) => comparator(v2, v1);
+ },
+
+ property(prop, comparator = undefined) {
+ if (!comparator) {
+ comparator = this.default(SortDirection.ASC);
+ }
+ return (o1, o2) => {
+ return comparator(o1[prop], o2[prop]);
+ };
+ }
+
+});
diff --git a/src/services/sort/comparators.test.js b/src/services/sort/comparators.test.js
new file mode 100644
index 00000000000..e7c141114df
--- /dev/null
+++ b/src/services/sort/comparators.test.js
@@ -0,0 +1,38 @@
+import { Comparators } from './comparators';
+import { Random } from '../random';
+import { SortDirection } from './sort_direction';
+
+describe('comparators - default', () => {
+ test('asc', () => {
+ expect(Comparators.default(SortDirection.ASC)(5, 10)).toBeLessThan(0);
+ });
+ test('desc', () => {
+ expect(Comparators.default(SortDirection.DESC)(5, 10)).toBeGreaterThan(0);
+ });
+ test('asc/desc when the two values equal', () => {
+ const dir = new Random().oneOf(SortDirection.ASC, SortDirection.DESC);
+ expect(Comparators.default(dir)(5, 5)).toBe(0);
+ });
+});
+
+describe('comparators - reverse', () => {
+ const comparator = jest.fn();
+ test('proper delegation to provided comparator', () => {
+ const reverseComparator = Comparators.reverse(comparator);
+ reverseComparator('v1', 'v2');
+ expect(comparator.mock.calls.length).toBe(1);
+ expect(comparator.mock.calls[0][0]).toBe('v2');
+ expect(comparator.mock.calls[0][1]).toBe('v1');
+ });
+});
+
+describe('comparators - property', () => {
+ const comparator = jest.fn();
+ test('proper delegation to provided comparator', () => {
+ const propComparator = Comparators.property('name', comparator);
+ propComparator({ name: 'n1' }, { name: 'n2' });
+ expect(comparator.mock.calls.length).toBe(1);
+ expect(comparator.mock.calls[0][0]).toBe('n1');
+ expect(comparator.mock.calls[0][1]).toBe('n2');
+ });
+});
diff --git a/src/services/sort/index.js b/src/services/sort/index.js
index d945c274adf..a370c9e908e 100644
--- a/src/services/sort/index.js
+++ b/src/services/sort/index.js
@@ -1 +1,4 @@
export { SortableProperties } from './sortable_properties';
+export { SortDirectionType, SortDirection } from './sort_direction';
+export { PropertySortType } from './property_sort';
+export { Comparators } from './comparators';
diff --git a/src/services/sort/property_sort.js b/src/services/sort/property_sort.js
new file mode 100644
index 00000000000..daa4455b8c2
--- /dev/null
+++ b/src/services/sort/property_sort.js
@@ -0,0 +1,7 @@
+import PropTypes from 'prop-types';
+import { SortDirectionType } from './sort_direction';
+
+export const PropertySortType = PropTypes.shape({
+ field: PropTypes.string.isRequired,
+ direction: SortDirectionType.isRequired
+});
diff --git a/src/services/sort/sort_direction.js b/src/services/sort/sort_direction.js
new file mode 100644
index 00000000000..f4e3ed2a785
--- /dev/null
+++ b/src/services/sort/sort_direction.js
@@ -0,0 +1,14 @@
+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;
+ }
+});
+
+export const SortDirectionType = PropTypes.oneOf([ SortDirection.ASC, SortDirection.DESC ]);
diff --git a/src/utils/index.js b/src/utils/index.js
new file mode 100644
index 00000000000..f4a6a69a4cc
--- /dev/null
+++ b/src/utils/index.js
@@ -0,0 +1 @@
+export * from './prop_types';
diff --git a/src/utils/prop_types/index.js b/src/utils/prop_types/index.js
new file mode 100644
index 00000000000..d2da414cc7d
--- /dev/null
+++ b/src/utils/prop_types/index.js
@@ -0,0 +1,5 @@
+import { is } from './is';
+
+export const EuiPropTypes = {
+ is
+};
diff --git a/src/utils/prop_types/is.js b/src/utils/prop_types/is.js
new file mode 100644
index 00000000000..86c8194cf68
--- /dev/null
+++ b/src/utils/prop_types/is.js
@@ -0,0 +1,25 @@
+import { isNil } from 'lodash';
+
+export const is = (expectedValue) => {
+
+ const validator = (props, propName, componentName) => {
+ const compName = componentName || 'ANONYMOUS';
+ const value = props[propName];
+ if (value !== expectedValue) {
+ return new Error(`[${propName}] property in [${compName}] component is expected to equal [${expectedValue}] but
+ [${value}] was provided instead.`);
+ }
+ return null;
+ };
+
+ validator.isRequired = (props, propName, componentName) => {
+ const compName = componentName || 'ANONYMOUS';
+ const value = props[propName];
+ if (isNil(value)) {
+ return new Error(`[${propName}] property in [${compName}] component is required but seems to be missing`);
+ }
+ return validator(props, propName, componentName);
+ };
+
+ return validator;
+};
diff --git a/yarn.lock b/yarn.lock
index 090488211ca..8e2843842ee 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5615,6 +5615,10 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkd
dependencies:
minimist "0.0.8"
+moment@2.13.0:
+ version "2.13.0"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.13.0.tgz#24162d99521e6d40f99ae6939e806d2139eaac52"
+
mri@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.0.tgz#5c0a3f29c8ccffbbb1ec941dcec09d71fa32f36a"
@@ -5966,6 +5970,10 @@ number-is-nan@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+numeral@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/numeral/-/numeral-2.0.6.tgz#4ad080936d443c2561aed9f2197efffe25f4e506"
+
nwmatcher@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.3.tgz#64348e3b3d80f035b40ac11563d278f8b72db89c"