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
This commit is contained in:
Allen
2017-09-23 04:29:37 -05:00
committed by GitHub
parent ba7c2e9bb7
commit 9f92b5368f
14 changed files with 406 additions and 24 deletions

View File

@@ -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:
}
```
#### <a name='cellEdit.mode'>cellEdit.mode - [String]</a>
`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.
#### <a name='cellEdit.blurToSave'>cellEdit.blurToSave - [Bool]</a>
Default is `false`, enable it will be able to save the cell automatically when blur from the cell editor.
#### <a name='cellEdit.nonEditableRows'>cellEdit.nonEditableRows - [Function]</a>
`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`)
#### <a name='cellEdit.timeToCloseMessage'>cellEdit.timeToCloseMessage - [Function]</a>
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.

View File

@@ -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.
## <a name='editable'>column.editable - [Bool]</a>
`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.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`.
## <a name='validator'>column.validator - [Function]</a>
`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'
}
```

View File

@@ -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
};
<BootstrapTableful
keyField='id'
data={ products }
columns={ columns }
cellEdit={ cellEdit }
/>
`;
const cellEdit = {
mode: 'click',
blurToSave: true
};
export default () => (
<div>
<h3>Product Price should bigger than $2000</h3>
<BootstrapTableful keyField="id" data={ products } columns={ columns } cellEdit={ cellEdit } />
<Code>{ sourceCode }</Code>
</div>
);

View File

@@ -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', () => <BlurToSaveTable />)
.add('Row Level Editable', () => <RowLevelEditableTable />)
.add('Column Level Editable', () => <ColumnLevelEditableTable />)
.add('Rich Hook Functions', () => <CellEditHooks />);
.add('Rich Hook Functions', () => <CellEditHooks />)
.add('Validation', () => <CellEditValidator />);

View File

@@ -137,7 +137,8 @@ BootstrapTable.propTypes = {
blurToSave: PropTypes.bool,
beforeSaveCell: PropTypes.func,
afterSaveCell: PropTypes.func,
nonEditableRows: PropTypes.func
nonEditableRows: PropTypes.func,
timeToCloseMessage: PropTypes.number
})
};

View File

@@ -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
};

View File

@@ -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 (
<td>
<TextEditor ref={ node => this.editor = node } defaultValue={ value } { ...editorAttrs } />
<td className="react-bootstrap-table-editing-cell">
<TextEditor
ref={ node => this.editor = node }
defaultValue={ value }
classNames={ editorClass }
{ ...editorAttrs }
/>
{ invalidMessage ? <EditorIndicator invalidMessage={ invalidMessage } /> : null }
</td>
);
}
@@ -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;

View File

@@ -0,0 +1,19 @@
/* eslint no-return-assign: 0 */
import React from 'react';
import PropTypes from 'prop-types';
const EditorIndicator = ({ invalidMessage }) =>
(
<div className="alert alert-danger fade in">
<strong>{ invalidMessage }</strong>
</div>
);
EditorIndicator.propTypes = {
invalidMessage: PropTypes.string
};
EditorIndicator.defaultProps = {
invalidMessage: null
};
export default EditorIndicator;

View File

@@ -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,

View File

@@ -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 (
<tr>
@@ -36,9 +34,7 @@ const Row = (props) => {
key={ _.get(row, column.dataField) }
row={ row }
column={ column }
blurToSave={ blurToSave }
onComplete={ onComplete }
onEscape={ onEscape }
{ ...rest }
/>
);
}

View File

@@ -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 (
<input
ref={ node => 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;

View File

@@ -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;
}
}
}

View File

@@ -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);
});
});
});
});

View File

@@ -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(
<TextEditor
defaultValue={ value }
classNames={ className }
/>
);
});
it('should render correct custom classname', () => {
expect(wrapper.length).toBe(1);
expect(wrapper.find(`.${className}`).length).toBe(1);
});
});
});