From 9f92b5368fdce89ced7b66be9d43401d1a36fa08 Mon Sep 17 00:00:00 2001 From: Allen Date: Sat, 23 Sep 2017 04:29:37 -0500 Subject: [PATCH] fix #63 (part1 - cell editing validation) * implement cell editing validation * add test cases for cell editing validation * add story for validator for cell editor * add docs for cell editor validation --- docs/README.md | 6 +- docs/columns.md | 24 ++++- .../cell-edit/cell-edit-validator-table.js | 85 ++++++++++++++++++ .../stories/index.js | 4 +- .../src/bootstrap-table.js | 3 +- packages/react-bootstrap-table2/src/const.js | 3 +- .../src/editing-cell.js | 68 ++++++++++++-- .../src/editor-indicator.js | 19 ++++ .../react-bootstrap-table2/src/header-cell.js | 4 +- packages/react-bootstrap-table2/src/row.js | 12 +-- .../react-bootstrap-table2/src/text-editor.js | 13 ++- .../style/react-bootstrap-table.scss | 90 +++++++++++++++++++ .../test/editing-cell.test.js | 82 ++++++++++++++++- .../test/text-editor.test.js | 17 ++++ 14 files changed, 406 insertions(+), 24 deletions(-) create mode 100644 packages/react-bootstrap-table2-example/examples/cell-edit/cell-edit-validator-table.js create mode 100644 packages/react-bootstrap-table2/src/editor-indicator.js diff --git a/docs/README.md b/docs/README.md index 19330f8..3b7311f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,7 @@ Following is a `cellEdit` object: { mode: 'click', blurToSave: true, + timeToCloseMessage: 2500, onEditing: (rowId, dataField, newValue) => { ... }, beforeSaveCell: (oldValue, newValue, row, column) => { ... }, afterSaveCell: (oldValue, newValue, row, column) => { ... }, @@ -49,10 +50,13 @@ Following is a `cellEdit` object: } ``` #### cellEdit.mode - [String] -`cellEdit.mode` possible value is `click` and `dbclick`. It's required value that tell `react-bootstrap-table2` how to trigger the cell editing. +`cellEdit.mode` possible value is `click` and `dbclick`. **It's required value** that tell `react-bootstrap-table2` how to trigger the cell editing. #### cellEdit.blurToSave - [Bool] Default is `false`, enable it will be able to save the cell automatically when blur from the cell editor. #### cellEdit.nonEditableRows - [Function] `cellEdit.nonEditableRows` accept a callback function and expect return an array which used to restrict all the columns of some rows as non-editable. So the each item in return array should be rowkey(`keyField`) + +#### cellEdit.timeToCloseMessage - [Function] +If a [`column.validator`](./columns.md#validator) defined and the new value is invalid, `react-bootstrap-table2` will popup a alert at the bottom of editor. `cellEdit.timeToCloseMessage` is a chance to let you decide how long the alert should be stay. Default is 3000 millisecond. \ No newline at end of file diff --git a/docs/columns.md b/docs/columns.md index 8ac781e..dc1e378 100644 --- a/docs/columns.md +++ b/docs/columns.md @@ -26,6 +26,7 @@ Available properties in a column object: * [headerAlign](#headerAlign) * [headerAttrs](#headerAttrs) * [editable](#editable) +* [validator](#validator) Following is a most simplest and basic usage: @@ -417,4 +418,25 @@ A new `Object` will be the result of element headerAttrs. > overwrited when other props related to HTML attributes were given. ## column.editable - [Bool] -`column.editable` default is true, means every column is editable if you configure [`cellEdit`](./README.md#cellEdit). But you can disable some columns editable via setting `false`. \ No newline at end of file +`column.editable` default is true, means every column is editable if you configure [`cellEdit`](./README.md#cellEdit). But you can disable some columns editable via setting `false`. + +## column.validator - [Function] +`column.validator` used for validate the data when cell on updating. it's should accept a callback function with following argument: +`newValue`, `row` and `column`: + +```js +{ + // omit... + validator: (newValue, row, column) => { + return ...; + } +} +``` + +The return value can be a bool or an object. If your valiation is pass, return `true` explicitly. If your valiation is invalid, return following object instead: +```js +{ + valid: false, + message: 'SOME_REASON_HERE' +} +``` \ No newline at end of file diff --git a/packages/react-bootstrap-table2-example/examples/cell-edit/cell-edit-validator-table.js b/packages/react-bootstrap-table2-example/examples/cell-edit/cell-edit-validator-table.js new file mode 100644 index 0000000..d3dcffb --- /dev/null +++ b/packages/react-bootstrap-table2-example/examples/cell-edit/cell-edit-validator-table.js @@ -0,0 +1,85 @@ +import React from 'react'; +/* eslint no-unused-vars: 0 */ +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' +}, { + dataField: 'name', + text: 'Product Name' +}, { + dataField: 'price', + text: 'Product Price', + validator: (newValue, row, column) => { + if (isNaN(newValue)) { + return { + valid: false, + message: 'Price should be numeric' + }; + } + if (newValue < 2000) { + return { + valid: false, + message: 'Price should bigger than 2000' + }; + } + return true; + } +}]; + +const sourceCode = `\ +const columns = [{ + dataField: 'id', + text: 'Product ID' +}, { + dataField: 'name', + text: 'Product Name' +}, { + dataField: 'price', + text: 'Product Price', + validator: (newValue, row, column) => { + if (isNaN(newValue)) { + return { + valid: false, + message: 'Price should be numeric' + }; + } + if (newValue < 2000) { + return { + valid: false, + message: 'Price should bigger than 2000' + }; + } + return true; + } +}]; + +const cellEdit = { + mode: 'click', + blurToSave: true +}; + + +`; + +const cellEdit = { + mode: 'click', + blurToSave: true +}; +export default () => ( +
+

