diff --git a/src-docs/src/components/guide_section/guide_section.js b/src-docs/src/components/guide_section/guide_section.js index 2e79cae3771..39186b174a7 100644 --- a/src-docs/src/components/guide_section/guide_section.js +++ b/src-docs/src/components/guide_section/guide_section.js @@ -172,7 +172,7 @@ export class GuideSection extends Component { function markup(text) { const regex = /(#[a-zA-Z]+)|(`[^`]+`)/g; - return text.split(regex).map(token => { + return text.split(regex).map((token, index) => { if (!token) { return ''; } @@ -181,11 +181,11 @@ export class GuideSection extends Component { const onClick = () => { document.getElementById(id).scrollIntoView(); }; - return {id}; + return {id}; } if (token.startsWith('`')) { const code = token.substring(1, token.length - 1); - return {code}; + return {code}; } return token; @@ -196,7 +196,7 @@ export class GuideSection extends Component { const descriptionMarkup = markup(propDescription); let defaultValueMarkup = ''; if (defaultValue) { - defaultValueMarkup = [ {defaultValue.value} ]; + defaultValueMarkup = [ {defaultValue.value} ]; if (defaultValue.comment) { defaultValueMarkup.push(`(${defaultValue.comment})`); } @@ -236,7 +236,7 @@ export class GuideSection extends Component { if (description) { descriptionElement = ( -
+

{description}

diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 3ad5641e4c8..63a41a5cbab 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 { BasicTableExample } + from './views/basic_table/basic_table_example'; + import { TableOfRecordsExample } from './views/table_of_records/table_of_records_example'; @@ -242,6 +245,7 @@ const components = [ StepsExample, TableExample, TableOfRecordsExample, + BasicTableExample, TabsExample, TextExample, TitleExample, diff --git a/src-docs/src/views/basic_table/actions/actions.js b/src-docs/src/views/basic_table/actions/actions.js new file mode 100644 index 00000000000..5467b39b64f --- /dev/null +++ b/src-docs/src/views/basic_table/actions/actions.js @@ -0,0 +1,207 @@ +import React, { Component } from 'react'; +import { formatDate } from '../../../../../src/services/format'; +import { createDataStore } from '../data_store'; +import { EuiBasicTable } from '../../../../../src/components/basic_table'; +import { EuiLink } from '../../../../../src/components/link/link'; +import { EuiHealth } from '../../../../../src/components/health'; +import { EuiButton } from '../../../../../src/components/button/button'; +import { EuiFlexGroup } from '../../../../../src/components/flex/flex_group'; +import { EuiFlexItem } from '../../../../../src/components/flex/flex_item'; +import { EuiSwitch } from '../../../../../src/components/form/switch/switch'; +import { EuiSpacer } from '../../../../../src/components/spacer/spacer'; + +/* +Example user object: + +{ + id: '1', + firstName: 'john', + lastName: 'doe', + github: 'johndoe', + dateOfBirth: Date.now(), + nationality: 'NL', + online: true +} + +Example country object: + +{ + code: 'NL', + name: 'Netherlands', + flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' +} +*/ + +const store = createDataStore(); + +export class Table extends Component { + + constructor(props) { + super(props); + this.state = { + ...this.buildTableState({ page: { index: 0, size: 5 } }), + selection: [], + multiAction: false + }; + } + + buildTableState(criteria) { + const { page } = criteria; + return { + criteria, + data: store.findUsers(page.index, page.size, criteria.sort) + }; + } + + reloadData(selection) { + this.setState(prevState => ({ + ...this.buildTableState(prevState.criteria), + selection + })); + } + + renderDeleteButton() { + const selection = this.state.selection; + if (selection.length === 0) { + return; + } + const onClick = () => { + store.deleteUsers(...selection.map(user => user.id)); + this.reloadData([]); + }; + return ( + + Delete {selection.length} Users + + ); + } + + toggleMultiAction() { + this.setState(prevState => ({ multiAction: !prevState.multiAction })); + } + + deleteUser(user) { + store.deleteUsers(user.id); + this.reloadData([]); + } + + cloneUser(user) { + store.cloneUser(user.id); + this.reloadData([]); + } + + render() { + const { page, sort } = this.state.criteria; + const data = this.state.data; + const deleteButton = this.renderDeleteButton(); + return ( +
+
+ + {deleteButton} + + + + +
+ + ( + {username} + ) + }, + { + field: 'dateOfBirth', + name: 'Date of Birth', + dataType: 'date', + render: (date) => formatDate(date, 'dobLong'), + sortable: true + }, + { + field: 'nationality', + name: 'Nationality', + render: (countryCode) => { + const country = store.getCountry(countryCode); + return `${country.flag} ${country.name}`; + } + }, + { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + }, + sortable: true + }, + { + name: 'Actions', + actions: this.state.multiAction ? [ + { + name: 'Clone', + description: 'Clone this person', + icon: 'copy', + onClick: (user) => this.cloneUser(user) + }, + { + name: 'Delete', + description: 'Delete this person', + icon: 'trash', + color: 'danger', + onClick: (user) => this.deleteUser(user) + } + ] : [ + { + name: 'Delete', + type: 'icon', + description: 'Delete this person', + icon: 'trash', + color: 'danger', + onClick: (person) => this.deleteUser(person) + } + ] + } + ]} + pagination={{ + pageIndex: page.index, + pageSize: page.size, + totalItemCount: data.totalCount, + pageSizeOptions: [3, 5, 8] + }} + sorting={{ sort }} + selection={{ + itemId: 'id', + selectable: (user) => user.online, + selectableMessage: (selectable) => !selectable ? 'User is currently offline' : undefined, + onSelectionChange: (selection) => this.setState({ selection }) + }} + onChange={(criteria) => this.setState(this.buildTableState(criteria))} + /> +
+ ); + } +} diff --git a/src-docs/src/views/basic_table/actions/actions_section.js b/src-docs/src/views/basic_table/actions/actions_section.js new file mode 100644 index 00000000000..8ac8360a99a --- /dev/null +++ b/src-docs/src/views/basic_table/actions/actions_section.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { EuiBasicTable } from '../../../../../src/components'; +import { GuideSectionTypes } from '../../../components'; +import { renderToHtml } from '../../../services'; + +import { Table } from './actions'; +import { EuiCode } from '../../../../../src/components/code'; +const source = require('!!raw-loader!./actions'); +const html = renderToHtml(Table); + +export const section = { + title: 'Actions', + source: [ + { + type: GuideSectionTypes.JS, + code: source, + }, { + type: GuideSectionTypes.HTML, + code: html, + } + ], + text: ( +
+

+ The following example demonstrates "actions" columns. This is a special column + where you can define item level actions on. The most basic action you can define is a button + (maybe be of type `button` or `icon`) and it is also + possible to define a custom action. +

+

+ The implementation enforces some of the UI/UX guidelines: +

+
    +
  • + There can only be a single action tool visible per row. When more than one action is defined, + they will all be collapsed under a single popover "gear" button. +
  • +
  • + The actions are only visible when the user hovers over the row with the mouse. +
  • +
+
+ ), + components: { EuiBasicTable }, + demo: , +}; diff --git a/src-docs/src/views/basic_table/actions/index.js b/src-docs/src/views/basic_table/actions/index.js new file mode 100644 index 00000000000..e53785aad9b --- /dev/null +++ b/src-docs/src/views/basic_table/actions/index.js @@ -0,0 +1 @@ +export { section } from './actions_section'; diff --git a/src-docs/src/views/basic_table/basic/basic.js b/src-docs/src/views/basic_table/basic/basic.js new file mode 100644 index 00000000000..0aee3887afb --- /dev/null +++ b/src-docs/src/views/basic_table/basic/basic.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { formatDate } from '../../../../../src/services/format'; +import { createDataStore } from '../data_store'; +import { EuiBasicTable } from '../../../../../src/components/basic_table'; +import { EuiHealth } from '../../../../../src/components/health'; +import { EuiLink } from '../../../../../src/components/link/link'; + +/* +Example user object: + +{ + id: '1', + firstName: 'john', + lastName: 'doe', + github: 'johndoe', + dateOfBirth: Date.now(), + nationality: 'NL', + online: true +} + +Example country object: + +{ + code: 'NL', + name: 'Netherlands', + flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' +} +*/ + +const store = createDataStore(); + +export const Table = () => ( + index < 10)} + columns={[ + { + field: 'firstName', + name: 'First Name' + }, + { + field: 'lastName', + name: 'Last Name' + }, + { + field: 'github', + name: 'Github', + render: (username) => ( + {username} + ) + }, + { + field: 'dateOfBirth', + name: 'Date of Birth', + dataType: 'date', + render: (date) => formatDate(date, 'dobLong') + }, + { + field: 'nationality', + name: 'Nationality', + render: (countryCode) => { + const country = store.getCountry(countryCode); + return `${country.flag} ${country.name}`; + } + }, + { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + } + } + ]} + /> +); diff --git a/src-docs/src/views/basic_table/basic/basic_section.js b/src-docs/src/views/basic_table/basic/basic_section.js new file mode 100644 index 00000000000..182a188e071 --- /dev/null +++ b/src-docs/src/views/basic_table/basic/basic_section.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { GuideSectionTypes } from '../../../components'; +import { renderToHtml } from '../../../services'; +import { + EuiCode +} from '../../../../../src/components'; +import { propsInfo } from './props_info'; + +import { Table } from './basic'; + +const source = require('!!raw-loader!./basic'); +const html = renderToHtml(Table); + +export const section = { + title: 'Basic', + source: [ + { + type: GuideSectionTypes.JS, + code: source, + }, { + type: GuideSectionTypes.HTML, + code: html, + } + ], + text: ( +
+

+ The following user table example shows the most basic form of the EuiBasicTable. + It is configured with the required items and columns properties. + It shows how each column defines the data it needs to display per item. Some simply display the value + as is (e.g. firstName and lastName fields of the user). Other + columns customize the way the extracted data is displayed. This can be done in two (non-mutual exclusive) + ways: +

+
    +
  • + Provide a hit about the type of data (e.g. the "Date of Birth" column indicates that the + data it shows is of type date). Providing data type hints will cause built-in + defaults to be adjusted (e.g. numbers will by defaults be right aligned). +
  • +
  • + Provide a render function that given the value (and the item as a second argument) + returns the react node that should be displayed as the content of the cell. This can be as simple as + formatting values (e.g. "Date of Birth" column) to returning a more complex react components + (e.g. the "Online", "Github" and "Nationality" columns). +
  • +
+
+ ), + props: propsInfo, + demo:
, +}; diff --git a/src-docs/src/views/basic_table/basic/index.js b/src-docs/src/views/basic_table/basic/index.js new file mode 100644 index 00000000000..6033b27eb1c --- /dev/null +++ b/src-docs/src/views/basic_table/basic/index.js @@ -0,0 +1 @@ +export { section } from './basic_section'; diff --git a/src-docs/src/views/basic_table/basic/props_info.js b/src-docs/src/views/basic_table/basic/props_info.js new file mode 100644 index 00000000000..b2889297828 --- /dev/null +++ b/src-docs/src/views/basic_table/basic/props_info.js @@ -0,0 +1,328 @@ +export const propsInfo = { + + EuiBasicTable: { + __docgenInfo: { + props: { + items: { + description: 'A list of objects to who in the table - an item per row', + required: true, + type: { name: 'object[]' } + }, + columns: { + description: 'Defines the table columns', + required: true, + type: { name: '(#FieldDataColumn | #ComputedColumn | #ActionsColumn)[]' } + }, + pagination: { + description: 'Configures pagination', + required: false, + type: { name: '#Pagination' } + }, + sorting: { + description: 'Configures sorting', + required: false, + type: { name: '#Sorting' } + }, + selection: { + description: 'Configures selection', + required: false, + type: { name: '#Selection' } + }, + onChange: { + description: 'Called whenever pagination or sorting changes (this property is required when either' + + 'pagination or sorting is configured', + required: false, + type: { name: '(criteria: #Criteria) => void' } + } + } + } + }, + + Pagination: { + __docgenInfo: { + _euiObjectType: 'type', + props: { + pageIndex: { + description: 'The current page (zero-based) index', + required: true, + type: { name: 'number' } + }, + pageSize: { + description: 'The maximum number if items that can be shown in a single page', + required: true, + type: { name: 'number' } + }, + totalItemCount: { + description: 'The total number of items the page is "sliced" of', + required: true, + type: { name: 'number' } + }, + pageSizeOptions: { + description: 'Configures the page size dropdown options', + required: false, + defaultValue: { value: '[5, 10, 20]' }, + type: { name: 'number[]' } + } + } + } + }, + + Sorting: { + __docgenInfo: { + _euiObjectType: 'type', + props: { + sort: { + description: 'Indicates the property/field to sort on', + required: false, + type: { name: '{ field: string, direction: "asc" | "desc" }' } + } + } + } + }, + + Selection: { + __docgenInfo: { + _euiObjectType: 'type', + props: { + itemId: { + description: 'describes how to extract a unique ID from each item', + required: true, + type: { name: 'string | (item) => string' } + }, + onSelectionChanged: { + description: 'A callback that will be called whenever the item selection changes', + required: false, + type: { name: '(selectedItems) => void' } + }, + selectable: { + description: 'A callback that is called per item to indicate whether it is selectable', + required: false, + type: { name: '(item) => boolean' } + }, + selectableMessage: { + description: 'A callback that is called per item to retrieve a message for its selectable state.' + + 'We display these messages as a tooltip on an unselectable checkbox', + required: false, + type: { name: '(selectable, item) => string' } + } + } + } + }, + + Criteria: { + __docgenInfo: { + _euiObjectType: 'type', + props: { + page: { + description: 'If the shown items represents a page (slice) into a bigger set, this describes this page', + required: false, + type: { name: '{ index: number, size: number }' } + }, + sort: { + description: 'If the shown items are sorted, this describes the sort criteria', + required: false, + type: { name: '{ field: string, direction: "asc" | "desc" }' } + } + } + } + }, + + FieldDataColumn: { + __docgenInfo: { + _euiObjectType: 'type', + description: `Describes a column that displays a value derived of one of the item's fields`, + props: { + field: { + description: 'A field of the item (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, item) => PropTypes.node' } + } + } + } + }, + + ComputedColumn: { + __docgenInfo: { + _euiObjectType: 'type', + description: `Describes a column for computed values`, + props: { + render: { + description: `A function that computes the value for each item and renders it`, + required: true, + type: { name: '(item) => 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 item`, + required: true, + type: { name: '(#DefaultItemAction | #CustomItemAction)[]' } + }, + 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..)' } + } + } + } + }, + + DefaultItemAction: { + __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: '(item) => 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: '(item) => boolean' } + }, + enabled: { + description: 'A callback function that determines whether the action is enabled', + required: false, + defaultValue: { value: '() => true' }, + type: { name: '(item) => 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)' } + } + } + } + }, + + CustomItemAction: { + __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: '(item, enabled) => PropTypes.node' } + }, + available: { + description: 'A callback that defines whether the action is available', + required: false, + type: { name: '(item) => boolean' } + }, + enabled: { + description: 'A callback that defines whether the action is enabled', + required: false, + type: { name: '(item) => boolean' } + } + } + } + }, +}; diff --git a/src-docs/src/views/basic_table/basic_table_example.js b/src-docs/src/views/basic_table/basic_table_example.js new file mode 100644 index 00000000000..6a137c4b0c2 --- /dev/null +++ b/src-docs/src/views/basic_table/basic_table_example.js @@ -0,0 +1,69 @@ +import React from 'react'; + +import { + EuiCode, + EuiText, + EuiTitle, + EuiCallOut, + EuiSpacer +} from '../../../../src/components'; + +import { section as basicSection } from './basic'; +import { section as paginatedSection } from './paginated'; +import { section as sortingSection } from './sorting'; +import { section as selectionSection } from './selection'; +import { section as actionsSection } from './actions'; + +export const BasicTableExample = { + title: 'BasicTable', + intro: ( + + +

EuiBasicTable

+
+

+ EuiBasicTable is a high level component that aims to simplify and unify the way + one creates a tables. +

+ + 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). + + +

+ The EuiBasicTable accepts two required properties: +

+
    +
  • + items - The table data - an array of objects that should be displayed in the table - + one item per row. The exact item data that will be rendered in each cell in these rows is determined + but the `columns` property. +
  • +
  • + columns - Defines what columns the table has and how to extract the item data to display + in each column for each row. +
  • +
+

+ Other optional properties are tightly aligned to the different supported features + - pagination, + sorting and selection +

+ +
+ ), + sections: [ + basicSection, + paginatedSection, + sortingSection, + selectionSection, + actionsSection + ], +}; diff --git a/src-docs/src/views/basic_table/data_store.js b/src-docs/src/views/basic_table/data_store.js new file mode 100644 index 00000000000..8dfdcfc6903 --- /dev/null +++ b/src-docs/src/views/basic_table/data_store.js @@ -0,0 +1,97 @@ +import { Comparators } from '../../../../src/services/sort'; +import { Random } from '../../../../src/services/random'; +import { times } from '../../../../src/services/utils'; + +const random = new Random(); + +const createCountries = () => [ + { code: 'NL', name: 'Netherlands', flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' }, + { code: 'CZ', name: 'Czech Republic', flag: '๐Ÿ‡จ๐Ÿ‡ฟ' }, + { code: 'ZA', name: 'South Africa', flag: '๐Ÿ‡ฟ๐Ÿ‡ฆ' }, + { code: 'US', name: 'United States', flag: '๐Ÿ‡บ๐Ÿ‡ฒ' }, + { code: 'AU', name: 'Australia', flag: '๐Ÿ‡ฆ๐Ÿ‡บ' }, + { code: 'IL', name: 'Israel', flag: '๐Ÿ‡ฎ๐Ÿ‡ฑ' }, + { code: 'NO', name: 'Norway', flag: '๐Ÿ‡ณ๐Ÿ‡ด' }, + { code: 'IT', name: 'Italy', flag: '๐Ÿ‡ฎ๐Ÿ‡น' }, + { code: 'CA', name: 'Canada', flag: '๐Ÿ‡จ๐Ÿ‡ฆ' }, + { code: 'CG', name: 'Congo', flag: '๐Ÿ‡จ๐Ÿ‡ฌ' }, + { code: 'CL', name: 'Chile', flag: '๐Ÿ‡จ๐Ÿ‡ฑ' }, + { code: 'FJ', name: 'Fiji', flag: '๐Ÿ‡ซ๐Ÿ‡ฏ' }, + { code: 'GB', name: 'United Kingdom', flag: '๐Ÿ‡ฌ๐Ÿ‡ง' }, + { code: 'GR', name: 'Greece', flag: '๐Ÿ‡ฌ๐Ÿ‡ท' }, + { code: 'HT', name: 'Haiti', flag: '๐Ÿ‡ญ๐Ÿ‡น' }, + { code: 'LB', name: 'Lebanon', flag: '๐Ÿ‡ฑ๐Ÿ‡ง' }, + { code: 'MM', name: 'Myanmar', flag: '๐Ÿ‡ฒ๐Ÿ‡ฒ' }, + { code: 'MX', name: 'Mexico', flag: '๐Ÿ‡ฒ๐Ÿ‡ฝ' }, + { code: 'NG', name: 'Nigeria', flag: '๐Ÿ‡ณ๐Ÿ‡ฌ' }, + { code: 'SG', name: 'Singapore', flag: '๐Ÿ‡ธ๐Ÿ‡ฌ' }, + { code: 'SO', name: 'Somalia', flag: '๐Ÿ‡ธ๐Ÿ‡ด' }, + { code: 'TN', name: 'Tunisia', flag: '๐Ÿ‡น๐Ÿ‡ณ' }, + { code: 'VE', name: 'Venezuela', flag: '๐Ÿ‡ป๐Ÿ‡ช' }, + { code: 'ZM', name: 'Zambia', flag: '๐Ÿ‡ฟ๐Ÿ‡ฒ' }, +]; + +const createUsers = (countries) => { + return 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'), + github: random.oneOf('martijnvg', 'elissaw', 'clintongormley', 'imotov', 'karmi', 'drewr', 'HonzaKral', 'rashidkpc', 'jordansissel'), + dateOfBirth: random.date({ min: new Date(1971, 0, 0), max: new Date(1990, 0, 0) }), + nationality: random.oneOf(...countries.map(country => country.code)), + online: random.boolean() + }; + }); +}; + +export const createDataStore = () => { + const countries = createCountries(); + const users = createUsers(countries); + return { + countries, + users, + + findUsers: (pageIndex, pageSize, sort) => { + let list = users; + if (sort) { + list = users.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, + totalCount: list.length + }; + }, + + deleteUsers: (...ids) => { + ids.forEach(id => { + const index = users.findIndex(user => user.id === id); + if (index >= 0) { + users.splice(index, 1); + } + }); + }, + + cloneUser: (id) => { + const index = users.findIndex(user => user.id === id); + if (index >= 0) { + const user = users[index]; + users.splice(index, 0, { ...user, id: users.length }); + } + }, + + getCountry: (code) => countries.find(country => country.code === code) + }; +}; diff --git a/src-docs/src/views/basic_table/paginated/index.js b/src-docs/src/views/basic_table/paginated/index.js new file mode 100644 index 00000000000..d192d685ecb --- /dev/null +++ b/src-docs/src/views/basic_table/paginated/index.js @@ -0,0 +1 @@ +export { section } from './paginated_section'; diff --git a/src-docs/src/views/basic_table/paginated/paginated.js b/src-docs/src/views/basic_table/paginated/paginated.js new file mode 100644 index 00000000000..b2c4cf624a0 --- /dev/null +++ b/src-docs/src/views/basic_table/paginated/paginated.js @@ -0,0 +1,111 @@ +import React, { + Component +} from 'react'; +import { formatDate } from '../../../../../src/services/format'; +import { createDataStore } from '../data_store'; +import { EuiBasicTable } from '../../../../../src/components/basic_table'; +import { EuiLink } from '../../../../../src/components/link/link'; +import { EuiHealth } from '../../../../../src/components/health'; + +/* +Example user object: + +{ + id: '1', + firstName: 'john', + lastName: 'doe', + github: 'johndoe', + dateOfBirth: Date.now(), + nationality: 'NL', + online: true +} + +Example country object: + +{ + code: 'NL', + name: 'Netherlands', + flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' +} +*/ + +const store = createDataStore(); + +export class Table extends Component { + + constructor(props) { + super(props); + this.state = this.buildState({ + page: { + index: 0, + size: 5 + } + }); + } + + buildState(criteria) { + const { page } = criteria; + return { + criteria, + data: store.findUsers(page.index, page.size) + }; + } + + render() { + const page = this.state.criteria.page; + const data = this.state.data; + return ( + ( + {username} + ) + }, + { + field: 'dateOfBirth', + name: 'Date of Birth', + dataType: 'date', + render: (date) => formatDate(date, 'dobLong') + }, + { + field: 'nationality', + name: 'Nationality', + render: (countryCode) => { + const country = store.getCountry(countryCode); + return `${country.flag} ${country.name}`; + } + }, + { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + } + } + ]} + pagination={{ + pageIndex: page.index, + pageSize: page.size, + totalItemCount: data.totalCount, + pageSizeOptions: [3, 5, 8] + }} + onChange={(criteria) => this.setState(this.buildState(criteria))} + /> + ); + } +} diff --git a/src-docs/src/views/basic_table/paginated/paginated_section.js b/src-docs/src/views/basic_table/paginated/paginated_section.js new file mode 100644 index 00000000000..5b74476e55d --- /dev/null +++ b/src-docs/src/views/basic_table/paginated/paginated_section.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { + EuiBasicTable, + EuiCode +} from '../../../../../src/components'; +import { GuideSectionTypes } from '../../../components'; +import { renderToHtml } from '../../../services'; + +import { Table } from './paginated'; +const source = require('!!raw-loader!./paginated'); +const html = renderToHtml(Table); + +export const section = { + title: 'Pagination', + source: [ + { + type: GuideSectionTypes.JS, + code: source, + }, { + type: GuideSectionTypes.HTML, + code: html, + } + ], + text: ( +

+ The following example shows how to configure pagination via the pagination + property. +

+ ), + components: { EuiBasicTable }, + demo:
, +}; diff --git a/src-docs/src/views/basic_table/selection/index.js b/src-docs/src/views/basic_table/selection/index.js new file mode 100644 index 00000000000..ea0604dd22f --- /dev/null +++ b/src-docs/src/views/basic_table/selection/index.js @@ -0,0 +1 @@ +export { section } from './selection_section'; diff --git a/src-docs/src/views/basic_table/selection/selection.js b/src-docs/src/views/basic_table/selection/selection.js new file mode 100644 index 00000000000..b4450acfeff --- /dev/null +++ b/src-docs/src/views/basic_table/selection/selection.js @@ -0,0 +1,147 @@ +import React, { + Component +} from 'react'; +import { formatDate } from '../../../../../src/services/format'; +import { createDataStore } from '../data_store'; +import { EuiBasicTable } from '../../../../../src/components/basic_table'; +import { EuiLink } from '../../../../../src/components/link/link'; +import { EuiHealth } from '../../../../../src/components/health'; +import { EuiButton } from '../../../../../src/components/button/button'; + +/* +Example user object: + +{ + id: '1', + firstName: 'john', + lastName: 'doe', + github: 'johndoe', + dateOfBirth: Date.now(), + nationality: 'NL', + online: true +} + +Example country object: + +{ + code: 'NL', + name: 'Netherlands', + flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' +} +*/ + +const store = createDataStore(); + +export class Table extends Component { + + constructor(props) { + super(props); + this.state = { + ...this.buildState({ page: { index: 0, size: 5 } }), + selection: [] + }; + } + + buildState(criteria) { + const { page } = criteria; + return { + criteria, + data: store.findUsers(page.index, page.size, criteria.sort) + }; + } + + renderDeleteButton() { + const selection = this.state.selection; + if (selection.length === 0) { + return; + } + const onClick = () => { + store.deleteUsers(selection.map(user => user.id)); + this.setState(prevState => ({ + ...this.buildState(prevState.criteria), + selection: [] + })); + }; + return ( + + Delete {selection.length} Users + + ); + } + + render() { + const { page, sort } = this.state.criteria; + const data = this.state.data; + const deleteButton = this.renderDeleteButton(); + return ( +
+ {deleteButton} + ( + {username} + ) + }, + { + field: 'dateOfBirth', + name: 'Date of Birth', + dataType: 'date', + render: (date) => formatDate(date, 'dobLong'), + sortable: true + }, + { + field: 'nationality', + name: 'Nationality', + render: (countryCode) => { + const country = store.getCountry(countryCode); + return `${country.flag} ${country.name}`; + } + }, + { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + }, + sortable: true + } + ]} + pagination={{ + pageIndex: page.index, + pageSize: page.size, + totalItemCount: data.totalCount, + pageSizeOptions: [3, 5, 8] + }} + sorting={{ sort }} + selection={{ + itemId: 'id', + selectable: (user) => user.online, + selectableMessage: (selectable) => !selectable ? 'User is currently offline' : undefined, + onSelectionChange: (selection) => this.setState({ selection }) + }} + onChange={(criteria) => this.setState(this.buildState(criteria))} + /> +
+ ); + } +} diff --git a/src-docs/src/views/basic_table/selection/selection_section.js b/src-docs/src/views/basic_table/selection/selection_section.js new file mode 100644 index 00000000000..1f28d66866b --- /dev/null +++ b/src-docs/src/views/basic_table/selection/selection_section.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { + EuiBasicTable, + EuiCode +} from '../../../../../src/components'; +import { GuideSectionTypes } from '../../../components'; +import { renderToHtml } from '../../../services'; + +import { Table } from './selection'; +const source = require('!!raw-loader!./selection'); +const html = renderToHtml(Table); + +export const section = { + title: 'Selection', + source: [ + { + type: GuideSectionTypes.JS, + code: source, + }, { + type: GuideSectionTypes.HTML, + code: html, + } + ], + text: ( +

+ The following example shows how to configure selection via the selection + property. +

+ ), + components: { EuiBasicTable }, + demo:
, +}; diff --git a/src-docs/src/views/basic_table/sorting/index.js b/src-docs/src/views/basic_table/sorting/index.js new file mode 100644 index 00000000000..f4e1274b35f --- /dev/null +++ b/src-docs/src/views/basic_table/sorting/index.js @@ -0,0 +1 @@ +export { section } from './sorting_section'; diff --git a/src-docs/src/views/basic_table/sorting/sorting.js b/src-docs/src/views/basic_table/sorting/sorting.js new file mode 100644 index 00000000000..1404d194044 --- /dev/null +++ b/src-docs/src/views/basic_table/sorting/sorting.js @@ -0,0 +1,115 @@ +import React, { + Component +} from 'react'; +import { formatDate } from '../../../../../src/services/format'; +import { createDataStore } from '../data_store'; +import { EuiBasicTable } from '../../../../../src/components/basic_table'; +import { EuiLink } from '../../../../../src/components/link/link'; +import { EuiHealth } from '../../../../../src/components/health'; + +/* +Example user object: + +{ + id: '1', + firstName: 'john', + lastName: 'doe', + github: 'johndoe', + dateOfBirth: Date.now(), + nationality: 'NL', + online: true +} + +Example country object: + +{ + code: 'NL', + name: 'Netherlands', + flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' +} +*/ + +const store = createDataStore(); + +export class Table extends Component { + + constructor(props) { + super(props); + this.state = this.buildState({ + page: { + index: 0, + size: 5 + } + }); + } + + buildState(criteria) { + const { page } = criteria; + return { + criteria, + data: store.findUsers(page.index, page.size, criteria.sort) + }; + } + + render() { + const { page, sort } = this.state.criteria; + const data = this.state.data; + return ( + ( + {username} + ) + }, + { + field: 'dateOfBirth', + name: 'Date of Birth', + dataType: 'date', + render: (date) => formatDate(date, 'dobLong'), + sortable: true + }, + { + field: 'nationality', + name: 'Nationality', + render: (countryCode) => { + const country = store.getCountry(countryCode); + return `${country.flag} ${country.name}`; + } + }, + { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + }, + sortable: true + } + ]} + pagination={{ + pageIndex: page.index, + pageSize: page.size, + totalItemCount: data.totalCount, + pageSizeOptions: [3, 5, 8] + }} + sorting={{ sort }} + onChange={(criteria) => this.setState(this.buildState(criteria))} + /> + ); + } +} diff --git a/src-docs/src/views/basic_table/sorting/sorting_section.js b/src-docs/src/views/basic_table/sorting/sorting_section.js new file mode 100644 index 00000000000..26a2783ac74 --- /dev/null +++ b/src-docs/src/views/basic_table/sorting/sorting_section.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { + EuiBasicTable, + EuiCode +} from '../../../../../src/components'; +import { GuideSectionTypes } from '../../../components'; +import { renderToHtml } from '../../../services'; + +import { Table } from './sorting'; +const source = require('!!raw-loader!./sorting'); +const html = renderToHtml(Table); + +export const section = { + title: 'Sorting', + source: [ + { + type: GuideSectionTypes.JS, + code: source, + }, { + type: GuideSectionTypes.HTML, + code: html, + } + ], + text: ( +

+ The following example shows how to configure column sorting via the sorting + property and flagging the sortable columns as sortable: true +

+ ), + components: { EuiBasicTable }, + demo:
, +}; diff --git a/src/components/basic_table/__snapshots__/basic_table.test.js.snap b/src/components/basic_table/__snapshots__/basic_table.test.js.snap new file mode 100644 index 00000000000..4601914801f --- /dev/null +++ b/src/components/basic_table/__snapshots__/basic_table.test.js.snap @@ -0,0 +1,1338 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiBasicTable basic - empty 1`] = ` +
+ + + + Name + + + + +
+`; + +exports[`EuiBasicTable basic - with records 1`] = ` +
+ + + + Name + + + + + + name1 + + + + + name2 + + + + + name3 + + + + +
+`; + +exports[`EuiBasicTable with pagination - 2nd page 1`] = ` +
+ + + + Name + + + + + + name1 + + + + + name2 + + + + + +
+`; + +exports[`EuiBasicTable with pagination 1`] = ` +
+ + + + Name + + + + + + name1 + + + + + name2 + + + + + name3 + + + + + +
+`; + +exports[`EuiBasicTable with pagination and selection 1`] = ` +
+ + + + + + + Name + + + + + + + + + name1 + + + + + + + + name2 + + + + + + + + name3 + + + + + +
+`; + +exports[`EuiBasicTable with pagination, selection and sorting 1`] = ` +
+ + + + + + + Name + + + + + + + + + name1 + + + + + + + + name2 + + + + + + + + name3 + + + + + +
+`; + +exports[`EuiBasicTable with pagination, selection, sorting and a single record action 1`] = ` +
+ + + + + + + Name + + + Actions + + + + + + + + + name1 + + + + + + + + + + + name2 + + + + + + + + + + + name3 + + + + + + + + +
+`; + +exports[`EuiBasicTable with pagination, selection, sorting and column dataType 1`] = ` +
+ + + + + + + Count + + + + + + + + + 1 + + + + + + + + 2 + + + + + + + + 3 + + + + + +
+`; + +exports[`EuiBasicTable with pagination, selection, sorting and column renderer 1`] = ` +
+ + + + + + + Name + + + + + + + + + NAME1 + + + + + + + + NAME2 + + + + + + + + NAME3 + + + + + +
+`; + +exports[`EuiBasicTable with pagination, selection, sorting and multiple record actions 1`] = ` +
+ + + + + + + Name + + + Actions + + + + + + + + + name1 + + + + + + + + + + + name2 + + + + + + + + + + + name3 + + + + + + + + +
+`; + +exports[`EuiBasicTable with pagination, selection, sorting, column renderer and column dataType 1`] = ` +
+ + + + + + + Count + + + + + + + + + x + + + + + + + + xx + + + + + + + + xxx + + + + + +
+`; + +exports[`EuiBasicTable with sorting 1`] = ` +
+ + + + Name + + + + + + name1 + + + + + name2 + + + + + name3 + + + + +
+`; diff --git a/src/components/basic_table/__snapshots__/collapsed_item_actions.test.js.snap b/src/components/basic_table/__snapshots__/collapsed_item_actions.test.js.snap new file mode 100644 index 00000000000..70cf38155bc --- /dev/null +++ b/src/components/basic_table/__snapshots__/collapsed_item_actions.test.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollapsedItemActions render 1`] = ` + + } + closePopover={[Function]} + id="id-actions" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + popoverRef={[Function]} +> + + default1 + , + , + ] + } + /> + +`; diff --git a/src/components/basic_table/__snapshots__/custom_item_action.test.js.snap b/src/components/basic_table/__snapshots__/custom_item_action.test.js.snap new file mode 100644 index 00000000000..322fd9657dc --- /dev/null +++ b/src/components/basic_table/__snapshots__/custom_item_action.test.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomItemAction render 1`] = ` +
+`; diff --git a/src/components/basic_table/__snapshots__/default_item_action.test.js.snap b/src/components/basic_table/__snapshots__/default_item_action.test.js.snap new file mode 100644 index 00000000000..d9fb8db8206 --- /dev/null +++ b/src/components/basic_table/__snapshots__/default_item_action.test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultItemAction render - button 1`] = ` + + action1 + +`; + +exports[`DefaultItemAction render - icon 1`] = ` + +`; diff --git a/src/components/basic_table/__snapshots__/expanded_item_actions.test.js.snap b/src/components/basic_table/__snapshots__/expanded_item_actions.test.js.snap new file mode 100644 index 00000000000..276e44fb751 --- /dev/null +++ b/src/components/basic_table/__snapshots__/expanded_item_actions.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExpandedItemActions render 1`] = ` +Array [ + , + , +] +`; diff --git a/src/components/basic_table/__snapshots__/pagination_bar.test.js.snap b/src/components/basic_table/__snapshots__/pagination_bar.test.js.snap new file mode 100644 index 00000000000..7a8e8d39904 --- /dev/null +++ b/src/components/basic_table/__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/basic_table/_basic_table.scss b/src/components/basic_table/_basic_table.scss new file mode 100644 index 00000000000..b779361f8f4 --- /dev/null +++ b/src/components/basic_table/_basic_table.scss @@ -0,0 +1,3 @@ +.euiBasicTable { + +} diff --git a/src/components/basic_table/_index.scss b/src/components/basic_table/_index.scss new file mode 100644 index 00000000000..ca3dba62103 --- /dev/null +++ b/src/components/basic_table/_index.scss @@ -0,0 +1 @@ +@import 'basic_table'; diff --git a/src/components/basic_table/basic_table.behavior.test.js b/src/components/basic_table/basic_table.behavior.test.js new file mode 100644 index 00000000000..3915c430176 --- /dev/null +++ b/src/components/basic_table/basic_table.behavior.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { findTestSubject } from '../../test'; + +import { EuiBasicTable } from './basic_table'; + +describe('EuiBasicTable', () => { + describe('behavior', () => { + describe('selected items', () => { + let props; + let component; + + beforeEach(() => { + props = { + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description' + } + ], + selection: { + itemId: 'id', + onSelectionChanged: () => {} + }, + onChange: () => {} + }; + + component = mount(); + }); + + test('check the select all checkbox when all are selected', () => { + findTestSubject(component, 'checkboxSelectRow-1').simulate('change', { target: { checked: true } }); + findTestSubject(component, 'checkboxSelectRow-2').simulate('change', { target: { checked: true } }); + expect(findTestSubject(component, 'checkboxSelectAll').prop('checked')).toBe(true); + }); + + test('uncheck the select all checkbox when some are selected', () => { + findTestSubject(component, 'checkboxSelectRow-1').simulate('change', { target: { checked: true } }); + expect(findTestSubject(component, 'checkboxSelectAll').prop('checked')).toBe(false); + }); + + test('are all selected when the select all checkbox is checked', () => { + findTestSubject(component, 'checkboxSelectAll').simulate('change', { target: { checked: true } }); + expect(findTestSubject(component, 'checkboxSelectRow-1').prop('checked')).toBe(true); + expect(findTestSubject(component, 'checkboxSelectRow-2').prop('checked')).toBe(true); + }); + + test('select all checkbox becomes unchecked when selected items are deleted', () => { + findTestSubject(component, 'checkboxSelectAll').simulate('change', { target: { checked: true } }); + props.items = []; + component.setProps(props); + expect(findTestSubject(component, 'checkboxSelectAll').prop('checked')).toBe(false); + }); + }); + }); +}); diff --git a/src/components/basic_table/basic_table.js b/src/components/basic_table/basic_table.js new file mode 100644 index 00000000000..b5a356908d6 --- /dev/null +++ b/src/components/basic_table/basic_table.js @@ -0,0 +1,597 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { + formatAuto, formatBoolean, formatDate, formatNumber, formatText, LEFT_ALIGNMENT, PropertySortType, + RIGHT_ALIGNMENT, SortDirection +} from '../../services'; +import { isFunction } from '../../services/predicate'; +import { get } from '../../services/objects'; +import { EuiTable } from '../table/table'; +import { EuiTableHeaderCellCheckbox } from '../table/table_header_cell_checkbox'; +import { EuiCheckbox } from '../form/checkbox/checkbox'; +import { EuiTableHeaderCell } from '../table/table_header_cell'; +import { EuiTableHeader } from '../table/table_header'; +import { EuiTableBody } from '../table/table_body'; +import { EuiTableRowCellCheckbox } from '../table/table_row_cell_checkbox'; +import { COLORS as BUTTON_ICON_COLORS } from '../button/button_icon/button_icon'; +import { ICON_TYPES } from '../icon'; +import { CollapsedItemActions } from './collapsed_item_actions'; +import { ExpandedItemActions } from './expanded_item_actions'; +import { EuiTableRowCell } from '../table/table_row_cell'; +import { EuiTableRow } from '../table/table_row'; +import { PaginationBar, PaginationType } from './pagination_bar'; + +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 DefaultItemActionType = PropTypes.shape({ + type: PropTypes.oneOf([ 'icon', 'button' ]), // default is 'button' + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, // (item) => void, + available: PropTypes.func, // (item) => boolean; + enabled: PropTypes.func, // (item) => boolean; + icon: PropTypes.oneOfType([ // required when type is 'icon' + PropTypes.oneOf(ICON_TYPES), + PropTypes.func // (item) => oneOf(ICON_TYPES) + ]), + color: PropTypes.oneOfType([ + PropTypes.oneOf(BUTTON_ICON_COLORS), + PropTypes.func // (item) => oneOf(ICON_BUTTON_COLORS) + ]) +}); + +const CustomItemActionType = PropTypes.shape({ + render: PropTypes.func.isRequired, // (item, enabled) => PropTypes.node; + available: PropTypes.func, // (item) => boolean; + enabled: PropTypes.func // (item) => boolean; +}); + +const SupportedItemActionType = PropTypes.oneOfType([ + DefaultItemActionType, + CustomItemActionType +]); + +const ActionsColumnType = PropTypes.shape({ + actions: PropTypes.arrayOf(SupportedItemActionType).isRequired, + name: PropTypes.string, + description: PropTypes.string, + width: PropTypes.string +}); + +export 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) +}); + +export const ComputedColumnType = PropTypes.shape({ + render: PropTypes.func.isRequired, // (record) => PropTypes.node + name: PropTypes.string, + description: PropTypes.string, + width: PropTypes.string, + truncateText: PropTypes.bool +}); + +export const ColumnType = PropTypes.oneOfType([FieldDataColumnType, ComputedColumnType, ActionsColumnType]); + +const ItemIdType = PropTypes.oneOfType([ + PropTypes.string, // the name of the item id property + PropTypes.func // (item) => string +]); + +const SelectionType = PropTypes.shape({ + itemId: ItemIdType.isRequired, + onSelectionChange: PropTypes.func, // (selection: Record[]) => void;, + selectable: PropTypes.func, // (item) => boolean; + selectableMessage: PropTypes.func // (selectable, item) => boolean; +}); + +const SortingType = PropTypes.shape({ + sort: PropertySortType +}); + +const BasicTablePropTypes = { + items: PropTypes.array.isRequired, + columns: PropTypes.arrayOf(ColumnType).isRequired, + pagination: PaginationType, + sorting: SortingType, + selection: SelectionType, + onChange: PropTypes.func, + className: PropTypes.string +}; + +export class EuiBasicTable extends Component { + + static propTypes = BasicTablePropTypes; + + constructor(props) { + super(props); + this.state = { + hoverRow: null, + selection: [] + }; + } + + static buildCriteria(props) { + const criteria = {}; + if (props.pagination) { + criteria.page = { + index: props.pagination.pageIndex, + size: props.pagination.pageSize + }; + } + if (props.sorting) { + criteria.sort = props.sorting.sort; + } + return criteria; + } + + itemId(item) { + const { selection } = this.props; + if (selection) { + if (isFunction(selection.itemId)) { + return selection.itemId(item); + } + return item[selection.itemId]; + } + } + + changeSelection(selection) { + if (!this.props.selection) { + return; + } + this.setState({ selection }); + if (this.props.selection.onSelectionChange) { + this.props.selection.onSelectionChange(selection); + } + } + + clearSelection() { + this.changeSelection([]); + } + + onPageSizeChange(size) { + this.clearSelection(); + const currentCriteria = EuiBasicTable.buildCriteria(this.props); + const criteria = { + ...currentCriteria, + page: { + index: 0, // when page size changes, we take the user back to the first page + size + } + }; + this.props.onChange(criteria); + } + + onPageChange(index) { + this.clearSelection(); + const currentCriteria = EuiBasicTable.buildCriteria(this.props); + const criteria = { + ...currentCriteria, + page: { + ...currentCriteria.page, + index + } + }; + this.props.onChange(criteria); + } + + onColumnSortChange(column) { + this.clearSelection(); + const currentCriteria = EuiBasicTable.buildCriteria(this.props); + 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.onChange(criteria); + } + + onRowHover(row) { + this.setState({ hoverRow: row }); + } + + clearRowHover() { + this.setState({ hoverRow: null }); + } + + componentWillReceiveProps(nextProps) { + // Don't call changeSelection here or else we can get into an infinite loop: + // changeSelection calls props.onSelectionChanged on owner -> + // owner may react by changing props -> + // we receive new props, calling componentWillReceiveProps -> + // and we're in an infinite loop + if (!this.props.selection) { + return; + } + + if (!nextProps.selection) { + this.setState({ selection: [] }); + return; + } + + this.setState(prevState => { + const selection = prevState.selection.filter(selectedItem => ( + nextProps.items.findIndex(item => this.itemId(item) === this.itemId(selectedItem)) !== -1 + )); + return { selection }; + }); + } + + render() { + const { className } = this.props; + + const classes = classNames( + 'euiRecordsTable', + className + ); + + const table = this.renderTable(); + const paginationBar = this.renderPaginationBar(); + + return ( +
+ {table} + {paginationBar} +
+ ); + } + + renderTable() { + const head = this.renderTableHead(); + const body = this.renderTableBody(); + return {head}{body}; + } + + renderTableHead() { + + const { items, columns, selection } = this.props; + + const headers = []; + + if (selection) { + const selectableItems = items.filter(item => ( + !selection.selectable || selection.selectable(item) + )); + + const checked = this.state.selection && + selectableItems.length > 0 && + this.state.selection.length === selectableItems.length; + + const disabled = selectableItems.length === 0; + + const onChange = (event) => { + if (event.target.checked) { + this.changeSelection(selectableItems); + } else { + this.changeSelection([]); + } + }; + + headers.push( + + + + ); + } + + columns.forEach((column, index) => { + // actions column + if (column.actions) { + headers.push( + + {column.name} + + ); + return; + } + + const align = this.resolveColumnAlign(column); + + // computed column + if (!column.field) { + headers.push( + + {column.name} + + ); + return; + } + + // field data column + const sortDirection = this.resolveColumnSortDirection(column); + const onSort = this.resolveColumnOnSort(column); + const isSorted = !!sortDirection; + const isSortAscending = SortDirection.isAsc(sortDirection); + headers.push( + + {column.name} + + ); + }); + + return {headers}; + } + + renderTableBody() { + const { items } = this.props; + const rows = items.map((item, index) => { + return this.renderItemRow(item, index); + }); + return {rows}; + } + + renderItemRow(item, rowIndex) { + const { columns, selection } = this.props; + + const cells = []; + + const itemId = selection ? this.itemId(item) : rowIndex; + const selected = !selection ? false : this.state.selection && !!this.state.selection.find(selectedRecord => ( + this.itemId(selectedRecord) === itemId + )); + + if (selection) { + cells.push(this.renderItemSelectionCell(itemId, item, selected)); + } + + columns.forEach((column, columnIndex) => { + if (column.actions) { + cells.push(this.renderItemActionsCell(itemId, item, column, columnIndex, rowIndex)); + } else if (column.field) { + cells.push(this.renderItemFieldDataCell(itemId, item, column, columnIndex)); + } else { + cells.push(this.renderItemComputedCell(itemId, item, column, columnIndex)); + } + }); + + const onMouseOver = () => this.onRowHover(rowIndex); + const onMouseOut = () => this.clearRowHover(); + return ( + + {cells} + + ); + } + + renderItemSelectionCell(itemId, item, selected) { + const { selection } = this.props; + const key = `_selection_column_${itemId}`; + const checked = selected; + const disabled = selection.selectable && !selection.selectable(item); + const title = selection.selectableMessage && selection.selectableMessage(!disabled, item); + const onChange = (event) => { + if (event.target.checked) { + this.changeSelection([...this.state.selection, item]); + } else { + this.changeSelection(this.state.selection.reduce((selection, selectedItem) => { + if (this.itemId(selectedItem) !== itemId) { + selection.push(selectedItem); + } + return selection; + }, [])); + } + }; + return ( + + + + ); + } + + renderItemActionsCell(itemId, item, column, columnIndex, rowIndex) { + const visible = this.state.hoverRow === rowIndex; + + const actionEnabled = (action) => + this.state.selection.length === 0 && (!action.enabled || action.enabled(item)); + + let actualActions = column.actions; + if (column.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: (item) => { + return ( + + ); + } + } + ]; + } + + const tools = ( + + ); + + const key = `record_actions_${itemId}_${columnIndex}`; + return ( + + {tools} + + ); + } + + renderItemFieldDataCell(itemId, item, column, columnIndex) { + const key = `_data_column_${column.field}_${itemId}_${columnIndex}`; + const align = this.resolveColumnAlign(column); + const textOnly = !column.render; + const value = get(item, column.field); + const contentRenderer = this.resolveContentRenderer(column); + const content = contentRenderer(value, item); + return ( + + {content} + + ); + } + + renderItemComputedCell(itemId, item, column, columnIndex) { + const key = `_computed_column_${itemId}_${columnIndex}`; + const align = this.resolveColumnAlign(column); + const contentRenderer = this.resolveContentRenderer(column); + const content = contentRenderer(item); + return ( + + {content} + + ); + } + + 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) { + const { sorting } = this.props; + if (!sorting || !sorting.sort || !column.sortable) { + return; + } + if (sorting.sort.field === column.field) { + return sorting.sort.direction; + } + } + + resolveColumnOnSort(column) { + if (column.sortable) { + if (!this.props.onChange) { + throw new Error(`BasicTable is configured to be sortable on column [${column.field}] but + [onChange] is not configured. This callback must be implemented to handle the sort requests`); + } + return () => this.onColumnSortChange(column); + } + } + + 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; + } + + renderPaginationBar() { + const { pagination, onChange } = this.props; + if (pagination) { + if (!onChange) { + throw new Error(`The Basic Table is configured with pagination but [onChange] is + not configured. This callback must be implemented to handle pagination changes`); + } + return ( + + ); + } + } +} diff --git a/src/components/basic_table/basic_table.test.js b/src/components/basic_table/basic_table.test.js new file mode 100644 index 00000000000..ab0f35af4eb --- /dev/null +++ b/src/components/basic_table/basic_table.test.js @@ -0,0 +1,436 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { requiredProps } from '../../test'; + +import { EuiBasicTable } from './basic_table'; + +describe('EuiBasicTable', () => { + + test('basic - empty', () => { + + const props = { + ...requiredProps, + items: [], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description' + } + ] + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('basic - with records', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description' + } + ] + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description' + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination - 2nd page', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description' + } + ], + pagination: { + pageIndex: 1, + pageSize: 3, + totalItemCount: 5 + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with sorting', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description', + sortable: true + } + ], + sorting: { + sort: { field: 'name', direction: 'asc' } + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination and selection', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description' + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + selection: { + itemId: 'id', + onSelectionChanged: () => undefined + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination, selection and sorting', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description', + sortable: true + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + selection: { + itemId: 'id', + onSelectionChanged: () => undefined + }, + sorting: { + sort: { field: 'name', direction: 'asc' } + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination, selection, sorting and column renderer', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description', + sortable: true, + render: (name) => name.toUpperCase() + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + selection: { + itemId: 'id', + onSelectionChanged: () => undefined + }, + sorting: { + sort: { field: 'name', direction: 'asc' } + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination, selection, sorting and column dataType', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', count: 1 }, + { id: '2', count: 2 }, + { id: '3', count: 3 } + ], + columns: [ + { + field: 'count', + name: 'Count', + description: 'description of count', + sortable: true, + dataType: 'number' + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + selection: { + itemId: 'id', + onSelectionChanged: () => undefined + }, + sorting: { + sort: { field: 'count', direction: 'asc' } + }, + onChange: () => {} + }; + 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, + items: [ + { id: '1', count: 1 }, + { id: '2', count: 2 }, + { id: '3', count: 3 } + ], + columns: [ + { + field: 'count', + name: 'Count', + description: 'description of count', + sortable: true, + dataType: 'number', + render: (count) => 'x'.repeat(count) + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + selection: { + itemId: 'id', + onSelectionChanged: () => undefined + }, + sorting: { + sort: { field: 'count', direction: 'asc' } + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination, selection, sorting and a single record action', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description', + sortable: true + }, + { + name: 'Actions', + actions: [ + { + type: 'button', + name: 'Edit', + description: 'edit', + onClick: () => undefined + } + ] + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + selection: { + itemId: 'id', + onSelectionChanged: () => undefined + }, + sorting: { + sort: { field: 'name', direction: 'asc' } + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('with pagination, selection, sorting and multiple record actions', () => { + + const props = { + ...requiredProps, + items: [ + { id: '1', name: 'name1' }, + { id: '2', name: 'name2' }, + { id: '3', name: 'name3' } + ], + columns: [ + { + field: 'name', + name: 'Name', + description: 'description', + sortable: true + }, + { + name: 'Actions', + actions: [ + { + type: 'button', + name: 'Edit', + description: 'edit', + onClick: () => undefined + }, + { + type: 'button', + name: 'Delete', + description: 'delete', + onClick: () => undefined + } + ] + } + ], + pagination: { + pageIndex: 0, + pageSize: 3, + totalItemCount: 5 + }, + selection: { + itemId: 'id', + onSelectionChanged: () => undefined + }, + sorting: { + sort: { field: 'name', direction: 'asc' } + }, + onChange: () => {} + }; + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + +}); diff --git a/src/components/basic_table/collapsed_item_actions.js b/src/components/basic_table/collapsed_item_actions.js new file mode 100644 index 00000000000..10bf25eb73b --- /dev/null +++ b/src/components/basic_table/collapsed_item_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 CollapsedItemActions 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, itemId, item, actionEnabled, onFocus } = this.props; + + const isOpen = this.state.popoverOpen; + + let allDisabled = true; + const controls = actions.reduce((controls, action, index) => { + const key = `action_${itemId}_${index}`; + const available = action.available ? action.available(item) : true; + if (!available) { + return controls; + } + const enabled = actionEnabled(action); + allDisabled = allDisabled && !enabled; + if (action.render) { + const actionControl = action.render(item, enabled); + controls.push( + + {actionControl} + + ); + } else { + controls.push( + + {action.name} + + ); + } + return controls; + }, []); + + const popoverButton = ( + + ); + + return ( + + + + ); + } +} diff --git a/src/components/basic_table/collapsed_item_actions.test.js b/src/components/basic_table/collapsed_item_actions.test.js new file mode 100644 index 00000000000..d90e82d64e1 --- /dev/null +++ b/src/components/basic_table/collapsed_item_actions.test.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { CollapsedItemActions } from './collapsed_item_actions'; + +describe('CollapsedItemActions', () => { + + test('render', () => { + + const props = { + actions: [ + { + name: 'default1', + description: 'default 1', + onClick: () => { + } + }, + { + name: 'custom1', + description: 'custom 1', + render: () => { + } + } + ], + visible: true, + itemId: 'id', + item: { id: 'xyz' }, + actionEnabled: () => true, + onFocus: () => {} + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/custom_item_action.js b/src/components/basic_table/custom_item_action.js new file mode 100644 index 00000000000..6b3d3456e7a --- /dev/null +++ b/src/components/basic_table/custom_item_action.js @@ -0,0 +1,54 @@ +import React, { cloneElement } from 'react'; + +export class CustomItemAction 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, item } = this.props; + const tool = action.render(item, enabled); + const clonedTool = cloneElement(tool, { onFocus: this.onFocus, onBlur: this.onBlur }); + const style = this.hasFocus() || visible ? { opacity: 1 } : { opacity: 0 }; + return ( +
+ {clonedTool} +
+ ); + } +} diff --git a/src/components/basic_table/custom_item_action.test.js b/src/components/basic_table/custom_item_action.test.js new file mode 100644 index 00000000000..3bfa026bec6 --- /dev/null +++ b/src/components/basic_table/custom_item_action.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { CustomItemAction } from './custom_item_action'; + +describe('CustomItemAction', () => { + + test('render', () => { + + const props = { + action: { + name: 'custom1', + description: 'custom 1', + render: () => 'test' + }, + enabled: true, + visible: true, + item: { id: 'xyz' } + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/default_item_action.js b/src/components/basic_table/default_item_action.js new file mode 100644 index 00000000000..cdd9a4fbeed --- /dev/null +++ b/src/components/basic_table/default_item_action.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { isString } from '../../services/predicate'; +import { EuiButton, EuiButtonIcon } from '../button'; + +const defaults = { + color: 'primary' +}; + +export class DefaultItemAction 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, item } = this.props; + if (!action.onClick) { + throw new Error(`Cannot render item 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(item); + 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 item 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, item } = this.props; + if (action.icon) { + return isString(action.icon) ? action.icon : action.icon(item); + } + } + + resolveActionColor() { + const { action, item } = this.props; + if (action.color) { + return isString(action.color) ? action.color : action.color(item); + } + return defaults.color; + } +} diff --git a/src/components/basic_table/default_item_action.test.js b/src/components/basic_table/default_item_action.test.js new file mode 100644 index 00000000000..47950bb5638 --- /dev/null +++ b/src/components/basic_table/default_item_action.test.js @@ -0,0 +1,55 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { DefaultItemAction } from './default_item_action'; +import { Random } from '../../services/random'; + +const random = new Random(); + +describe('DefaultItemAction', () => { + + test('render - button', () => { + + const props = { + action: { + name: 'action1', + description: 'action 1', + type: random.oneOf(undefined, 'button', 'foobar'), + onClick: () => {} + }, + enabled: true, + visible: true, + item: { id: 'xyz' } + }; + + 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, + item: { id: 'xyz' } + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/expanded_item_actions.js b/src/components/basic_table/expanded_item_actions.js new file mode 100644 index 00000000000..642adcb9a56 --- /dev/null +++ b/src/components/basic_table/expanded_item_actions.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { DefaultItemAction } from './default_item_action'; +import { CustomItemAction } from './custom_item_action'; + +export const ExpandedItemActions = ({ actions, visible, itemId, item, actionEnabled }) => { + + return actions.reduce((tools, action, index) => { + const available = action.available ? action.available(item) : true; + if (!available) { + return tools; + } + const enabled = actionEnabled(action); + const key = `item_action_${itemId}_${index}`; + if (action.render) { + // custom action has a render function + tools.push( + + ); + } else { + tools.push( + + ); + } + return tools; + }, []); +}; diff --git a/src/components/basic_table/expanded_item_actions.test.js b/src/components/basic_table/expanded_item_actions.test.js new file mode 100644 index 00000000000..ebc154bd79f --- /dev/null +++ b/src/components/basic_table/expanded_item_actions.test.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { shallow } from 'enzyme/build/index'; +import { ExpandedItemActions } from './expanded_item_actions'; + +describe('ExpandedItemActions', () => { + + test('render', () => { + + const props = { + actions: [ + { + name: 'default1', + description: 'default 1', + onClick: () => { + } + }, + { + name: 'custom1', + description: 'custom 1', + render: () => { + } + } + ], + visible: true, + itemId: 'xyz', + item: { id: 'xyz' }, + actionEnabled: () => true + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/basic_table/index.js b/src/components/basic_table/index.js new file mode 100644 index 00000000000..c90f19eb43a --- /dev/null +++ b/src/components/basic_table/index.js @@ -0,0 +1,3 @@ +export { + EuiBasicTable, +} from './basic_table'; diff --git a/src/components/basic_table/pagination_bar.js b/src/components/basic_table/pagination_bar.js new file mode 100644 index 00000000000..e4ea9a6d717 --- /dev/null +++ b/src/components/basic_table/pagination_bar.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { EuiSpacer } from '../spacer'; +import { EuiTablePagination } from '../table'; +import PropTypes from 'prop-types'; + +export const PaginationType = PropTypes.shape({ + pageIndex: PropTypes.number.isRequired, + pageSize: PropTypes.number.isRequired, + totalItemCount: PropTypes.number.isRequired, + pageSizeOptions: PropTypes.arrayOf(PropTypes.number) +}); + +const defaults = { + pageSizeOptions: [5, 10, 20] +}; + +export const PaginationBar = ({ pagination, onPageSizeChange, onPageChange }) => { + const pageSizeOptions = pagination.pageSizeOptions ? + pagination.pageSizeOptions : + defaults.pageSizeOptions; + const pageCount = Math.ceil(pagination.totalItemCount / pagination.pageSize); + return ( +
+ + +
+ ); +}; + +PaginationBar.propTypes = { + pagination: PaginationType.isRequired, + onPageSizeChange: PropTypes.func.isRequired, + onPageChange: PropTypes.func.isRequired +}; diff --git a/src/components/basic_table/pagination_bar.test.js b/src/components/basic_table/pagination_bar.test.js new file mode 100644 index 00000000000..688ed411eaa --- /dev/null +++ b/src/components/basic_table/pagination_bar.test.js @@ -0,0 +1,51 @@ +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, + pagination: { + pageIndex: 0, + pageSize: 5, + totalItemCount: 0 + }, + onPageSizeChange: () => {}, + onPageChange: () => {} + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + + test('render - custom page size options', () => { + + const props = { + ...requiredProps, + pagination: { + pageIndex: 0, + pageSize: 5, + totalItemCount: 0, + pageSizeOptions: [1, 2, 3] + }, + onPageSizeChange: () => {}, + onPageChange: () => {} + }; + + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + + }); + +}); diff --git a/src/components/index.js b/src/components/index.js index 6eab2ddb288..837fbf7f369 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -217,6 +217,10 @@ export { EuiTableOfRecords } from './table_of_records'; +export { + EuiBasicTable +} from './basic_table'; + export { EuiTab, EuiTabs, diff --git a/src/services/objects.js b/src/services/objects.js new file mode 100644 index 00000000000..928989d6c0b --- /dev/null +++ b/src/services/objects.js @@ -0,0 +1,3 @@ +export { + get +} from 'lodash'; diff --git a/src/services/utils.js b/src/services/utils.js new file mode 100644 index 00000000000..93dc03d60b0 --- /dev/null +++ b/src/services/utils.js @@ -0,0 +1,3 @@ +export { + times +} from 'lodash';