diff --git a/docs/columns.md b/docs/columns.md index 5e7a8b0..a517532 100644 --- a/docs/columns.md +++ b/docs/columns.md @@ -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' ## column.formatExtraData - [Any] 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. +## column.sort - [Bool] +Enable the column sort via a `true` value given. + +## column.sortFunc - [Function] +`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`**. + ## column.classes - [String | Function] It's availabe to have custom class on table column: diff --git a/packages/react-bootstrap-table2-example/examples/sort/custom-sort-table.js b/packages/react-bootstrap-table2-example/examples/sort/custom-sort-table.js new file mode 100644 index 0000000..d362e8d --- /dev/null +++ b/packages/react-bootstrap-table2-example/examples/sort/custom-sort-table.js @@ -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' +}]; + + +`; + +export default () => ( +
+ + { sourceCode } +
+); diff --git a/packages/react-bootstrap-table2-example/examples/sort/enable-sort-table.js b/packages/react-bootstrap-table2-example/examples/sort/enable-sort-table.js new file mode 100644 index 0000000..255414b --- /dev/null +++ b/packages/react-bootstrap-table2-example/examples/sort/enable-sort-table.js @@ -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' +}]; + + +`; + +export default () => ( +
+ + { sourceCode } +
+); diff --git a/packages/react-bootstrap-table2-example/stories/index.js b/packages/react-bootstrap-table2-example/stories/index.js index 16c3bae..8e40fcf 100644 --- a/packages/react-bootstrap-table2-example/stories/index.js +++ b/packages/react-bootstrap-table2-example/stories/index.js @@ -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', () => ) .add('Customize Column Style', () => ) .add('Customize Column HTML attribute', () => ); + +storiesOf('Sort Table', module) + .add('Enable Sort', () => ) + .add('Custom Sort Fuction', () => ); diff --git a/packages/react-bootstrap-table2/src/bootstrap-table.js b/packages/react-bootstrap-table2/src/bootstrap-table.js index f60f519..5acfb2f 100644 --- a/packages/react-bootstrap-table2/src/bootstrap-table.js +++ b/packages/react-bootstrap-table2/src/bootstrap-table.js @@ -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 (
-
+
); } + + handleSort(column) { + this.store.sortBy(column); + + this.setState(() => { + return { + data: this.store.get() + }; + }); + } } BootstrapTable.propTypes = { diff --git a/packages/react-bootstrap-table2/src/const.js b/packages/react-bootstrap-table2/src/const.js new file mode 100644 index 0000000..acc75d2 --- /dev/null +++ b/packages/react-bootstrap-table2/src/const.js @@ -0,0 +1,4 @@ +export default { + SORT_ASC: 'asc', + SORT_DESC: 'desc' +}; diff --git a/packages/react-bootstrap-table2/src/header-cell.js b/packages/react-bootstrap-table2/src/header-cell.js index be2e525..d419710 100644 --- a/packages/react-bootstrap-table2/src/header-cell.js +++ b/packages/react-bootstrap-table2/src/header-cell.js @@ -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 = ; + } else { + sortSymbol = ; + } + } + return (
); }; @@ -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; diff --git a/packages/react-bootstrap-table2/src/header.js b/packages/react-bootstrap-table2/src/header.js index eddf6a2..36752f1 100644 --- a/packages/react-bootstrap-table2/src/header.js +++ b/packages/react-bootstrap-table2/src/header.js @@ -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 }) => ( - - - { - columns.map((column, i) => - ) - } - - -); +const Header = (props) => { + const { + columns, + onSort, + sortField, + sortOrder + } = props; + return ( + + + { + columns.map((column, i) => { + const currSort = column.dataField === sortField; + return ( + ); + }) + } + + + ); +}; Header.propTypes = { - columns: PropTypes.array.isRequired + columns: PropTypes.array.isRequired, + onSort: PropTypes.func, + sortField: PropTypes.string, + sortOrder: PropTypes.string }; export default Header; diff --git a/packages/react-bootstrap-table2/src/sort-caret.js b/packages/react-bootstrap-table2/src/sort-caret.js new file mode 100644 index 0000000..08a918a --- /dev/null +++ b/packages/react-bootstrap-table2/src/sort-caret.js @@ -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 ( + + + + ); +}; + +SortCaret.propTypes = { + order: PropTypes.oneOf([Const.SORT_ASC, Const.SORT_DESC]).isRequired +}; +export default SortCaret; diff --git a/packages/react-bootstrap-table2/src/sort-symbol.js b/packages/react-bootstrap-table2/src/sort-symbol.js new file mode 100644 index 0000000..ecaf324 --- /dev/null +++ b/packages/react-bootstrap-table2/src/sort-symbol.js @@ -0,0 +1,13 @@ +import React from 'react'; + +const SortSymbol = () => ( + + + + + + + + ); + +export default SortSymbol; diff --git a/packages/react-bootstrap-table2/src/store/base.js b/packages/react-bootstrap-table2/src/store/base.js index 5805a43..c44a5ef 100644 --- a/packages/react-bootstrap-table2/src/store/base.js +++ b/packages/react-bootstrap-table2/src/store/base.js @@ -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; + } } diff --git a/packages/react-bootstrap-table2/src/store/sort.js b/packages/react-bootstrap-table2/src/store/sort.js new file mode 100644 index 0000000..95271b3 --- /dev/null +++ b/packages/react-bootstrap-table2/src/store/sort.js @@ -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 }; diff --git a/packages/react-bootstrap-table2/src/utils.js b/packages/react-bootstrap-table2/src/utils.js index e86be0f..780be47 100644 --- a/packages/react-bootstrap-table2/src/utils.js +++ b/packages/react-bootstrap-table2/src/utils.js @@ -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 }; diff --git a/packages/react-bootstrap-table2/style/react-bootstrap-table.scss b/packages/react-bootstrap-table2/style/react-bootstrap-table.scss index 2737023..3a8a126 100644 --- a/packages/react-bootstrap-table2/style/react-bootstrap-table.scss +++ b/packages/react-bootstrap-table2/style/react-bootstrap-table.scss @@ -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; + } } \ No newline at end of file diff --git a/packages/react-bootstrap-table2/test/header-cell.test.js b/packages/react-bootstrap-table2/test/header-cell.test.js index 134de2d..408b853 100644 --- a/packages/react-bootstrap-table2/test/header-cell.test.js +++ b/packages/react-bootstrap-table2/test/header-cell.test.js @@ -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(); + }); + + 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( + ); + }); + + 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( + ); + }); + + 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); + }); + }); + }); }); diff --git a/packages/react-bootstrap-table2/test/header.test.js b/packages/react-bootstrap-table2/test/header.test.js index 80abaa1..3f4e783 100644 --- a/packages/react-bootstrap-table2/test/header.test.js +++ b/packages/react-bootstrap-table2/test/header.test.js @@ -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( +
); + }); + + 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); + }); + }); }); diff --git a/packages/react-bootstrap-table2/test/sort-caret.test.js b/packages/react-bootstrap-table2/test/sort-caret.test.js new file mode 100644 index 0000000..933525d --- /dev/null +++ b/packages/react-bootstrap-table2/test/sort-caret.test.js @@ -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( + ); + }); + + 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( + ); + }); + + 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); + }); + }); +}); diff --git a/packages/react-bootstrap-table2/test/sort-symbol.test.js b/packages/react-bootstrap-table2/test/sort-symbol.test.js new file mode 100644 index 0000000..d13346c --- /dev/null +++ b/packages/react-bootstrap-table2/test/sort-symbol.test.js @@ -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( + ); + }); + 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); + }); +}); diff --git a/packages/react-bootstrap-table2/test/store/base.test.js b/packages/react-bootstrap-table2/test/store/base.test.js new file mode 100644 index 0000000..e633071 --- /dev/null +++ b/packages/react-bootstrap-table2/test/store/base.test.js @@ -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]); + }); + }); + }); +}); diff --git a/packages/react-bootstrap-table2/test/store/sort.test.js b/packages/react-bootstrap-table2/test/store/sort.test.js new file mode 100644 index 0000000..1eb42ff --- /dev/null +++ b/packages/react-bootstrap-table2/test/store/sort.test.js @@ -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); + }); +});
- { children } + { children }{ sortSymbol }