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"