Product Price should bigger than $2000

+ + { sourceCode } +
+); diff --git a/packages/react-bootstrap-table2-example/stories/index.js b/packages/react-bootstrap-table2-example/stories/index.js index 9ad48f2..7c43434 100644 --- a/packages/react-bootstrap-table2-example/stories/index.js +++ b/packages/react-bootstrap-table2-example/stories/index.js @@ -42,6 +42,7 @@ import BlurToSaveTable from 'examples/cell-edit/blur-to-save-table'; import RowLevelEditableTable from 'examples/cell-edit/row-level-editable-table'; import ColumnLevelEditableTable from 'examples/cell-edit/column-level-editable-table'; import CellEditHooks from 'examples/cell-edit/cell-edit-hooks-table'; +import CellEditValidator from 'examples/cell-edit/cell-edit-validator-table'; // css style import 'bootstrap/dist/css/bootstrap.min.css'; @@ -92,4 +93,5 @@ storiesOf('Cell Editing', module) .add('Blur to Save Cell', () => ) .add('Row Level Editable', () => ) .add('Column Level Editable', () => ) - .add('Rich Hook Functions', () => ); + .add('Rich Hook Functions', () => ) + .add('Validation', () => ); diff --git a/packages/react-bootstrap-table2/src/bootstrap-table.js b/packages/react-bootstrap-table2/src/bootstrap-table.js index d7ea363..f93a30a 100644 --- a/packages/react-bootstrap-table2/src/bootstrap-table.js +++ b/packages/react-bootstrap-table2/src/bootstrap-table.js @@ -137,7 +137,8 @@ BootstrapTable.propTypes = { blurToSave: PropTypes.bool, beforeSaveCell: PropTypes.func, afterSaveCell: PropTypes.func, - nonEditableRows: PropTypes.func + nonEditableRows: PropTypes.func, + timeToCloseMessage: PropTypes.number }) }; diff --git a/packages/react-bootstrap-table2/src/const.js b/packages/react-bootstrap-table2/src/const.js index 8a812ee..5391b82 100644 --- a/packages/react-bootstrap-table2/src/const.js +++ b/packages/react-bootstrap-table2/src/const.js @@ -3,5 +3,6 @@ export default { SORT_DESC: 'desc', UNABLE_TO_CELL_EDIT: 'none', CLICK_TO_CELL_EDIT: 'click', - DBCLICK_TO_CELL_EDIT: 'dbclick' + DBCLICK_TO_CELL_EDIT: 'dbclick', + TIME_TO_CLOSE_MESSAGE: 3000 }; diff --git a/packages/react-bootstrap-table2/src/editing-cell.js b/packages/react-bootstrap-table2/src/editing-cell.js index f32e1ad..97320c9 100644 --- a/packages/react-bootstrap-table2/src/editing-cell.js +++ b/packages/react-bootstrap-table2/src/editing-cell.js @@ -1,33 +1,73 @@ +/* eslint arrow-body-style: 0 */ /* eslint react/prop-types: 0 */ /* eslint no-return-assign: 0 */ import React, { Component } from 'react'; +import cs from 'classnames'; import PropTypes from 'prop-types'; import _ from './utils'; +import Const from './const'; import TextEditor from './text-editor'; +import EditorIndicator from './editor-indicator'; class EditingCell extends Component { constructor(props) { super(props); + this.indicatorTimer = null; + this.clearTimer = this.clearTimer.bind(this); this.handleBlur = this.handleBlur.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); + this.beforeComplete = this.beforeComplete.bind(this); + this.state = { + invalidMessage: null + }; + } + + componentWillUnmount() { + this.clearTimer(); + } + + clearTimer() { + if (this.indicatorTimer) { + clearTimeout(this.indicatorTimer); + } + } + + beforeComplete(row, column, newValue) { + this.clearTimer(); + const { onComplete, timeToCloseMessage } = this.props; + if (_.isFunction(column.validator)) { + const validateForm = column.validator(newValue, row, column); + if (_.isObject(validateForm) && !validateForm.valid) { + this.setState(() => { + return { invalidMessage: validateForm.message }; + }); + this.indicatorTimer = setTimeout(() => { + this.setState(() => { + return { invalidMessage: null }; + }); + }, timeToCloseMessage); + return; + } + } + onComplete(row, column, newValue); } handleBlur() { - const { onEscape, onComplete, blurToSave, row, column } = this.props; + const { onEscape, blurToSave, row, column } = this.props; if (blurToSave) { const value = this.editor.text.value; if (!_.isDefined(value)) { // TODO: for other custom or embed editor } - onComplete(row, column, value); + this.beforeComplete(row, column, value); } else { onEscape(); } } handleKeyDown(e) { - const { onEscape, onComplete, row, column } = this.props; + const { onEscape, row, column } = this.props; if (e.keyCode === 27) { // ESC onEscape(); } else if (e.keyCode === 13) { // ENTER @@ -35,11 +75,12 @@ class EditingCell extends Component { if (!_.isDefined(value)) { // TODO: for other custom or embed editor } - onComplete(row, column, value); + this.beforeComplete(row, column, value); } } render() { + const { invalidMessage } = this.state; const { row, column } = this.props; const { dataField } = column; @@ -48,9 +89,17 @@ class EditingCell extends Component { onKeyDown: this.handleKeyDown, onBlur: this.handleBlur }; + + const editorClass = invalidMessage ? cs('animated', 'shake') : null; return ( - - this.editor = node } defaultValue={ value } { ...editorAttrs } /> + + this.editor = node } + defaultValue={ value } + classNames={ editorClass } + { ...editorAttrs } + /> + { invalidMessage ? : null } ); } @@ -60,7 +109,12 @@ EditingCell.propTypes = { row: PropTypes.object.isRequired, column: PropTypes.object.isRequired, onComplete: PropTypes.func.isRequired, - onEscape: PropTypes.func.isRequired + onEscape: PropTypes.func.isRequired, + timeToCloseMessage: PropTypes.number +}; + +EditingCell.defaultProps = { + timeToCloseMessage: Const.TIME_TO_CLOSE_MESSAGE }; export default EditingCell; diff --git a/packages/react-bootstrap-table2/src/editor-indicator.js b/packages/react-bootstrap-table2/src/editor-indicator.js new file mode 100644 index 0000000..c19819d --- /dev/null +++ b/packages/react-bootstrap-table2/src/editor-indicator.js @@ -0,0 +1,19 @@ +/* eslint no-return-assign: 0 */ +import React from 'react'; +import PropTypes from 'prop-types'; + +const EditorIndicator = ({ invalidMessage }) => + ( +
+ { invalidMessage } +
+ ); + +EditorIndicator.propTypes = { + invalidMessage: PropTypes.string +}; + +EditorIndicator.defaultProps = { + invalidMessage: null +}; +export default EditorIndicator; diff --git a/packages/react-bootstrap-table2/src/header-cell.js b/packages/react-bootstrap-table2/src/header-cell.js index d419710..1b9c42a 100644 --- a/packages/react-bootstrap-table2/src/header-cell.js +++ b/packages/react-bootstrap-table2/src/header-cell.js @@ -99,7 +99,9 @@ HeaderCell.propTypes = { headerAlign: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), align: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), sort: PropTypes.bool, - sortFunc: PropTypes.func + sortFunc: PropTypes.func, + editable: PropTypes.bool, + validator: PropTypes.func }).isRequired, index: PropTypes.number.isRequired, onSort: PropTypes.func, diff --git a/packages/react-bootstrap-table2/src/row.js b/packages/react-bootstrap-table2/src/row.js index c0e06b2..ed76102 100644 --- a/packages/react-bootstrap-table2/src/row.js +++ b/packages/react-bootstrap-table2/src/row.js @@ -16,13 +16,11 @@ const Row = (props) => { editable: editableRow } = props; const { - ridx: editingRowIdx, - cidx: editingColIdx, mode, onStart, - onEscape, - onComplete, - blurToSave + ridx: editingRowIdx, + cidx: editingColIdx, + ...rest } = cellEdit; return ( @@ -36,9 +34,7 @@ const Row = (props) => { key={ _.get(row, column.dataField) } row={ row } column={ column } - blurToSave={ blurToSave } - onComplete={ onComplete } - onEscape={ onEscape } + { ...rest } /> ); } diff --git a/packages/react-bootstrap-table2/src/text-editor.js b/packages/react-bootstrap-table2/src/text-editor.js index 285236d..4c41954 100644 --- a/packages/react-bootstrap-table2/src/text-editor.js +++ b/packages/react-bootstrap-table2/src/text-editor.js @@ -1,5 +1,6 @@ /* eslint no-return-assign: 0 */ import React, { Component } from 'react'; +import cs from 'classnames'; import PropTypes from 'prop-types'; class TextEditor extends Component { @@ -10,12 +11,13 @@ class TextEditor extends Component { } render() { - const { defaultValue, ...rest } = this.props; + const { defaultValue, classNames, ...rest } = this.props; + const editorClass = cs('form-control editor edit-text', classNames); return ( this.text = node } type="text" - className="form-control editor edit-text" + className={ editorClass } { ...rest } /> ); @@ -23,9 +25,16 @@ class TextEditor extends Component { } TextEditor.propTypes = { + classNames: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object + ]), defaultValue: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]).isRequired }; +TextEditor.defaultProps = { + classNames: null +}; export default TextEditor; diff --git a/packages/react-bootstrap-table2/style/react-bootstrap-table.scss b/packages/react-bootstrap-table2/style/react-bootstrap-table.scss index 91cc609..9720bd0 100644 --- a/packages/react-bootstrap-table2/style/react-bootstrap-table.scss +++ b/packages/react-bootstrap-table2/style/react-bootstrap-table.scss @@ -25,4 +25,94 @@ td.react-bs-table-no-data { text-align: center; } + + td.react-bootstrap-table-editing-cell { + .animated { + animation-fill-mode: both; + } + + .animated.bounceIn, + .animated.bounceOut{ + animation-duration: .75s; + } + + .animated.shake{ + animation-duration: .3s; + } + + @keyframes shake { + from, to { + transform: translate3d(0, 0, 0); + } + + 10%, 50%, 90% { + transform: translate3d(-10px, 0, 0); + } + + 30%, 70%{ + transform: translate3d(10px, 0, 0); + } + } + + .shake { + animation-name: shake; + } + + @keyframes bounceIn { + from, 20%, 40%, 60%, 80%, to { + animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000); + } + + 0% { + opacity: 0; + transform: scale3d(.3, .3, .3); + } + + 20% { + transform: scale3d(1.1, 1.1, 1.1); + } + + 40% { + transform: scale3d(.9, .9, .9); + } + + 60% { + opacity: 1; + transform: scale3d(1.03, 1.03, 1.03); + } + + 80% { + transform: scale3d(.97, .97, .97); + } + + to { + opacity: 1; + transform: scale3d(1, 1, 1); + } + } + + .bounceIn { + animation-name: bounceIn; + } + + @keyframes bounceOut { + 20% { + transform: scale3d(.9, .9, .9); + } + + 50%, 55% { + opacity: 1; + transform: scale3d(1.1, 1.1, 1.1); + } + + to { + opacity: 0; + transform: scale3d(.3, .3, .3); + } + } + + .bounceOut { + animation-name: bounceOut; + } + } } \ No newline at end of file diff --git a/packages/react-bootstrap-table2/test/editing-cell.test.js b/packages/react-bootstrap-table2/test/editing-cell.test.js index 9bf0f19..835f7aa 100644 --- a/packages/react-bootstrap-table2/test/editing-cell.test.js +++ b/packages/react-bootstrap-table2/test/editing-cell.test.js @@ -6,6 +6,7 @@ import { shallow, mount } from 'enzyme'; import { TableRowWrapper } from './test-helpers/table-wrapper'; import EditingCell from '../src/editing-cell'; import TextEditor from '../src/text-editor'; +import EditorIndicator from '../src/editor-indicator'; describe('EditingCell', () => { @@ -17,7 +18,7 @@ describe('EditingCell', () => { name: 'A' }; - const column = { + let column = { dataField: 'id', text: 'ID' }; @@ -39,6 +40,7 @@ describe('EditingCell', () => { expect(wrapper.length).toBe(1); expect(wrapper.find('td').length).toBe(1); expect(wrapper.find(TextEditor).length).toBe(1); + expect(wrapper.state().invalidMessage).toBeNull(); }); it('should render TextEditor with correct props', () => { @@ -46,6 +48,12 @@ describe('EditingCell', () => { expect(textEditor.props().defaultValue).toEqual(row[column.dataField]); expect(textEditor.props().onKeyDown).toBeDefined(); expect(textEditor.props().onBlur).toBeDefined(); + expect(textEditor.props().classNames).toBeNull(); + }); + + it('should not render EditorIndicator due to state.invalidMessage is null', () => { + const indicator = wrapper.find(EditorIndicator); + expect(indicator.length).toEqual(0); }); it('when press ENTER on TextEditor should call onComplete correctly', () => { @@ -90,4 +98,76 @@ describe('EditingCell', () => { expect(onComplete.calledWith(row, column, `${row[column.dataField]}`)).toBe(true); }); }); + + describe('when column.validator is defined', () => { + let newValue; + let validForm; + let validatorCallBack; + + describe('and column.validator return an object', () => { + beforeEach(() => { + newValue = 'newValue'; + validForm = { valid: false, message: 'Something is invalid' }; + validatorCallBack = sinon.stub().returns(validForm); + column = { + dataField: 'id', + text: 'ID', + validator: validatorCallBack + }; + wrapper.instance().beforeComplete(row, column, newValue); + }); + + it('should call column.validator successfully', () => { + expect(validatorCallBack.callCount).toBe(1); + expect(validatorCallBack.calledWith(newValue, row, column)).toBe(true); + }); + + it('should not call onComplete', () => { + expect(onComplete.callCount).toBe(0); + }); + + it('should set indicatorTimer successfully', () => { + expect(wrapper.instance().indicatorTimer).toBeDefined(); + }); + + it('should set invalidMessage state correctly', () => { + expect(wrapper.state().invalidMessage).toEqual(validForm.message); + }); + + it('should render TextEditor with correct shake and animated class', () => { + const editor = wrapper.find(TextEditor); + expect(editor.length).toEqual(1); + expect(editor.props().classNames).toEqual('animated shake'); + }); + + it('should render EditorIndicator correctly', () => { + const indicator = wrapper.find(EditorIndicator); + expect(indicator.length).toEqual(1); + expect(indicator.props().invalidMessage).toEqual(validForm.message); + }); + }); + + describe('and column.validator return true or something', () => { + beforeEach(() => { + newValue = 'newValue'; + validForm = true; + validatorCallBack = sinon.stub().returns(validForm); + column = { + dataField: 'id', + text: 'ID', + validator: validatorCallBack + }; + wrapper.instance().beforeComplete(row, column, newValue); + }); + + it('should call column.validator successfully', () => { + expect(validatorCallBack.callCount).toBe(1); + expect(validatorCallBack.calledWith(newValue, row, column)).toBe(true); + }); + + it('should call onComplete', () => { + expect(onComplete.callCount).toBe(1); + }); + }); + }); }); diff --git a/packages/react-bootstrap-table2/test/text-editor.test.js b/packages/react-bootstrap-table2/test/text-editor.test.js index dbdaeef..0150bc3 100644 --- a/packages/react-bootstrap-table2/test/text-editor.test.js +++ b/packages/react-bootstrap-table2/test/text-editor.test.js @@ -21,4 +21,21 @@ describe('TextEditor', () => { expect(wrapper.find('input').prop('type')).toEqual('text'); expect(wrapper.find('.form-control.editor.edit-text').length).toBe(1); }); + + describe('whenclassNames prop defined', () => { + const className = 'test-class'; + beforeEach(() => { + wrapper = shallow( + + ); + }); + + it('should render correct custom classname', () => { + expect(wrapper.length).toBe(1); + expect(wrapper.find(`.${className}`).length).toBe(1); + }); + }); });