From feec96359b7e6c1ba0ff6f52bd1d5096a71ff823 Mon Sep 17 00:00:00 2001 From: Gary Menzel Date: Wed, 15 Nov 2017 20:43:29 +1100 Subject: [PATCH] Refactor complete May need to remove some redundant code --- docs/src/App.js | 22 ++- docs/src/examples/index.js | 2 + docs/src/examples/selecttable/index.js | 16 +- docs/src/examples/selecttreetable/index.js | 216 +++++++++++++++++++++ src/hoc/README.md | 124 +++++++++++- src/hoc/selectTable/index.js | 103 ++++++---- src/hoc/treeTable/index.js | 80 +++++--- 7 files changed, 472 insertions(+), 91 deletions(-) create mode 100644 docs/src/examples/selecttreetable/index.js diff --git a/docs/src/App.js b/docs/src/App.js index 9f089e3..2591876 100644 --- a/docs/src/App.js +++ b/docs/src/App.js @@ -10,13 +10,14 @@ import '../../react-table.css' import Readme from './stories/Readme.js' import HOCReadme from './stories/HOCReadme.js' -import { TreeTable, SelectTable } from './examples/index' - -const exampleStories = [ - // examples - { name: 'TreeTable', component: TreeTable }, - { name: 'SelectTable', component: SelectTable }, -] +// import { TreeTable, SelectTable, SelectTreeTable } from './examples/index' +// +// const exampleStories = [ +// // examples +// { name: 'TreeTable', component: TreeTable }, +// { name: 'SelectTable', component: SelectTable }, +// { name: 'SelectTreeTable', component: SelectTreeTable }, +// ] const stories = [ { name: 'Readme', component: Readme }, @@ -64,10 +65,11 @@ const stories = [ name: 'Multiple Pagers (Top and Bottom)', component: CodeSandbox('VEZ8OgvX'), }, - - // other examples - ...exampleStories, + { name: 'Tree Table', component: CodeSandbox('lxmr4wynzq') }, + { name: 'Select Table', component: CodeSandbox('7yq5ylw09j') }, + { name: 'Select Tree Table', component: CodeSandbox('2p7jp4klwp') }, + ] export default class App extends React.Component { diff --git a/docs/src/examples/index.js b/docs/src/examples/index.js index 53f599d..dd9864d 100644 --- a/docs/src/examples/index.js +++ b/docs/src/examples/index.js @@ -2,8 +2,10 @@ import TreeTable from './treetable' import SelectTable from './selecttable' +import SelectTreeTable from './selecttreetable' export { TreeTable, SelectTable, + SelectTreeTable, } diff --git a/docs/src/examples/selecttable/index.js b/docs/src/examples/selecttable/index.js index 8f8da87..4fc0007 100644 --- a/docs/src/examples/selecttable/index.js +++ b/docs/src/examples/selecttable/index.js @@ -46,7 +46,7 @@ export class ComponentTest extends React.Component { columns: null, selection: [], selectAll: false, - selectType: 'radio', + selectType: 'checkbox', }; } componentDidMount() @@ -111,12 +111,15 @@ export class ComponentTest extends React.Component { if(selectAll) { // we need to get at the internals of ReactTable - const wrappedInstance = this.checkboxTable.getWrappedInstance(); + const wrappedInstance = this.selectTable.getWrappedInstance(); // the 'sortedData' property contains the currently accessible records based on the filter and sort const currentRecords = wrappedInstance.getResolvedState().sortedData; // we just push all the IDs onto the selection array currentRecords.forEach((item)=>{ - selection.push(item._original._id); + if(item._original) + { + selection.push(item._original._id); + } }) } this.setState({selectAll,selection}) @@ -136,7 +139,7 @@ export class ComponentTest extends React.Component { this.setState({ selectType: this.state.selectType === 'radio' ? 'checkbox' : 'radio', selection: [], selectAll: false, }); } render(){ - const { toggleSelection, toggleAll, isSelected, logSelection, toggleType, } = this; + const { toggleSelection, toggleAll, isSelected, logSelection, toggleType } = this; const { data, columns, selectAll, selectType } = this.state; const extraProps = { @@ -148,7 +151,7 @@ export class ComponentTest extends React.Component { } return (
-

react-table - Checkbox Table

+

react-table - Select Table

{` (${this.state.selection.length}) selected`} @@ -157,7 +160,7 @@ export class ComponentTest extends React.Component { this.checkboxTable = r} + ref={(r)=>this.selectTable = r} className="-striped -highlight" {...extraProps} /> @@ -168,5 +171,4 @@ export class ComponentTest extends React.Component { } } -// export default treeTableHOC(ComponentTest); export default ComponentTest; diff --git a/docs/src/examples/selecttreetable/index.js b/docs/src/examples/selecttreetable/index.js new file mode 100644 index 0000000..4766777 --- /dev/null +++ b/docs/src/examples/selecttreetable/index.js @@ -0,0 +1,216 @@ + +import React from 'react'; +import shortid from 'shortid'; + +import ReactTable from '../../../../lib/index' +import '../../../../react-table.css' + +import selectTableHOC from '../../../../lib/hoc/selectTable' +import treeTableHOC from '../../../../lib/hoc/treeTable' + +const SelectTreeTable = selectTableHOC(treeTableHOC(ReactTable)); + +async function getData() +{ + const result = await ( await fetch('/au_500_tree.json') ).json(); + // we are adding a unique ID to the data for tracking the selected records + return result.map((item)=>{ + const _id = shortid.generate(); + return { + _id, + ...item, + } + }); +} + +function getColumns(data) +{ + const columns = []; + const sample = data[0]; + for(let key in sample) + { + if(key==='_id') continue; + columns.push({ + accessor: key, + Header: key, + }) + } + return columns; +} + +function getNodes(data,node=[]) +{ + data.forEach((item)=>{ + if(item.hasOwnProperty('_subRows') && item._subRows) + { + node = getNodes(item._subRows,node); + } else { + node.push(item._original); + } + }); + return node; +} + +export class ComponentTest extends React.Component { + constructor(props) { + super(props); + this.state = + { + data: null, + columns: null, + selection: [], + selectAll: false, + selectType: 'checkbox', + }; + } + componentDidMount() + { + getData().then((data)=>{ + const columns = getColumns(data); + const pivotBy = ['state','post']; + this.setState({ data, columns, pivotBy }); + }); + } + toggleSelection = (key,shift,row) => { + /* + Implementation of how to manage the selection state is up to the developer. + This implementation uses an array stored in the component state. + Other implementations could use object keys, a Javascript Set, or Redux... etc. + */ + // start off with the existing state + if (this.state.selectType === 'radio') { + let selection = []; + if (selection.indexOf(key)<0) selection.push(key); + this.setState({selection}); + } else { + let selection = [ + ...this.state.selection + ]; + const keyIndex = selection.indexOf(key); + // check to see if the key exists + if(keyIndex>=0) { + // it does exist so we will remove it using destructing + selection = [ + ...selection.slice(0,keyIndex), + ...selection.slice(keyIndex+1) + ] + } else { + // it does not exist so add it + selection.push(key); + } + // update the state + this.setState({selection}); + } + } + toggleAll = () => { + /* + 'toggleAll' is a tricky concept with any filterable table + do you just select ALL the records that are in your data? + OR + do you only select ALL the records that are in the current filtered data? + + The latter makes more sense because 'selection' is a visual thing for the user. + This is especially true if you are going to implement a set of external functions + that act on the selected information (you would not want to DELETE the wrong thing!). + + So, to that end, access to the internals of ReactTable are required to get what is + currently visible in the table (either on the current page or any other page). + + The HOC provides a method call 'getWrappedInstance' to get a ref to the wrapped + ReactTable and then get the internal state and the 'sortedData'. + That can then be iterrated to get all the currently visible records and set + the selection state. + */ + const selectAll = this.state.selectAll?false:true; + const selection = []; + if(selectAll) + { + // we need to get at the internals of ReactTable + const wrappedInstance = this.selectTable.getWrappedInstance(); + // the 'sortedData' property contains the currently accessible records based on the filter and sort + const currentRecords = wrappedInstance.getResolvedState().sortedData; + // we need to get all the 'real' (original) records out to get at their IDs + const nodes = getNodes(currentRecords); + // we just push all the IDs onto the selection array + nodes.forEach((item)=>{ + selection.push(item._id); + }) + } + this.setState({selectAll,selection}) + } + isSelected = (key) => { + /* + Instead of passing our external selection state we provide an 'isSelected' + callback and detect the selection state ourselves. This allows any implementation + for selection (either an array, object keys, or even a Javascript Set object). + */ + return this.state.selection.includes(key); + } + logSelection = () => { + console.log('selection:',this.state.selection); + } + toggleType = () => { + this.setState({ selectType: this.state.selectType === 'radio' ? 'checkbox' : 'radio', selection: [], selectAll: false, }); + } + toggleTree = () => { + if(this.state.pivotBy.length) { + this.setState({pivotBy:[],expanded:{}}); + } else { + this.setState({pivotBy:['state','post'],expanded:{}}); + } + } + onExpandedChange = (expanded) => { + this.setState({expanded}); + } + render(){ + const { toggleSelection, toggleAll, isSelected, logSelection, toggleType, toggleTree, onExpandedChange, } = this; + const { data, columns, selectAll, selectType, pivotBy, expanded, } = this.state; + const extraProps = + { + selectAll, + isSelected, + toggleAll, + toggleSelection, + selectType, + pivotBy, + expanded, + onExpandedChange, + pageSize: 5, + } + return ( +
+

