@vtex/styleguide
Version:
> VTEX Styleguide React components ([Docs](https://vtex.github.io/styleguide))
1,824 lines (1,689 loc) • 52.7 kB
Markdown
#### A table displays any kind of structured data and offers controls to easily navigate, search and filter through it. Data may be from just numbers to complex entities that employ other components to represent itself, like images, tags, links, etc.
Our Table was built to be highly composable and flexible. All parts are optional, and you can compose your table with any other Styleguide components. A Table may be used from a small table with numbers to full CRUD-like functionalities, from a small data display to the main screen of a complex module. All parts are plug'n'play parts that you can turn on and off to match your needs.
### 👍 Dos
- Try to support as many of the Table features as you can in your system - if we designed there it's because it's highly recommended to have.
- Provide as many domain-specific actions as you want in the dropdown slot.
- Line actions: should be mostly for actions that are resolved in the same screen, or if it's was identified to be a very recurrent action.
Simple example
```js
const sampleData = require('./sampleData').default
const itemsCopy = sampleData.items
.slice()
.reverse()
.splice(15)
const defaultSchema = {
properties: {
name: {
title: 'Name',
width: 300,
},
email: {
title: 'Email',
minWidth: 350,
},
number: {
title: 'Number',
// default is 200px
minWidth: 100,
},
},
}
;<div>
<div className="mb5">
<Table
fullWidth
schema={defaultSchema}
items={itemsCopy}
density="high"
onRowClick={({ rowData }) => {
alert(
`you just clicked ${rowData.name}, number is ${rowData.number} and email ${rowData.email}`
)
}}
/>
</div>
</div>
```
# Table schema
The Schema property is a JSON used to define the table columns and how they should behave visually. The Schema has properties and each one of them defines a column in the table.
Example with simple structure:
```js static
{
properties: {
column1: {
title: "First Column"
},
column2: {
title: "Second Column",
width: 350
}
}
}
```
##### title
- Control the title which appears on table Header.
- It receives only strings.
- If you want to customize it with a component, you can use the `headerRenderer` prop.
##### width
- Control the column width.
- It receives only numbers, which are values in pixels.
- Default value is 200px
##### minWidth
- Fix a minimum width to the column.
- It receives only numbers, which are values in pixels.
- Default value is 200px
##### cellRenderer
- Customize the render method of a single column cell.
- It receives a function that returns a node (react component).
- The function has the following params: ({ cellData, rowData, updateCellMeasurements })
- Default is render the value as a string.
- If you use `dynamicRowHeights` option in your table, you may need to use `updateCellMeasurements` to update the cell measurement cache (e.g. in an `onLoad` prop for images).
- If you have a custom cell component that has a click interaction and at the same time you use the onRowClick Table prop, you might stumble uppon the problem of both click actions being fired. We can work around that by doing a wrapper around cellRenderer to stop click event propagation, like so:
##### headerRight
- Use this boolean property to align right the text of a header. Useful for monetary values.
- Usage: `headerRight: true`.
- You will have to use the `cellRenderer` property to also align right the content of the column.
```jsx noeditor static
{
properties: {
column1: {
cellRenderer: ({ cellData, rowData }) => {
return (
<div
onClick={e => {
e.stopPropagation()
// the click event propagation start on the checkbox click below, and propagates up the DOM tree.
// this wrapper is going to catch the event right after it fires and stop it's propagation.
// stoping the click event from propagating until the row component node,
// so the onRowClick will not be fired.
// you can learm more about DOM event propagation here: http://tiny.cc/c1625y
}}>
<Checkbox
checked={this.state.check}
id="select-option"
name="select-option"
onChange={() => this.setState({ check: !this.state.check })}
/>
</div>
)
}
}
}
}
```
Example customizing color column cell, with clickable badges
```js
const sampleData = require('./sampleData').default
const itemsCopy = sampleData.items
.slice()
.reverse()
.splice(20)
const Tag = require('../Tag').default
class CustomTableExample extends React.Component {
constructor() {
super()
this.state = {
orderedItems: itemsCopy,
}
}
render() {
const customSchema = {
properties: {
name: {
title: 'Name',
width: 300,
},
email: {
title: 'Email',
width: 350,
},
color: {
title: 'Color',
// you can customize cell component render (also header component with headerRenderer)
cellRenderer: ({ cellData }) => {
return (
<Tag
bgColor={cellData.color}
color="#fff"
onClick={e => {
// if you use cellRender click event AND onRowclick event
// you should stop the event propagation so the cell click fires and row click don't
e.stopPropagation()
alert(
`you just clicked a cell to remove ${cellData.label}, HEX: ${cellData.color}`
)
}}>
<span className="nowrap">{cellData.label}</span>
</Tag>
)
},
},
},
}
return (
<div>
<div className="mb5">
<Table
schema={customSchema}
items={this.state.orderedItems}
indexColumnLabel="Index"
onRowClick={({ rowData }) => {
alert(`you just clicked the row with ${rowData.name}`)
}}
/>
</div>
</div>
)
}
}
;<CustomTableExample />
```
##### sortable
- Sinalize that a column is sortable, so the header will be clickable.
- This prop receives a boolean.
- On sortable header's click the Table `onSort` callback will be fired.
Example sortable by Name
```js
const sampleData = require('./sampleData').default
const itemsCopy = sampleData.items
.slice()
.reverse()
.splice(20)
const Tag = require('../Tag').default
class CustomTableExample extends React.Component {
constructor() {
super()
this.state = {
orderedItems: itemsCopy,
dataSort: {
sortedBy: null,
sortOrder: null,
},
}
this.sortNameAlphapeticallyASC = this.sortNameAlphapeticallyASC.bind(this)
this.sortNameAlphapeticallyDESC = this.sortNameAlphapeticallyDESC.bind(this)
this.handleSort = this.handleSort.bind(this)
}
sortNameAlphapeticallyASC(a, b) {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
}
sortNameAlphapeticallyDESC(a, b) {
return a.name < b.name ? 1 : a.name > b.name ? -1 : 0
}
handleSort({ sortOrder, sortedBy }) {
// I'll just handle sort by 'name', but I could handle multiple properties
if (sortedBy === 'name') {
const orderedItems =
sortOrder === 'ASC'
? itemsCopy.slice().sort(this.sortNameAlphapeticallyASC)
: itemsCopy.slice().sort(this.sortNameAlphapeticallyDESC)
// the above const could come out of an API call to sort items for example
this.setState({
orderedItems,
dataSort: {
sortedBy,
sortOrder,
},
})
}
}
render() {
const customSchema = {
properties: {
name: {
title: 'Name',
width: 300,
// sortable boolean in a schema property makes it sortable,
// (clicking header triggers onSort callback).
sortable: true,
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'Value',
headerRight: true,
cellRenderer: data => (
<div className="w-100 tr">$ {data.cellData}</div>
),
},
},
}
return (
<div>
<div className="mb5">
<Table
schema={customSchema}
items={this.state.orderedItems}
sort={{
sortedBy: this.state.dataSort.sortedBy,
sortOrder: this.state.dataSort.sortOrder,
}}
onSort={this.handleSort}
/>
</div>
</div>
)
}
}
;<CustomTableExample />
```
##### headerRenderer
- Customized the render method of a single header cell.
- It receives a function that returns a node (react component).
- The function has the following params: ({ columnIndex, key, title })
- This prop will not work if the `sortable` prop for the same header is active.
example customizing number column header to use intl FormattedMessage
```js
const sampleData = require('./sampleData').default
const itemsCopy = sampleData.items
.slice()
.reverse()
.splice(20)
const Tag = require('../Tag').default
class FormattedMessage extends React.Component {
render() {
const renderTextByIntlId = id => {
switch (id) {
case 'some.intl.message.id':
return 'Number'
break
default:
return 'Deafult Header title'
break
}
}
return <span>{renderTextByIntlId(this.props.id)}</span>
}
}
class CustomTableExample extends React.Component {
constructor() {
super()
this.state = {
orderedItems: itemsCopy,
}
}
render() {
const customSchema = {
properties: {
name: {
title: 'Name',
width: 250,
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'some.intl.message.id',
headerRenderer: ({ title }) => {
return <FormattedMessage id={title} />
},
},
},
}
return (
<div>
<div className="mb5">
<Table schema={customSchema} items={this.state.orderedItems} />
</div>
</div>
)
}
}
;<CustomTableExample />
```
# Features
<div className="center mw7 pv6">

</div>
##### Custom empty state
Empty states can also be customized, the passed children will be rendered inside an EmptyState component.
It's worth to customize empty state using this prop so the other table features will behave accordingly (e.g. the topbar, pagination and totalizers).
```js
const sampleData = require('./sampleData').default
const Button = require('../Button').default
const itemsCopy = sampleData.items
.slice()
.reverse()
.splice(15)
const defaultSchema = {
properties: {
name: {
title: 'Name',
width: 300,
},
email: {
title: 'Email',
minWidth: 350,
},
number: {
title: 'Number',
// default is 200px
minWidth: 100,
},
},
}
;<div>
<div>
<Table
fullWidth
schema={defaultSchema}
items={[]}
emptyStateLabel="This is my custom empty state title"
emptyStateChildren={
<React.Fragment>
<p>
A longer explanation of what should be here, and why should I care
about what should be here.
</p>
<div className="pt5">
<Button variation="secondary" size="small">
<span className="flex align-baseline">Suggested action</span>
</Button>
</div>
</React.Fragment>
}
onRowClick={({ rowData }) => {
alert(
`you just clicked ${rowData.name}, number is ${rowData.number} and email ${rowData.email}`
)
}}
/>
</div>
</div>
```
##### Pagination
This feature uses the pagination component in the bottom, after the table content.
```js
const ArrowDown = require('../icon/ArrowDown').default
const ArrowUp = require('../icon/ArrowUp').default
const sampleData = require('./sampleData').default
const Tag = require('../Tag').default
const tableLength = 5
const initialState = {
tableLength,
currentPage: 1,
slicedData: sampleData.items.slice(0, tableLength),
currentItemFrom: 1,
currentItemTo: tableLength,
itemsLength: sampleData.items.length,
emptyStateLabel: 'Nothing to show.',
}
const jsonschema = {
properties: {
name: {
title: 'Name',
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'Number',
width: 150,
},
color: {
title: 'Color',
cellRenderer: ({ cellData }) => {
return (
<Tag bgColor={cellData.color} color="#fff">
<span className="nowrap">{cellData.label}</span>
</Tag>
)
},
},
},
}
class ResourceListExample extends React.Component {
constructor() {
super()
this.state = initialState
this.handleNextClick = this.handleNextClick.bind(this)
this.handlePrevClick = this.handlePrevClick.bind(this)
this.goToPage = this.goToPage.bind(this)
this.handleRowsChange = this.handleRowsChange.bind(this)
}
handleNextClick() {
const newPage = this.state.currentPage + 1
const itemFrom = this.state.currentItemTo + 1
const itemTo = tableLength * newPage
const data = sampleData.items.slice(itemFrom - 1, itemTo)
this.goToPage(newPage, itemFrom, itemTo, data)
}
handlePrevClick() {
if (this.state.currentPage === 0) return
const newPage = this.state.currentPage - 1
const itemFrom = this.state.currentItemFrom - tableLength
const itemTo = this.state.currentItemFrom - 1
const data = sampleData.items.slice(itemFrom - 1, itemTo)
this.goToPage(newPage, itemFrom, itemTo, data)
}
goToPage(currentPage, currentItemFrom, currentItemTo, slicedData) {
this.setState({
currentPage,
currentItemFrom,
currentItemTo,
slicedData,
})
}
handleRowsChange(e, value) {
this.setState(
{
tableLength: parseInt(value),
currentItemTo: parseInt(value),
},
() => {
// this callback guarantees new sliced items respect filters and tableLength
const { filterStatements } = this.state
this.handleFiltersChange(filterStatements)
}
)
}
render() {
return (
<Table
schema={jsonschema}
items={this.state.slicedData}
emptyStateLabel={this.state.emptyStateLabel}
pagination={{
onNextClick: this.handleNextClick,
onPrevClick: this.handlePrevClick,
currentItemFrom: this.state.currentItemFrom,
currentItemTo: this.state.currentItemTo,
onRowsChange: this.handleRowsChange,
textShowRows: 'Show rows',
textOf: 'of',
totalItems: this.state.itemsLength,
rowsOptions: [5, 10, 15, 25],
}}
/>
)
}
}
;<ResourceListExample />
```
##### Line actions
This feature creates a last extra column with an ActionMenu component per line.
```js
const sampleData = require('./sampleData').default
const itemsCopy = sampleData.items
.slice()
.reverse()
.splice(15)
const defaultSchema = {
properties: {
name: {
title: 'Name',
},
email: {
title: 'Email',
},
number: {
title: 'Number',
},
},
}
const lineActions = [
{
label: ({ rowData }) => `Action for ${rowData.name}`,
onClick: ({ rowData }) => alert(`Executed action for ${rowData.name}`),
},
{
label: ({ rowData }) => `DANGEROUS action for ${rowData.name}`,
isDangerous: true,
onClick: ({ rowData }) =>
alert(`Executed a DANGEROUS action for ${rowData.name}`),
},
]
;<div>
<div className="mb5">
<Table
fullWidth
schema={defaultSchema}
items={itemsCopy}
lineActions={lineActions}
/>
</div>
</div>
```
##### Fixed first column
This case is recommended if you have lots of columns, so the most important information could be fixed
```js
const sampleData = require('./sampleData').default
const Tag = require('../Tag').default
const tableLength = 5
const initialState = {
tableLength,
currentPage: 1,
slicedData: sampleData.items.slice(0, tableLength),
currentItemFrom: 1,
currentItemTo: tableLength,
searchValue: '',
itemsLength: sampleData.items.length,
emptyStateLabel: 'Nothing to show.',
}
class ResourceListExample extends React.Component {
constructor() {
super()
this.state = initialState
this.customColorTagProperty = this.customColorTagProperty.bind(this)
}
customColorTagProperty(index) {
return {
title: `Color${index ? ` ${index}` : ''}`,
cellRenderer: ({ cellData }) => {
return (
<Tag bgColor={cellData.color} color="#fff">
<span className="nowrap">{cellData.label}</span>
</Tag>
)
},
}
}
render() {
const customSchema = {
properties: {
name: {
title: 'Name',
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'Number',
},
color: this.customColorTagProperty(),
color1: this.customColorTagProperty(1),
color2: this.customColorTagProperty(2),
color3: this.customColorTagProperty(3),
color4: this.customColorTagProperty(4),
color5: this.customColorTagProperty(5),
color6: this.customColorTagProperty(6),
},
}
return (
<Table
schema={customSchema}
items={this.state.slicedData}
fixFirstColumn
emptyStateLabel={this.state.emptyStateLabel}
/>
)
}
}
;<ResourceListExample />
```
##### Toolbar
The toolbar is a bundle of features, including search input, columns visibility toggler, density controls, import and export buttons, extra actions menu using ActionMenu component and a newLine button to help with entry creation (you can see the illustrative diagram in the beginning of the page for a better visualization of this structure)
```js
const ArrowDown = require('../icon/ArrowDown').default
const ArrowUp = require('../icon/ArrowUp').default
const sampleData = require('./sampleData').default
const Tag = require('../Tag').default
const tableLength = 5
const initialState = {
slicedData: sampleData.items.slice(0, tableLength),
searchValue: '',
emptyStateLabel: 'Nothing to show.',
}
const jsonschema = {
properties: {
name: {
title: 'Name',
width: 170,
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'Number',
width: 150,
},
color: {
title: 'Color',
width: 170,
cellRenderer: ({ cellData }) => {
return (
<Tag bgColor={cellData.color} color="#fff">
<span className="nowrap">{cellData.label}</span>
</Tag>
)
},
},
},
}
class ResourceListExample extends React.Component {
constructor() {
super()
this.state = initialState
this.handleInputSearchChange = this.handleInputSearchChange.bind(this)
this.handleInputSearchSubmit = this.handleInputSearchSubmit.bind(this)
this.handleInputSearchClear = this.handleInputSearchClear.bind(this)
}
handleInputSearchChange(e) {
this.setState({ searchValue: e.target.value })
}
handleInputSearchClear(e) {
this.setState({ ...initialState })
}
handleInputSearchSubmit(e) {
e.preventDefault()
if (!this.state.searchValue) {
this.setState({ ...initialState })
} else {
this.setState({
slicedData: [],
emptyStateLabel: 'No results found.',
})
}
}
render() {
return (
<Table
schema={jsonschema}
items={this.state.slicedData}
toolbar={{
inputSearch: {
value: this.state.searchValue,
placeholder: 'Search stuff...',
onChange: this.handleInputSearchChange,
onClear: this.handleInputSearchClear,
onSubmit: this.handleInputSearchSubmit,
},
density: {
buttonLabel: 'Line density',
lowOptionLabel: 'Low',
mediumOptionLabel: 'Medium',
highOptionLabel: 'High',
},
download: {
label: 'Export',
handleCallback: () => alert('Callback()'),
},
upload: {
label: 'Import',
handleCallback: () => alert('Callback()'),
},
fields: {
label: 'Toggle visible fields',
showAllLabel: 'Show All',
hideAllLabel: 'Hide All',
onToggleColumn: (params) => {
console.log(params.toggledField)
console.log(params.activeFields)
},
onHideAllColumns: (activeFields) => console.log(activeFields),
onShowAllColumns: (activeFields) => console.log(activeFields),
},
extraActions: {
label: 'More options',
actions: [
{
label: 'An action',
handleCallback: () => alert('An action'),
},
{
label: 'Another action',
handleCallback: () => alert('Another action'),
},
{
label: 'A third action',
handleCallback: () => alert('A third action'),
},
],
},
newLine: {
label: 'New',
handleCallback: () => alert('handle new line callback'),
},
}}
/>
)
}
}
;<ResourceListExample />
```
##### Totalizers
This uses the Totalizer component between the toolbar and the table content
```js
const ArrowDown = require('../icon/ArrowDown').default
const ArrowUp = require('../icon/ArrowUp').default
const sampleData = require('./sampleData').default
const Tag = require('../Tag').default
const tableLength = 5
const initialState = {
slicedData: sampleData.items.slice(0, tableLength),
emptyStateLabel: 'Nothing to show.',
}
const jsonschema = {
properties: {
name: {
title: 'Name',
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'Number',
width: 150,
},
color: {
title: 'Color',
cellRenderer: ({ cellData }) => {
return (
<Tag bgColor={cellData.color} color="#fff">
<span className="nowrap">{cellData.label}</span>
</Tag>
)
},
},
},
}
class ResourceListExample extends React.Component {
constructor() {
super()
this.state = initialState
}
render() {
return (
<Table
schema={jsonschema}
items={this.state.slicedData}
emptyStateLabel={this.state.emptyStateLabel}
totalizers={[
{
label: 'Account balance',
value: 23837,
},
{
label: 'Tickets',
value: '$ 36239,05',
iconBackgroundColor: '#eafce3',
icon: <ArrowUp color="#79B03A" size={14} />,
},
{
label: 'Outputs',
value: '-$ 13.485,26',
icon: <ArrowDown size={14} />,
},
{
label: 'Sales',
value: 23837,
isLoading: true,
},
]}
/>
)
}
}
;<ResourceListExample />
```
##### Filters
This feature uses FilterBar component inserting it between the toolbar and table content (just like the Totalizers, it's a separate component).
```js
const ArrowDown = require('../icon/ArrowDown').default
const ArrowUp = require('../icon/ArrowUp').default
const Checkbox = require('../Checkbox').default
const Input = require('../Input').default
const sampleData = require('./sampleData').default
const Tag = require('../Tag').default
const tableLength = 7
const initialState = {
tableLength,
slicedData: sampleData.items.slice(0, tableLength),
emptyStateLabel: 'Nothing to show.',
filterStatements: [],
}
const jsonschema = {
properties: {
name: {
title: 'Name',
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'Number',
width: 150,
},
color: {
title: 'Color',
cellRenderer: ({ cellData }) => {
return (
<Tag bgColor={cellData.color} color="#fff">
<span className="nowrap">{cellData.label}</span>
</Tag>
)
},
},
},
}
class ResourceListExample extends React.Component {
constructor() {
super()
this.state = initialState
this.simpleInputObject = this.simpleInputObject.bind(this)
this.simpleInputVerbsAndLabel = this.simpleInputVerbsAndLabel.bind(this)
this.numberInputObject = this.numberInputObject.bind(this)
this.numberInputRangeObject = this.numberInputRangeObject.bind(this)
this.colorSelectorObject = this.colorSelectorObject.bind(this)
this.handleFiltersChange = this.handleFiltersChange.bind(this)
}
simpleInputObject({ value, onChange }) {
return (
<Input value={value || ''} onChange={e => onChange(e.target.value)} />
)
}
simpleInputVerbsAndLabel() {
return {
renderFilterLabel: st => {
if (!st || !st.object) {
// you should treat empty object cases only for alwaysVisibleFilters
return 'Any'
}
return `${
st.verb === '=' ? 'is' : st.verb === '!=' ? 'is not' : 'contains'
} ${st.object}`
},
verbs: [
{
label: 'is',
value: '=',
object: this.simpleInputObject,
},
{
label: 'is not',
value: '!=',
object: this.simpleInputObject,
},
{
label: 'contains',
value: 'contains',
object: this.simpleInputObject,
},
],
}
}
numberInputObject({ value, onChange }) {
return (
<Input
placeholder="Insert number…"
type="number"
min="0"
max="180"
value={value || ''}
onChange={e => {
onChange(e.target.value.replace(/\D/g, ''))
}}
/>
)
}
numberInputRangeObject({ value, onChange }) {
return (
<div className="flex">
<Input
placeholder="from…"
errorMessage={
value &&
value.first &&
value.last &&
parseInt(value.first) >= parseInt(value.last)
? 'Must be smaller than other input'
: ''
}
value={value && value.first ? value.first : ''}
onChange={e => {
const currentObject = value || {}
currentObject.first = e.target.value.replace(/\D/g, '')
onChange(currentObject)
}}
/>
<div className="mv4 mh3 c-muted-2 b">and</div>
<Input
placeholder="to…"
value={value && value.last ? value.last : ''}
onChange={e => {
const currentObject = value || {}
currentObject.last = e.target.value.replace(/\D/g, '')
onChange(currentObject)
}}
/>
</div>
)
}
colorSelectorObject({ value, onChange }) {
const initialValue = {
pink: true,
black: true,
blue: true,
gray: true,
...(value || {}),
}
const toggleValueByKey = key => {
const newValue = {
...(value || initialValue),
[key]: value ? !value[key] : false,
}
return newValue
}
return (
<div>
{Object.keys(initialValue).map((opt, index) => {
return (
<div className="mb3" key={`class-statment-object-${opt}-${index}`}>
<Checkbox
checked={value ? value[opt] : initialValue[opt]}
label={opt}
name="default-checkbox-group"
onChange={() => {
const newValue = toggleValueByKey(`${opt}`)
const newValueKeys = Object.keys(newValue)
const isEmptyFilter = !newValueKeys.some(
key => !newValue[key]
)
onChange(isEmptyFilter ? null : newValue)
}}
value={opt}
/>
</div>
)
})}
</div>
)
}
handleFiltersChange(statements = []) {
// here you should receive filter values, so you can fire mutations ou fetch filtered data from APIs
// For the sake of example I'll filter the data manually since there is no API
const { tableLength } = this.state
let newData = sampleData.items.slice()
statements.forEach(st => {
if (!st || !st.object) return
const { subject, verb, object } = st
switch (subject) {
case 'color':
if (!object) return
const colorsMap = {
'#F71963': 'pink',
'#00BBD4': 'blue',
'#D6D8E0': 'gray',
'#142032': 'black',
}
newData = newData.filter(item => object[colorsMap[item.color.color]])
break
case 'name':
case 'email':
if (verb === 'contains') {
newData = newData.filter(item => item[subject].includes(object))
} else if (verb === '=') {
newData = newData.filter(item => item[subject] === object)
} else if (verb === '!=') {
newData = newData.filter(item => item[subject] !== object)
}
break
case 'number':
if (verb === '=') {
newData = newData.filter(item => item.number === parseInt(object))
} else if (verb === 'between') {
newData = newData.filter(
item =>
item.number >= parseInt(object.first) &&
item.number <= parseInt(object.last)
)
}
break
}
})
const newDataLength = newData.length
const newSlicedData = newData.slice(0, tableLength)
this.setState({
filterStatements: statements,
slicedData: newSlicedData,
itemsLength: newDataLength,
currentItemTo: tableLength > newDataLength ? newDataLength : tableLength,
})
}
render() {
return (
<Table
schema={jsonschema}
items={this.state.slicedData}
emptyStateLabel={this.state.emptyStateLabel}
filters={{
alwaysVisibleFilters: ['color', 'name'],
statements: this.state.filterStatements,
onChangeStatements: this.handleFiltersChange,
clearAllFiltersButtonLabel: 'Clear Filters',
collapseLeft: true,
options: {
color: {
label: 'Color',
renderFilterLabel: st => {
if (!st || !st.object) {
// you should treat empty object cases only for alwaysVisibleFilters
return 'All'
}
const keys = st.object ? Object.keys(st.object) : {}
const isAllTrue = !keys.some(key => !st.object[key])
const isAllFalse = !keys.some(key => st.object[key])
const trueKeys = keys.filter(key => st.object[key])
let trueKeysLabel = ''
trueKeys.forEach((key, index) => {
trueKeysLabel += `${key}${
index === trueKeys.length - 1 ? '' : ', '
}`
})
return `${
isAllTrue ? 'All' : isAllFalse ? 'None' : `${trueKeysLabel}`
}`
},
verbs: [
{
label: 'includes',
value: 'includes',
object: this.colorSelectorObject,
},
],
},
name: {
label: 'Name',
...this.simpleInputVerbsAndLabel(),
},
email: {
label: 'Email',
...this.simpleInputVerbsAndLabel(),
},
number: {
label: 'Number',
renderFilterLabel: st =>
`${
st.verb === 'between'
? `between ${st.object.first} and ${st.object.last}`
: `is ${st.object}`
}`,
verbs: [
{
label: 'is',
value: '=',
object: this.numberInputObject,
},
{
label: 'is between',
value: 'between',
object: this.numberInputRangeObject,
},
],
},
},
}}
/>
)
}
}
;<ResourceListExample />
```
##### Bulk actions
Bulk actions allow the user to select some or all the rows to apply an action. Texts have to be given to the component via a `texts` object.
Actions are passed via the `main` object and the `others` array props. Each object is composed of a `label` and the action event via `onClick` key.
The returned value for all lines selected is an object `allLinesSelected: true` otherwise the data of the rows are returned in the key `selectedRows` as an array.
##### NOTE 1:
`onRowClick` actions are not happening when clicking the checkbox.
##### NOTE 2:
There are two "select all" items.
- The **upper checkbox** on the left side selects the currently visible items, in the example below, 5.
- The **Select all** button on the right side, selects all items from the database (by concept, since you will probably only load the visible items). Since not all items might be loaded in the table, the callback will only return a flag telling your app to handle all items for the next database operation.
Check the console when selecting/unselecting rows or clicking an action button in the example below to see the action parameters
```js
const itemsCopy = [
{
email: 'olen.stamm21@yahoo.com',
name: 'Patrick Rothfuss',
number: 1.52725,
},
{
email: 'junius0@gmail.com',
name: 'Hurricane Skywalker IV',
number: 2.84639,
},
{
email: 'judd_gulgowski22@yahoo.com',
name: 'Tom Braddy',
number: 4.10182,
},
{
email: 'catharine.leuschke62@hotmail.com',
name: 'Momochi Zabuza',
number: 6.33245,
},
{
email: 'candido_ryan@hotmail.com',
name: 'Freddie Mercury',
number: 7.96637,
},
]
const defaultSchema = {
properties: {
name: {
type: 'string',
title: 'Name',
},
email: {
type: 'string',
title: 'Email',
},
number: {
type: 'number',
title: 'Number',
},
},
}
;<div className="mb5">
<Table
fullWidth
schema={defaultSchema}
items={itemsCopy}
density="high"
bulkActions={{
texts: {
secondaryActionsLabel: 'Actions',
rowsSelected: qty => (
<React.Fragment>Selected rows: {qty}</React.Fragment>
),
selectAll: 'Select all',
allRowsSelected: qty => (
<React.Fragment>All rows selected: {qty}</React.Fragment>
),
},
totalItems: 122,
onChange: params => console.log(params),
main: {
label: 'Main Action',
handleCallback: params => console.log(params),
},
others: [
{
label: 'Action 1',
handleCallback: params => console.log(params),
},
{
label: 'Action 2',
handleCallback: params => console.log(params),
},
{
label: 'Dangerous action',
isDangerous: true,
handleCallback: params => console.log(params),
},
],
}}
/>
</div>
```
##### Full blown example
With Toolbar, Totalizers, Pagination and Filters
```js
const ArrowDown = require('../icon/ArrowDown').default
const ArrowUp = require('../icon/ArrowUp').default
const Checkbox = require('../Checkbox').default
const Input = require('../Input').default
const sampleData = require('./sampleData').default
const Tag = require('../Tag').default
const tableLength = 5
const initialState = {
tableLength,
currentPage: 1,
slicedData: sampleData.items.slice(0, tableLength),
currentItemFrom: 1,
currentItemTo: tableLength,
searchValue: '',
itemsLength: sampleData.items.length,
emptyStateLabel: 'Nothing to show.',
filterStatements: [],
}
const jsonschema = {
properties: {
name: {
title: 'Name',
},
email: {
title: 'Email',
width: 300,
},
number: {
title: 'Number',
width: 150,
},
color: {
title: 'Color',
cellRenderer: ({ cellData }) => {
return (
<Tag bgColor={cellData.color} color="#fff">
<span className="nowrap">{cellData.label}</span>
</Tag>
)
},
},
},
}
class ResourceListExample extends React.Component {
constructor() {
super()
this.state = initialState
this.handleNextClick = this.handleNextClick.bind(this)
this.handlePrevClick = this.handlePrevClick.bind(this)
this.goToPage = this.goToPage.bind(this)
this.handleInputSearchChange = this.handleInputSearchChange.bind(this)
this.handleInputSearchSubmit = this.handleInputSearchSubmit.bind(this)
this.handleInputSearchClear = this.handleInputSearchClear.bind(this)
this.handleRowsChange = this.handleRowsChange.bind(this)
this.simpleInputObject = this.simpleInputObject.bind(this)
this.simpleInputVerbsAndLabel = this.simpleInputVerbsAndLabel.bind(this)
this.numberInputObject = this.numberInputObject.bind(this)
this.numberInputRangeObject = this.numberInputRangeObject.bind(this)
this.colorSelectorObject = this.colorSelectorObject.bind(this)
this.handleFiltersChange = this.handleFiltersChange.bind(this)
}
handleNextClick() {
const newPage = this.state.currentPage + 1
const itemFrom = this.state.currentItemTo + 1
const itemTo = tableLength * newPage
const data = sampleData.items.slice(itemFrom - 1, itemTo)
this.goToPage(newPage, itemFrom, itemTo, data)
}
handlePrevClick() {
if (this.state.currentPage === 0) return
const newPage = this.state.currentPage - 1
const itemFrom = this.state.currentItemFrom - tableLength
const itemTo = this.state.currentItemFrom - 1
const data = sampleData.items.slice(itemFrom - 1, itemTo)
this.goToPage(newPage, itemFrom, itemTo, data)
}
goToPage(currentPage, currentItemFrom, currentItemTo, slicedData) {
this.setState({
currentPage,
currentItemFrom,
currentItemTo,
slicedData,
})
}
handleRowsChange(e, value) {
this.setState(
{
tableLength: parseInt(value),
currentItemTo: parseInt(value),
},
() => {
// this callback garantees new sliced items respect filters and tableLength
const { filterStatements } = this.state
this.handleFiltersChange(filterStatements)
}
)
}
handleInputSearchChange(e) {
this.setState({ searchValue: e.target.value })
}
handleInputSearchClear(e) {
this.setState({ ...initialState })
}
handleInputSearchSubmit(e) {
const value = e && e.target && e.target.value
const regex = new RegExp(value, 'i')
if (!value) {
this.setState({ ...initialState })
} else {
this.setState({
slicedData: initialState.slicedData
.slice()
.filter(item => regex.test(item.name) || regex.test(item.email)),
})
}
}
simpleInputObject({
statements,
values,
statementIndex,
error,
extraParams,
onChangeObjectCallback,
}) {
return (
<Input
value={values || ''}
onChange={e => onChangeObjectCallback(e.target.value)}
/>
)
}
simpleInputVerbsAndLabel() {
return {
renderFilterLabel: st => {
if (!st || !st.object) {
// you should treat empty object cases only for alwaysVisibleFilters
return 'Any'
}
return `${
st.verb === '=' ? 'is' : st.verb === '!=' ? 'is not' : 'contains'
} ${st.object}`
},
verbs: [
{
label: 'is',
value: '=',
object: {
renderFn: this.simpleInputObject,
extraParams: {},
},
},
{
label: 'is not',
value: '!=',
object: {
renderFn: this.simpleInputObject,
extraParams: {},
},
},
{
label: 'contains',
value: 'contains',
object: {
renderFn: this.simpleInputObject,
extraParams: {},
},
},
],
}
}
numberInputObject({
statements,
values,
statementIndex,
error,
onChangeObjectCallback,
}) {
return (
<Input
placeholder="Insert number…"
type="number"
min="0"
max="180"
value={values || ''}
onChange={e => {
onChangeObjectCallback(e.target.value.replace(/\D/g, ''))
}}
/>
)
}
numberInputRangeObject({
statements,
values,
statementIndex,
error,
extraParams,
onChangeObjectCallback,
}) {
return (
<div className="flex">
<Input
placeholder="Number from…"
errorMessage={
statements[statementIndex].object &&
parseInt(statements[statementIndex].object.first) >=
parseInt(statements[statementIndex].object.last)
? 'Must be smaller than other input'
: ''
}
value={values && values.first ? values.first : ''}
onChange={e => {
const currentObject = values || {}
currentObject.first = e.target.value.replace(/\D/g, '')
onChangeObjectCallback(currentObject)
}}
/>
<div className="mv4 mh3 c-muted-2 b">and</div>
<Input
placeholder="Number to…"
value={values && values.last ? values.last : ''}
onChange={e => {
const currentObject = values || {}
currentObject.last = e.target.value.replace(/\D/g, '')
onChangeObjectCallback(currentObject)
}}
/>
</div>
)
}
colorSelectorObject({
statements,
values,
statementIndex,
error,
extraParams,
onChangeObjectCallback,
}) {
const initialValue = {
pink: true,
black: true,
blue: true,
gray: true,
...(values || {}),
}
const toggleValueByKey = key => {
const newValues = {
...(values || initialValue),
[key]: values ? !values[key] : false,
}
return newValues
}
return (
<div>
{Object.keys(initialValue).map((opt, index) => {
return (
<div className="mb3" key={`class-statment-object-${opt}-${index}`}>
<Checkbox
checked={values ? values[opt] : initialValue[opt]}
label={opt}
name="default-checkbox-group"
onChange={() => {
const newValue = toggleValueByKey(`${opt}`)
const newValueKeys = Object.keys(newValue)
const isEmptyFilter = !newValueKeys.some(
key => !newValue[key]
)
onChangeObjectCallback(isEmptyFilter ? null : newValue)
}}
value={opt}
/>
</div>
)
})}
</div>
)
}
handleFiltersChange(statements = []) {
// here you should receive filter values, so you can fire mutations ou fetch filtered data from APIs
// For the sake of example I'll filter the data manually since there is no API
const { tableLength } = this.state
let newData = sampleData.items.slice()
statements.forEach(st => {
if (!st || !st.object) return
const { subject, verb, object } = st
switch (subject) {
case 'color':
if (!object) return
const colorsMap = {
'#F71963': 'pink',
'#00BBD4': 'blue',
'#D6D8E0': 'gray',
'#142032': 'black',
}
newData = newData.filter(item => object[colorsMap[item.color.color]])
break
case 'name':
case 'email':
if (verb === 'contains') {
newData = newData.filter(item => item[subject].includes(object))
} else if (verb === '=') {
newData = newData.filter(item => item[subject] === object)
} else if (verb === '!=') {
newData = newData.filter(item => item[subject] !== object)
}
break
case 'number':
if (verb === '=') {
newData = newData.filter(item => item.number === parseInt(object))
} else if (verb === 'between') {
newData = newData.filter(
item =>
item.number >= parseInt(object.first) &&
item.number <= parseInt(object.last)
)
}
break
}
})
const newDataLength = newData.length
const newSlicedData = newData.slice(0, tableLength)
this.setState({
filterStatements: statements,
slicedData: newSlicedData,
itemsLength: newDataLength,
currentItemTo: tableLength > newDataLength ? newDataLength : tableLength,
})
}
render() {
return (
<Table
schema={jsonschema}
items={this.state.slicedData}
emptyStateLabel={this.state.emptyStateLabel}
toolbar={{
inputSearch: {
value: this.state.searchValue,
placeholder: 'Search stuff...',
onChange: this.handleInputSearchChange,
onClear: this.handleInputSearchClear,
onSubmit: this.handleInputSearchSubmit,
},
density: {
buttonLabel: 'Line density',
lowOptionLabel: 'Low',
mediumOptionLabel: 'Medium',
highOptionLabel: 'High',
},
download: {
label: 'Export',
handleCallback: () => alert('Callback()'),
},
upload: {
label: 'Import',
handleCallback: () => alert('Callback()'),
},
fields: {
label: 'Toggle visible fields',
showAllLabel: 'Show All',
hideAllLabel: 'Hide All',
onToggleColumn: (params) => {
console.log(params.toggledField)
console.log(params.activeFields)
},
onHideAllColumns: (activeFields) => console.log(activeFields),
onShowAllColumns: (activeFields) => console.log(activeFields),
},
extraActions: {
label: 'More options',
actions: [
{
label: 'An action',
handleCallback: () => alert('An action'),
},
{
label: 'Another action',
handleCallback: () => alert('Another action'),
},
{
label: 'A third action',
handleCallback: () => alert('A third action'),
},
],
},
newLine: {
label: 'New',
handleCallback: () => alert('handle new line callback'),
actions: [
'General',
'Desktop & Screen Saver',
'Dock',
'Language & Region',
].map(label => ({
label,
onClick: () => {},
})),
},
}}
pagination={{
onNextClick: this.handleNextClick,
onPrevClick: this.handlePrevClick,
currentItemFrom: this.state.currentItemFrom,
currentItemTo: this.state.currentItemTo,
onRowsChange: this.handleRowsChange,
textShowRows: 'Show rows',
textOf: 'of',
totalItems: this.state.itemsLength,
rowsOptions: [5, 10, 15, 25],
}}
totalizers={[
{
label: 'Account balance',
value: 23837,
},
{
label: 'Tickets',
value: '$ 36239,05',
iconBackgroundColor: '#eafce3',
icon: <ArrowUp color="#79B03A" size={14} />,
},
{
label: 'Outputs',
value: '-$ 13.485,26',
icon: <ArrowDown size={14} />,
},
{
label: 'Sales',
value: 23837,
isLoading: true,
},
]}
bulkActions={{
texts: {
secondaryActionsLabel: 'Actions',
rowsSelected: qty => (
<React.Fragment>Selected rows: {qty}</React.Fragment>
),
selectAll: 'Select all',
allRowsSelected: qty => (
<React.Fragment>All rows selected: {qty}</React.Fragment>
),
},
totalItems: 122,
onChange: params => console.log(params),
main: {
label: 'Main Action',
handleCallback: params => console.log(params),
},
others: [
{
label: 'Action 1',
handleCallback: params => console.log(params),
},
{
label: 'Action 2',
handleCallback: params => console.log(params),
},
{
label: 'Dangerous action',
isDangerous: true,
handleCallback