* implement table sort

* path for component test for table sort

* add store/base test

* add store/sort test

* add story for sort

* add column.sort and column.sortFunc
This commit is contained in:
Allen 2017-09-16 01:33:25 -05:00 committed by GitHub
parent ec1a927001
commit 2e10cb132e
20 changed files with 615 additions and 22 deletions

View File

@ -10,13 +10,15 @@ Available properties in a column object:
* [hidden](#hidden)
* [formatter](#formatter)
* [formatExtraData](#formatExtraData)
* [headerFormatter](#headerFormatter)
* [sort](#sort)
* [sortFunc](#sortFunc)
* [classes](#classes)
* [style](#style)
* [title](#title)
* [events](#events)
* [align](#align)
* [attrs](#attrs)
* [headerFormatter](#headerFormatter)
* [headerClasses](#headerClasses)
* [headerStyle](#headerStyle)
* [headerTitle](#headerTitle)
@ -85,6 +87,24 @@ dataField: 'address.city'
## <a name='formatExtraData'>column.formatExtraData - [Any]</a>
It's only used for [`column.formatter`](#formatter), you can define any value for it and will be passed as fourth argument for [`column.formatter`](#formatter) callback function.
## <a name='sort'>column.sort - [Bool]</a>
Enable the column sort via a `true` value given.
## <a name='sortFunc'>column.sortFunc - [Function]</a>
`column.sortFunc` only work when `column.sort` is enable. `sortFunc` allow you to define your sorting algorithm. This callback function accept four arguments:
```js
{
// omit...
sort: true,
sortFunc: (a, b, order, dataField) => {
if (order === 'asc') return a - b;
else return b - a;
}
}
```
> The possible value of `order` argument is **`asc`** and **`desc`**.
## <a name='classes'>column.classes - [String | Function]</a>
It's availabe to have custom class on table column:

View File

@ -0,0 +1,60 @@
/* eslint no-unused-vars: 0 */
import React from 'react';
import { BootstrapTableful } from 'react-bootstrap-table2';
import Code from 'components/common/code-block';
import { productsGenerator } from 'utils/common';
const products = productsGenerator();
const columns = [{
dataField: 'id',
text: 'Product ID',
sort: true,
// here, we implement a custom sort which perform a reverse sorting
sortFunc: (a, b, order, dataField) => {
if (order === 'asc') {
return b - a;
}
return a - b; // desc
}
}, {
dataField: 'name',
text: 'Product Name',
sort: true
}, {
dataField: 'price',
text: 'Product Price'
}];
const sourceCode = `\
const columns = [{
dataField: 'id',
text: 'Product ID',
sort: true,
// here, we implement a custom sort which perform a reverse sorting
sortFunc: (a, b, order, dataField) => {
if (order === 'asc') {
return b - a;
}
return a - b; // desc
}
}, {
dataField: 'name',
text: 'Product Name',
sort: true
}, {
dataField: 'price',
text: 'Product Price'
}];
<BootstrapTableful keyField='id' data={ products } columns={ columns } />
`;
export default () => (
<div>
<BootstrapTableful keyField="id" data={ products } columns={ columns } />
<Code>{ sourceCode }</Code>
</div>
);

View File

@ -0,0 +1,44 @@
import React from 'react';
import { BootstrapTableful } from 'react-bootstrap-table2';
import Code from 'components/common/code-block';
import { productsGenerator } from 'utils/common';
const products = productsGenerator();
const columns = [{
dataField: 'id',
text: 'Product ID',
sort: true
}, {
dataField: 'name',
text: 'Product Name',
sort: true
}, {
dataField: 'price',
text: 'Product Price'
}];
const sourceCode = `\
const columns = [{
dataField: 'id',
text: 'Product ID',
sort: true
}, {
dataField: 'name',
text: 'Product Name',
sort: true
}, {
dataField: 'price',
text: 'Product Price'
}];
<BootstrapTableful keyField='id' data={ products } columns={ columns } />
`;
export default () => (
<div>
<BootstrapTableful keyField="id" data={ products } columns={ columns } />
<Code>{ sourceCode }</Code>
</div>
);

View File

@ -31,6 +31,10 @@ import HeaderColumnClassTable from 'examples/header-columns/column-class-table';
import HeaderColumnStyleTable from 'examples/header-columns/column-style-table';
import HeaderColumnAttrsTable from 'examples/header-columns/column-attrs-table';
// table sort
import EnableSortTable from 'examples/sort/enable-sort-table';
import CustomSortTable from 'examples/sort/custom-sort-table';
// css style
import 'bootstrap/dist/css/bootstrap.min.css';
import 'stories/stylesheet/tomorrow.min.css';
@ -69,3 +73,7 @@ storiesOf('Work on Header Columns', module)
.add('Customize Column Class', () => <HeaderColumnClassTable />)
.add('Customize Column Style', () => <HeaderColumnStyleTable />)
.add('Customize Column HTML attribute', () => <HeaderColumnAttrsTable />);
storiesOf('Sort Table', module)
.add('Enable Sort', () => <EnableSortTable />)
.add('Custom Sort Fuction', () => <CustomSortTable />);

View File

@ -1,3 +1,4 @@
/* eslint arrow-body-style: 0 */
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cs from 'classnames';
@ -13,11 +14,15 @@ class BootstrapTable extends PropsBaseResolver(Component) {
this.validateProps();
const { store } = this.props;
this.store = !store ? new Store(props) : store;
this.handleSort = this.handleSort.bind(this);
this.state = {
data: this.store.get()
};
}
render() {
const {
data,
columns,
keyField,
striped,
@ -37,9 +42,14 @@ class BootstrapTable extends PropsBaseResolver(Component) {
return (
<div className="react-bootstrap-table-container">
<table className={ tableClass }>
<Header columns={ columns } />
<Header
columns={ columns }
sortField={ this.store.sortField }
sortOrder={ this.store.sortOrder }
onSort={ this.handleSort }
/>
<Body
data={ data }
data={ this.state.data }
keyField={ keyField }
columns={ columns }
isEmpty={ this.isEmpty() }
@ -50,6 +60,16 @@ class BootstrapTable extends PropsBaseResolver(Component) {
</div>
);
}
handleSort(column) {
this.store.sortBy(column);
this.setState(() => {
return {
data: this.store.get()
};
});
}
}
BootstrapTable.propTypes = {

View File

@ -0,0 +1,4 @@
export default {
SORT_ASC: 'asc',
SORT_DESC: 'desc'
};

View File

@ -1,12 +1,25 @@
/* eslint react/require-default-props: 0 */
import React from 'react';
import cs from 'classnames';
import PropTypes from 'prop-types';
import Const from './const';
import SortSymbol from './sort-symbol';
import SortCaret from './sort-caret';
import _ from './utils';
const HeaderCell = ({ column, index }) => {
const HeaderCell = (props) => {
const {
column,
index,
onSort,
sorting,
sortOrder
} = props;
const {
text,
sort,
hidden,
headerTitle,
headerAlign,
@ -25,6 +38,7 @@ const HeaderCell = ({ column, index }) => {
const cellClasses = _.isFunction(headerClasses) ? headerClasses(column, index) : headerClasses;
let cellStyle = {};
let sortSymbol;
if (headerStyle) {
cellStyle = _.isFunction(headerStyle) ? headerStyle(column, index) : headerStyle;
@ -46,9 +60,24 @@ const HeaderCell = ({ column, index }) => {
if (!_.isEmptyObject(cellStyle)) cellAttrs.style = cellStyle;
if (sort) {
const customClick = cellAttrs.onClick;
cellAttrs.onClick = (e) => {
onSort(column);
if (_.isFunction(customClick)) customClick(e);
};
cellAttrs.className = cs(cellAttrs.className, 'sortable');
if (sorting) {
sortSymbol = <SortCaret order={ sortOrder } />;
} else {
sortSymbol = <SortSymbol />;
}
}
return (
<th { ...cellAttrs }>
{ children }
{ children }{ sortSymbol }
</th>
);
};
@ -68,9 +97,14 @@ HeaderCell.propTypes = {
headerEvents: PropTypes.object,
events: PropTypes.object,
headerAlign: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
align: PropTypes.oneOfType([PropTypes.string, PropTypes.func])
align: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
sort: PropTypes.bool,
sortFunc: PropTypes.func
}).isRequired,
index: PropTypes.number.isRequired
index: PropTypes.number.isRequired,
onSort: PropTypes.func,
sorting: PropTypes.bool,
sortOrder: PropTypes.oneOf([Const.SORT_ASC, Const.SORT_DESC])
};
export default HeaderCell;

View File

@ -1,22 +1,44 @@
/* eslint react/require-default-props: 0 */
import React from 'react';
import PropTypes from 'prop-types';
import HeaderCell from './header-cell';
const Header = ({ columns }) => (
<thead>
<tr>
{
columns.map((column, i) =>
<HeaderCell key={ column.dataField } column={ column } index={ i } />)
}
</tr>
</thead>
);
const Header = (props) => {
const {
columns,
onSort,
sortField,
sortOrder
} = props;
return (
<thead>
<tr>
{
columns.map((column, i) => {
const currSort = column.dataField === sortField;
return (
<HeaderCell
index={ i }
key={ column.dataField }
column={ column }
onSort={ onSort }
sorting={ currSort }
sortOrder={ sortOrder }
/>);
})
}
</tr>
</thead>
);
};
Header.propTypes = {
columns: PropTypes.array.isRequired
columns: PropTypes.array.isRequired,
onSort: PropTypes.func,
sortField: PropTypes.string,
sortOrder: PropTypes.string
};
export default Header;

View File

@ -0,0 +1,21 @@
import React from 'react';
import cs from 'classnames';
import PropTypes from 'prop-types';
import Const from './const';
const SortCaret = ({ order }) => {
const orderClass = cs('react-bootstrap-table-sort-order', {
dropup: order === Const.SORT_ASC
});
return (
<span className={ orderClass }>
<span className="caret" />
</span>
);
};
SortCaret.propTypes = {
order: PropTypes.oneOf([Const.SORT_ASC, Const.SORT_DESC]).isRequired
};
export default SortCaret;

View File

@ -0,0 +1,13 @@
import React from 'react';
const SortSymbol = () => (
<span className="order">
<span className="dropdown">
<span className="caret" />
</span>
<span className="dropup">
<span className="caret" />
</span>
</span>);
export default SortSymbol;

View File

@ -1,10 +1,31 @@
import { sort } from './sort';
import Const from '../const';
export default class Store {
constructor(props) {
const { data } = props;
this.data = data ? data.slice() : [];
this.sortOrder = undefined;
this.sortField = undefined;
}
isEmpty() {
return this.data.length === 0;
}
sortBy({ dataField, sortFunc }) {
if (dataField !== this.sortField) {
this.sortOrder = Const.SORT_DESC;
} else {
this.sortOrder = this.sortOrder === Const.SORT_DESC ? Const.SORT_ASC : Const.SORT_DESC;
}
this.data = sort(dataField, this.data, this.sortOrder, sortFunc);
this.sortField = dataField;
}
get() {
return this.data;
}
}

View File

@ -0,0 +1,40 @@
/* eslint no-nested-ternary: 0 */
/* eslint no-lonely-if: 0 */
/* eslint no-underscore-dangle: 0 */
import _ from '../utils';
import Const from '../const';
function comparator(a, b) {
let result;
if (typeof b === 'string') {
result = b.localeCompare(a);
} else {
result = a > b ? -1 : ((a < b) ? 1 : 0);
}
return result;
}
const sort = (dataField, data, order, sortFunc) => {
const _data = [...data];
_data.sort((a, b) => {
let result;
let valueA = _.get(a, dataField);
let valueB = _.get(b, dataField);
valueA = _.isDefined(valueA) ? valueA : '';
valueB = _.isDefined(valueB) ? valueB : '';
if (sortFunc) {
result = sortFunc(valueA, valueB, order, dataField);
} else {
if (order === Const.SORT_DESC) {
result = comparator(valueA, valueB);
} else {
result = comparator(valueB, valueA);
}
}
return result;
});
return _data;
};
export { sort };

View File

@ -40,9 +40,14 @@ function isEmptyObject(obj) {
return true;
}
function isDefined(value) {
return typeof value !== 'undefined' && value !== null;
}
export default {
get,
isFunction,
isObject,
isEmptyObject
isEmptyObject,
isDefined
};

View File

@ -1,5 +1,24 @@
.react-bootstrap-table-container {
th.sortable {
cursor: pointer;
}
th > .order > .dropdown > .caret {
margin: 10px 0 10px 5px;
color: #cccccc;
}
th > .order > .dropup > .caret {
margin: 10px 0;
color: #cccccc;
}
th > .react-bootstrap-table-sort-order > .caret {
margin: 10px 6.5px;
}
td.react-bs-table-no-data {
text-align: center
}
text-align: center;
}
}

View File

@ -2,6 +2,9 @@ import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import Const from '../src/const';
import SortCaret from '../src/sort-caret';
import SortSymbol from '../src/sort-symbol';
import HeaderCell from '../src/header-cell';
describe('HeaderCell', () => {
@ -385,4 +388,75 @@ describe('HeaderCell', () => {
});
});
});
describe('when column.sort is enable', () => {
let column;
let onSortCallBack;
beforeEach(() => {
column = {
dataField: 'id',
text: 'ID',
sort: true
};
onSortCallBack = sinon.stub().withArgs(column);
wrapper = shallow(<HeaderCell column={ column } index={ index } onSort={ onSortCallBack } />);
});
it('should have sortable class on header cell', () => {
expect(wrapper.hasClass('sortable')).toBe(true);
});
it('should have onClick event on header cell', () => {
expect(wrapper.find('th').prop('onClick')).toBeDefined();
});
it('should trigger onSort callback when click on header cell', () => {
wrapper.find('th').simulate('click');
expect(onSortCallBack.callCount).toBe(1);
});
describe('and sorting prop is false', () => {
it('header should render SortSymbol as default', () => {
expect(wrapper.find(SortSymbol).length).toBe(1);
});
});
describe('and sorting prop is true', () => {
[Const.SORT_ASC, Const.SORT_DESC].forEach((order) => {
describe(`and sortOrder is ${order}`, () => {
beforeEach(() => {
wrapper = shallow(
<HeaderCell column={ column } index={ index } sortOrder={ order } sorting />);
});
it('should render SortCaret correctly', () => {
expect(wrapper.find(SortCaret).length).toBe(1);
expect(wrapper.find(SortCaret).prop('order')).toEqual(order);
});
});
});
});
describe('when column.headerEvents prop is defined and have custom onClick', () => {
beforeEach(() => {
column = {
dataField: 'id',
text: 'ID',
sort: true,
headerEvents: {
onClick: sinon.stub()
}
};
wrapper = shallow(
<HeaderCell column={ column } index={ index } onSort={ onSortCallBack } />);
});
it('custom event hook should still be called when triggering sorting', () => {
wrapper.find('th').simulate('click');
expect(onSortCallBack.callCount).toBe(1);
expect(column.headerEvents.onClick.callCount).toBe(1);
});
});
});
});

View File

@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
import HeaderCell from '../src/header-cell';
import Header from '../src/header';
import Const from '../src/const';
describe('Header', () => {
let wrapper;
@ -25,4 +26,21 @@ describe('Header', () => {
expect(wrapper.find(HeaderCell).length).toBe(columns.length);
});
});
describe('header with columns enable sort', () => {
const sortField = columns[1].dataField;
beforeEach(() => {
wrapper = shallow(
<Header columns={ columns } sortField={ sortField } sortOrder={ Const.SORT_ASC } />);
});
it('The HeaderCell should receive correct sorting props', () => {
const headerCells = wrapper.find(HeaderCell);
expect(headerCells.length).toBe(columns.length);
expect(headerCells.at(0).prop('sorting')).toBe(false);
expect(headerCells.at(1).prop('sorting')).toBe(true);
expect(headerCells.at(1).prop('sortOrder')).toBe(Const.SORT_ASC);
});
});
});

View File

@ -0,0 +1,37 @@
import React from 'react';
import { shallow } from 'enzyme';
import Const from '../src/const';
import SortCaret from '../src/sort-caret';
describe('SortCaret', () => {
let wrapper;
describe(`when order prop is ${Const.SORT_ASC}`, () => {
beforeEach(() => {
wrapper = shallow(
<SortCaret order={ Const.SORT_ASC } />);
});
it('should render caret correctly', () => {
expect(wrapper.length).toBe(1);
expect(wrapper.find('span').length).toBe(2);
expect(wrapper.find('.caret').length).toBe(1);
expect(wrapper.find('.dropup').length).toBe(1);
});
});
describe(`when order prop is ${Const.SORT_DESC}`, () => {
beforeEach(() => {
wrapper = shallow(
<SortCaret order={ Const.SORT_DESC } />);
});
it('should render caret correctly', () => {
expect(wrapper.length).toBe(1);
expect(wrapper.find('span').length).toBe(2);
expect(wrapper.find('.caret').length).toBe(1);
expect(wrapper.find('.dropup').length).toBe(0);
});
});
});

View File

@ -0,0 +1,19 @@
import React from 'react';
import { shallow } from 'enzyme';
import SortSymbol from '../src/sort-symbol';
describe('SortSymbol', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(
<SortSymbol />);
});
it('should render sort symbol correctly', () => {
expect(wrapper.length).toBe(1);
expect(wrapper.find('.order').length).toBe(1);
expect(wrapper.find('.caret').length).toBe(2);
expect(wrapper.find('.dropdown').length).toBe(1);
expect(wrapper.find('.dropup').length).toBe(1);
});
});

View File

@ -0,0 +1,75 @@
import Base from '../../src/store/base';
import Const from '../../src/const';
describe('Store Base', () => {
let store;
const data = [
{ id: 3, name: 'name2' },
{ id: 2, name: 'ABC' },
{ id: 4, name: '123tester' },
{ id: 1, name: '!@#' }
];
beforeEach(() => {
store = new Base({ data });
});
describe('initialize', () => {
it('should have correct initialize data', () => {
expect(store.sortOrder).toBeUndefined();
expect(store.sortField).toBeUndefined();
expect(store.data.length).toEqual(data.length);
});
});
describe('isEmpty', () => {
beforeEach(() => {
store = new Base({ data: [] });
});
it('should have correct initialize data', () => {
expect(store.isEmpty()).toBeTruthy();
});
});
describe('sortBy', () => {
let dataField;
beforeEach(() => {
dataField = 'name';
});
it('should change sortField by dataField param', () => {
store.sortBy({ dataField });
expect(store.sortField).toEqual(dataField);
});
it('should change sortOrder correctly when sortBy same dataField', () => {
store.sortBy({ dataField });
expect(store.sortOrder).toEqual(Const.SORT_DESC);
store.sortBy({ dataField });
expect(store.sortOrder).toEqual(Const.SORT_ASC);
});
it('should change sortOrder correctly when sortBy different dataField', () => {
store.sortBy({ dataField });
expect(store.sortOrder).toEqual(Const.SORT_DESC);
dataField = 'id';
store.sortBy({ dataField });
expect(store.sortOrder).toEqual(Const.SORT_DESC);
dataField = 'name';
store.sortBy({ dataField });
expect(store.sortOrder).toEqual(Const.SORT_DESC);
});
it('should have correct result after sortBy', () => {
store.sortBy({ dataField });
const result = store.data.map(e => e[dataField]).sort((a, b) => b - a);
store.get().forEach((e, i) => {
expect(e[dataField]).toEqual(result[i]);
});
});
});
});

View File

@ -0,0 +1,39 @@
import sinon from 'sinon';
import { sort } from '../../src/store/sort';
import Const from '../../src/const';
describe('Sort Function', () => {
const data = [
{ id: 3, name: 'name2' },
{ id: 2, name: 'ABC' },
{ id: 4, name: '123tester' },
{ id: 1, name: '!@#' }
];
it('should sort array with ASC order correctly', () => {
const result = sort('id', data, Const.SORT_ASC);
expect(result.length).toEqual(data.length);
const sortedArray = data.map(e => e.id).sort((a, b) => a - b);
sortedArray.forEach((e, i) => {
expect(e).toEqual(result[i].id);
});
});
it('should sort array with DESC order correctly', () => {
const result = sort('id', data, Const.SORT_DESC);
expect(result.length).toEqual(data.length);
const sortedArray = data.map(e => e.id).sort((a, b) => b - a);
sortedArray.forEach((e, i) => {
expect(e).toEqual(result[i].id);
});
});
it('should call custom sort function when sortFunc given', () => {
const sortFunc = sinon.stub().returns(1);
sort('id', data, Const.SORT_DESC, sortFunc);
expect(sortFunc.callCount).toBe(6);
});
});