react-table - Select Tree Table

+

This example combines two HOCs (the TreeTable and the SelectTable) to make a composite component.

+

We'll call it SelectTreeTable!

+

Here is what the buttons do:

+
    +
  • Toggle Tree: enables or disabled the pivotBy on the table.
  • +
  • Select Type: changes from 'checkbox' to 'radio' and back again.
  • +
  • Log Selection to Console: open your console to see what has been selected.
  • +
+

+ NOTE: the selection is maintained when toggling the tree on and off but is cleared + when switching between select types (radio, checkbox). +

+ + + + {` (${this.state.selection.length}) selected`} + { + data? + this.selectTable = r} + className="-striped -highlight" + freezeWhenExpanded={true} + {...extraProps} + /> + :null + } +
+ ); + } +} + +export default ComponentTest; diff --git a/src/hoc/README.md b/src/hoc/README.md index 8b49fc3..69711c0 100644 --- a/src/hoc/README.md +++ b/src/hoc/README.md @@ -1,6 +1,122 @@ -# This is the readme for HOCs with ReactTable +
+ React Table Logo +
-## TO DO -- Document the HOCs -- Document the standard for writing HOCs with ReactTable +# ReactTable - expanding with HOCs +This documentation is about expanding ReactTable using Higher Order Components/Functions. + +## Covered in this README +- A Brief explanation of HOCs and why they are a good approach for ReactTable enhancements +- Documentation of the currently available HOCs + - TreeTable + - SelectTable +- Documentation of the standard for writing HOCs with ReactTable + +## What are HOCs and why use them with ReactTable +HOCs (or Higher Order Components/Functions) are either a React Component (or a function that returns a React Component) +that are used to enhance the functionality of an existing component. How much you can enhance depends on the props that +the component exposes. + +Fortunately, ReactTable exposes a LOT of functionality as props to the component. In some cases there are too many +props to keep track of and that is where HOCs come in. + +You can write a HOC that just focusses on the additional functionality you want to enhance and keep those enhancements to +reuse over and over again when you need them. You don't have to edit the ReactSource code, just wrap ReactTable in one or +more HOCs (more on some issues related to chaining HOCs later) that provide the additional functionality you want to expose. + +The most obvious HOC is one that can add `checkbox` or select functionality. The HOC included provides `select` functionality +that allows the developer to specify if they want a `checkbox` or `radio` style of select column. The implementation of the +selection is recorded (e.g. in component state, Redux, etc.) and how to manage multiple selections. The HOC really only handles +the rendering pieces. + +But there is more documentation on the `select` HOC below. + + +## Currently Available HOCs + +### TreeTable +TreeTable takes over the rendering of the generated pivot rows of ReactTable so that they appear more like an expandable Tree. + +It accomplishes this by rendering a 100% wide div and then only rendering the cell that controls the pivot at that level. + +Using it is as simple as doing the following: +```javascript +import ReactTable from 'react-table' +import treeTableHOC from 'react-table/lib/hoc/treeTable' + +const TreeTable = treeTableHOC(ReactTable) +``` +After you have done the above, you can then use `TreeTable` just as you would `ReactTable` but it will render pivots using +the Tree style described above. + + +### SelectTable +SelectTable is a little trickier. The HOCs attempt to avoid adding additional state and, as there is no internal ID for a row that +can be relied on to be static (ReactTable just reuses indexes when rendering) the developer has to maintain the state outside of even +the wrapped component. So it is largely based on callbacks. + +You include the HOC in the same manner as you would for the treeTableHOC but then need to provide the following overrides: +- isSelected - returns `true` if the key passed is selected otherwise it should return `false` +- selectAll - a property that indicates if the selectAll is set (`true|false`) +- toggleAll - called when the user clicks the `selectAll` checkbox/radio +- toggleSelection - called when the use clicks a specific checkbox/radio in a row +- selectType - either `checkbox|radio` to indicate what type of selection is required + +In the case of `radio` there is no `selectAll` displayed but the developer is responsible for only making one selection in +the controlling component's state. You could select multiple but it wouldn't make sense and you should use `checkbox` instead. + +You also have to decide what `selectAll` means. Given ReactTable is a paged solution there are other records off-page. When someone +selects the `selectAll` checkbox, should it mark every possible record, only what might be visible to due a Filter or only those items +on the current page? + +The example opts for the middle approach so it gets a `ref` to the ReactTable instance and pulls the `sortedData` out of the resolved +state (then walks through those records and pulls their ID into the `selection` state of the controlling component). + +You can also replace the input component that is used to render the select box and select all box: +- SelectAllInputComponent - the checkbox in the top left corner +- SelectInputComponent - the checkbox used on a row + +### SelectTreeTable +SelectTreeTable is a combination of TreeTable and SelectTable. + +To function correctly the chain has to be in the correct order as follows (see the comments in the guid on HOCs below). + +```Javascript +const SelectTreeTable = selectTableHOC(treeTableHOC(ReactTable)); +``` + +In this particular instance it is (probably) because the functions need access to the state on the wrapped component to manage +the selected items. Although that is not totally clearly the issue. + +## HOC Guide for ReactTable +There are a few rules required when writing a HOC for ReactTable (other than meeting the normal lint standards - which are +still being developed). + +Firstly, there are issues with `ref` when you write a HOC. Consider a deeply nested component wrapped in multiple HOCs... + +A HOC in the middle of the chain requires access to the instance of the component it thinks it is wrapping but there is at +least one other wrapper in the way. The challenge is: How do I get to the actual wrapped component? + +Each HOC is required to be a React Class so that a `ref` can be obtained against each component: + +```Javascript + this.wrappedInstance = r} /> +``` +*NOTE:* "Component" can also be the `` instance. + +Then the following method needs +to be placed on the class so that it exposes the correct instance of ReactTable: + +```Javascript +getWrappedInstance() { + if (!this.wrappedInstance) console.warn(' - No wrapped instance') + if (this.wrappedInstance.getWrappedInstance) return this.wrappedInstance.getWrappedInstance() + else return this.wrappedInstance +} +``` +Essentially this will walk down the chain (if there are chained HOCs) and stop when it gets to the end and return the wrapped instance. + +Finally, sometimes the chains need to be in a specific order to function correctly. It is not clear if this is just an architectural +issue or if it would be better solved using a library like `recompose`. Anyone who is able to contribute a reliable solution to this +is welcome to submit a PR. diff --git a/src/hoc/selectTable/index.js b/src/hoc/selectTable/index.js index 912c17d..27ca3b5 100644 --- a/src/hoc/selectTable/index.js +++ b/src/hoc/selectTable/index.js @@ -2,60 +2,79 @@ import React from 'react'; +const defaultSelectInputComponent = (props) => { + return ( + { + const { shiftKey } = e; + e.stopPropagation(); + props.onClick(props.id, shiftKey, props.row); + }} + onChange={()=>{}} + /> + ) +} + export default (Component) => { - const wrapper = class RTCheckboxTable extends React.Component { - // we only need a Component so we can get the 'ref' - pure components can't get a 'ref' + const wrapper = class RTSelectTable extends React.Component { - rowSelector = (row) => + constructor(props) { - if(!row || !row.hasOwnProperty(this.props.keyField)) return null; - const checked = this.props.isSelected(row[this.props.keyField]); - return ( - { - const { shiftKey } = e; - e.stopPropagation(); - this.props.toggleSelection(row[this.props.keyField],shiftKey,row); - }} - onChange={()=>{}} - value='' - /> - ); + super(props); } - headSelector = (row) => - { - if (this.props.selectType === 'radio') return null; - const checked = this.props.selectAll; - return ( - { - e.stopPropagation(); - this.props.toggleAll(); - }} - onChange={()=>{}} - value='' - /> - ); + rowSelector(row) { + if(!row || !row.hasOwnProperty(this.props.keyField)) return null; + const { toggleSelection, selectType, keyField } = this.props; + const checked = this.props.isSelected(row[this.props.keyField]); + const inputProps = + { + checked, + onClick: toggleSelection, + selectType, + id: row[keyField], + row, + } + return React.createElement(this.props.SelectInputComponent,inputProps); + } + + headSelector(row) { + const { selectType } = this.props; + if (selectType === 'radio') return null; + + const { toggleAll, selectAll: checked, SelectAllInputComponent, } = this.props; + const inputProps = + { + checked, + onClick: toggleAll, + selectType, + } + + return React.createElement(SelectAllInputComponent,inputProps); } // this is so we can expose the underlying ReactTable to get at the sortedData for selectAll - getWrappedInstance = ()=>this.wrappedInstance + getWrappedInstance() { + if (!this.wrappedInstance) console.warn('RTSelectTable - No wrapped instance'); + if (this.wrappedInstance.getWrappedInstance) return this.wrappedInstance.getWrappedInstance(); + else return this.wrappedInstance + } render() { - const { columns:originalCols, isSelected, toggleSelection, toggleAll, keyField, selectAll, ...rest } = this.props; - const { rowSelector, headSelector, } = this; + const { + columns:originalCols, isSelected, toggleSelection, toggleAll, keyField, selectAll, + selectType, SelectAllInputComponent, SelectInputComponent, + ...rest + } = this.props; const select = { id: '_selector', accessor: ()=>'x', // this value is not important - Header: headSelector, - Cell: (ci) => { return rowSelector(ci.original); }, + Header: this.headSelector.bind(this), + Cell: (ci) => { return this.rowSelector.bind(this)(ci.original); }, width: 30, filterable: false, sortable: false, @@ -75,7 +94,7 @@ export default (Component) => { } } - wrapper.displayName = 'RTCheckboxTable'; + wrapper.displayName = 'RTSelectTable'; wrapper.defaultProps = { keyField: '_id', @@ -83,7 +102,9 @@ export default (Component) => { selectAll: false, toggleSelection: (key, shift, row)=>{ console.log('No toggleSelection handler provided:', { key, shift, row }) }, toggleAll: () => { console.log('No toggleAll handler provided.') }, - selectType: 'radio', + selectType: 'check', + SelectInputComponent: defaultSelectInputComponent, + SelectAllInputComponent: defaultSelectInputComponent, } return wrapper; diff --git a/src/hoc/treeTable/index.js b/src/hoc/treeTable/index.js index 5254cb6..6c9fa7d 100644 --- a/src/hoc/treeTable/index.js +++ b/src/hoc/treeTable/index.js @@ -3,56 +3,78 @@ import React from 'react' export default (Component) => { - const wrapper = (componentProps) => { - const TrComponent = (props) => { - const { ri, ...rest } = props; + const wrapper = class RTTreeTable extends React.Component { + + constructor(props) + { + super(props); + this.getWrappedInstance.bind(this); + this.TrComponent.bind(this); + this.getTrProps.bind(this); + } + + // this is so we can expose the underlying ReactTable to get at the sortedData for selectAll + getWrappedInstance = () => { + if (!this.wrappedInstance) console.warn('RTTreeTable - No wrapped instance'); + if (this.wrappedInstance.getWrappedInstance) return this.wrappedInstance.getWrappedInstance(); + else return this.wrappedInstance + } + + TrComponent = (props) => { + const { + ri, + ...rest + } = props; if(ri && ri.groupedByPivot) { - const cell = props.children[ri.level]; + const cell = {...props.children[ri.level]}; cell.props.style.flex = 'unset'; cell.props.style.width = '100%'; cell.props.style.maxWidth = 'unset'; - cell.props.style.paddingLeft = `${componentProps.treeTableIndent*ri.level}px`; + cell.props.style.paddingLeft = `${this.props.treeTableIndent*ri.level}px`; // cell.props.style.backgroundColor = '#DDD'; cell.props.style.borderBottom = '1px solid rgba(128,128,128,0.2)'; - return
{cell}
; + return
{cell}
; } return ; } - const getTrProps = (state,ri,ci,instance) => { + getTrProps = (state,ri,ci,instance) => { return {ri}; } - const { columns, ...rest } = componentProps; - const extra = { - columns: columns.map((col)=>{ - let column = col; - if(rest.pivotBy && rest.pivotBy.includes(col.accessor)) - { - column = { - accessor: col.accessor, - width: `${componentProps.treeTableIndent}px`, - show: false, - Header: '', + render() { + const { columns, treeTableIndent, ...rest } = this.props; + const { TrComponent, getTrProps } = this; + const extra = { + columns: columns.map((col)=>{ + let column = col; + if(rest.pivotBy && rest.pivotBy.includes(col.accessor)) + { + column = { + accessor: col.accessor, + width: `${treeTableIndent}px`, + show: false, + Header: '', + } } - } - return column; - }), - TrComponent, - getTrProps, - }; - - return ( - - ) + return column; + }), + TrComponent, + getTrProps, + }; + + return ( + this.wrappedInstance=r }/> + ) + } } wrapper.displayName = 'RTTreeTable'; wrapper.defaultProps = { - treeTableRowBackground: '#EEE', treeTableIndent: 10, } + return wrapper; }