From 0e6ac6bc4c278a0a42a9bcc8f849945ae800dcfa Mon Sep 17 00:00:00 2001 From: Wei Zhu Date: Fri, 11 Nov 2016 15:26:51 +0800 Subject: [PATCH] Optimize table selection (#3757) * Extract SelectionRadio, SelectionCheckbox, SelectionCheckboxAll * Add some tests --- components/table/SelectionBox.tsx | 81 ++++++++++ components/table/SelectionCheckboxAll.tsx | 114 ++++++++++++++ components/table/Table.tsx | 174 ++++++++-------------- components/table/createStore.tsx | 38 +++++ tests/table/SelectionBox.test.js | 87 +++++++++++ tests/table/Table.test.js | 91 +++++++++++ 6 files changed, 474 insertions(+), 111 deletions(-) create mode 100644 components/table/SelectionBox.tsx create mode 100644 components/table/SelectionCheckboxAll.tsx create mode 100644 components/table/createStore.tsx create mode 100644 tests/table/SelectionBox.test.js create mode 100644 tests/table/Table.test.js diff --git a/components/table/SelectionBox.tsx b/components/table/SelectionBox.tsx new file mode 100644 index 0000000000..f2035ee896 --- /dev/null +++ b/components/table/SelectionBox.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import Checkbox from '../checkbox'; +import Radio from '../radio'; +import { Store } from './createStore'; + +export interface SelectionBoxProps { + store: Store; + type: string; + defaultSelection: string[]; + rowIndex: string; + disabled?: boolean; + onChange: (e) => void; +} + +export default class SelectionBox extends React.Component { + unsubscribe: () => void; + + constructor(props) { + super(props); + + this.state = { + checked: this.getCheckState(props), + }; + } + + componentDidMount() { + this.subscribe(); + } + + componentWillUnmount() { + if (this.unsubscribe) { + this.unsubscribe(); + } + } + + subscribe() { + const { store } = this.props; + this.unsubscribe = store.subscribe(() => { + const checked = this.getCheckState(this.props); + if (checked !== this.state.checked) { + this.setState({ checked }); + } + }); + } + + getCheckState(props) { + const { store, defaultSelection, rowIndex } = props; + let checked = false; + if (store.getState().selectionDirty) { + checked = store.getState().selectedRowKeys.indexOf(rowIndex) >= 0; + } else { + checked = (store.getState().selectedRowKeys.indexOf(rowIndex) >= 0 || + defaultSelection.indexOf(rowIndex) >= 0); + } + return checked; + } + + render() { + const { type, rowIndex, disabled, onChange } = this.props; + const { checked } = this.state; + + if (type === 'radio') { + return ( + + ); + } + + return ( + + ); + } +} diff --git a/components/table/SelectionCheckboxAll.tsx b/components/table/SelectionCheckboxAll.tsx new file mode 100644 index 0000000000..ad7d78ae88 --- /dev/null +++ b/components/table/SelectionCheckboxAll.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Checkbox from '../checkbox'; +import { Store } from './createStore'; + +export interface SelectionCheckboxAllProps { + store: Store; + disabled: boolean; + getCheckboxPropsByItem: (item) => any; + getRecordKey: (record, index?) => string; + data: any[]; + onChange: (e) => void; +} + +export default class SelectionCheckboxAll extends React.Component { + unsubscribe: () => void; + + constructor(props) { + super(props); + + this.state = { + checked: this.getCheckState(), + indeterminate: this.getIndeterminateState(), + }; + } + + componentDidMount() { + this.subscribe(); + } + + componentWillUnmount() { + if (this.unsubscribe) { + this.unsubscribe(); + } + } + + subscribe() { + const { store } = this.props; + this.unsubscribe = store.subscribe(() => { + const checked = this.getCheckState(); + const indeterminate = this.getIndeterminateState(); + if (checked !== this.state.checked) { + this.setState({ checked }); + } + if (indeterminate !== this.state.indeterminate) { + this.setState({ indeterminate }); + } + }); + } + + checkSelection(data, type, byDefaultChecked) { + const { store, getCheckboxPropsByItem, getRecordKey } = this.props; + // type should be 'every' | 'some' + if (type === 'every' || type === 'some') { + return ( + byDefaultChecked + ? data[type](item => getCheckboxPropsByItem(item).defaultChecked) + : data[type]((item, i) => + store.getState().selectedRowKeys.indexOf(getRecordKey(item, i)) >= 0) + ); + } + return false; + } + + getCheckState() { + const { store, data } = this.props; + let checked; + if (!data.length) { + checked = false; + } else { + checked = store.getState().selectionDirty + ? this.checkSelection(data, 'every', false) + : ( + this.checkSelection(data, 'every', false) || + this.checkSelection(data, 'every', true) + ); + + } + return checked; + } + + getIndeterminateState() { + const { store, data } = this.props; + let indeterminate; + if (!data.length) { + indeterminate = false; + } else { + indeterminate = store.getState().selectionDirty + ? ( + this.checkSelection(data, 'some', false) && + !this.checkSelection(data, 'every', false) + ) + : ((this.checkSelection(data, 'some', false) && + !this.checkSelection(data, 'every', false)) || + (this.checkSelection(data, 'some', true) && + !this.checkSelection(data, 'every', true)) + ); + } + return indeterminate; + } + + render() { + const { disabled, onChange } = this.props; + const { checked, indeterminate } = this.state; + + return ( + + ); + } +} diff --git a/components/table/Table.tsx b/components/table/Table.tsx index ffa3398ea3..ac56a6df39 100755 --- a/components/table/Table.tsx +++ b/components/table/Table.tsx @@ -1,7 +1,5 @@ import React from 'react'; import RcTable from 'rc-table'; -import Checkbox from '../checkbox'; -import Radio from '../radio'; import FilterDropdown from './filterDropdown'; import Pagination, { PaginationProps } from '../pagination'; import Icon from '../icon'; @@ -11,6 +9,9 @@ import { flatArray, treeMap } from './util'; import assign from 'object-assign'; import splitObject from '../_util/splitObject'; import warning from '../_util/warning'; +import createStore, { Store } from './createStore'; +import SelectionBox from './SelectionBox'; +import SelectionCheckboxAll from './SelectionCheckboxAll'; function noop() { } @@ -134,6 +135,7 @@ export default class Table extends React.Component, any> { context: TableContext; CheckboxPropsCache: Object; + store: Store; constructor(props) { super(props); @@ -149,9 +151,7 @@ export default class Table extends React.Component, any> { this.state = assign({}, this.getSortStateFromColumns(), { // 减少状态 - selectedRowKeys: (props.rowSelection || {}).selectedRowKeys || [], filters: this.getFiltersFromColumns(), - selectionDirty: false, pagination: this.hasPagination() ? assign({}, defaultPagination, pagination, { current: pagination.defaultCurrent || pagination.current || 1, @@ -160,9 +160,14 @@ export default class Table extends React.Component, any> { }); this.CheckboxPropsCache = {}; + + this.store = createStore({ + selectedRowKeys: (props.rowSelection || {}).selectedRowKeys || [], + selectionDirty: false, + }); } - getCheckboxPropsByItem(item) { + getCheckboxPropsByItem = (item) => { const { rowSelection = {} } = this.props; if (!rowSelection.getCheckboxProps) { return {}; @@ -204,14 +209,14 @@ export default class Table extends React.Component, any> { // dataSource 的变化会清空选中项 if ('dataSource' in nextProps && nextProps.dataSource !== this.props.dataSource) { - this.setState({ + this.store.setState({ selectionDirty: false, }); this.CheckboxPropsCache = {}; } if (nextProps.rowSelection && 'selectedRowKeys' in nextProps.rowSelection) { - this.setState({ + this.store.setState({ selectedRowKeys: nextProps.rowSelection.selectedRowKeys || [], }); const { rowSelection } = this.props; @@ -246,7 +251,7 @@ export default class Table extends React.Component, any> { setSelectedRowKeys(selectedRowKeys, { selectWay, record, checked, changeRowKeys }: any) { const { rowSelection = {} } = this.props; if (rowSelection && !('selectedRowKeys' in rowSelection)) { - this.setState({ selectedRowKeys }); + this.store.setState({ selectedRowKeys }); } const data = this.getFlatData(); if (!rowSelection.onChange && !rowSelection[selectWay]) { @@ -385,7 +390,6 @@ export default class Table extends React.Component, any> { } const newState = { - selectionDirty: false, pagination, filters: {}, }; @@ -409,6 +413,9 @@ export default class Table extends React.Component, any> { } this.setState(newState, () => { + this.store.setState({ + selectionDirty: false, + }); const onChange = this.props.onChange; if (onChange) { onChange.apply(null, this.prepareParamsArguments(assign({}, this.state, { @@ -422,15 +429,15 @@ export default class Table extends React.Component, any> { handleSelect = (record, rowIndex, e) => { const checked = e.target.checked; - const defaultSelection = this.state.selectionDirty ? [] : this.getDefaultSelection(); - let selectedRowKeys = this.state.selectedRowKeys.concat(defaultSelection); + const defaultSelection = this.store.getState().selectionDirty ? [] : this.getDefaultSelection(); + let selectedRowKeys = this.store.getState().selectedRowKeys.concat(defaultSelection); let key = this.getRecordKey(record, rowIndex); if (checked) { selectedRowKeys.push(this.getRecordKey(record, rowIndex)); } else { selectedRowKeys = selectedRowKeys.filter((i) => key !== i); } - this.setState({ + this.store.setState({ selectionDirty: true, }); this.setSelectedRowKeys(selectedRowKeys, { @@ -442,11 +449,11 @@ export default class Table extends React.Component, any> { handleRadioSelect = (record, rowIndex, e) => { const checked = e.target.checked; - const defaultSelection = this.state.selectionDirty ? [] : this.getDefaultSelection(); - let selectedRowKeys = this.state.selectedRowKeys.concat(defaultSelection); + const defaultSelection = this.store.getState().selectionDirty ? [] : this.getDefaultSelection(); + let selectedRowKeys = this.store.getState().selectedRowKeys.concat(defaultSelection); let key = this.getRecordKey(record, rowIndex); selectedRowKeys = [key]; - this.setState({ + this.store.setState({ selectionDirty: true, }); this.setSelectedRowKeys(selectedRowKeys, { @@ -459,8 +466,8 @@ export default class Table extends React.Component, any> { handleSelectAllRow = (e) => { const checked = e.target.checked; const data = this.getFlatCurrentPageData(); - const defaultSelection = this.state.selectionDirty ? [] : this.getDefaultSelection(); - const selectedRowKeys = this.state.selectedRowKeys.concat(defaultSelection); + const defaultSelection = this.store.getState().selectionDirty ? [] : this.getDefaultSelection(); + const selectedRowKeys = this.store.getState().selectedRowKeys.concat(defaultSelection); const changableRowKeys = data .filter(item => !this.getCheckboxPropsByItem(item).disabled) .map((item, i) => this.getRecordKey(item, i)); @@ -482,7 +489,7 @@ export default class Table extends React.Component, any> { } }); } - this.setState({ + this.store.setState({ selectionDirty: true, }); this.setSelectedRowKeys(selectedRowKeys, { @@ -503,7 +510,6 @@ export default class Table extends React.Component, any> { pagination.onChange(pagination.current); const newState = { - selectionDirty: false, pagination, }; // Controlled current prop will not respond user interaction @@ -514,6 +520,10 @@ export default class Table extends React.Component, any> { } this.setState(newState); + this.store.setState({ + selectionDirty: false, + }); + const onChange = this.props.onChange; if (onChange) { onChange.apply(null, this.prepareParamsArguments(assign({}, this.state, { @@ -523,48 +533,31 @@ export default class Table extends React.Component, any> { } } - renderSelectionRadio = (_, record, index) => { - let rowIndex = this.getRecordKey(record, index); // 从 1 开始 - const props = this.getCheckboxPropsByItem(record); - let checked; - if (this.state.selectionDirty) { - checked = this.state.selectedRowKeys.indexOf(rowIndex) >= 0; - } else { - checked = (this.state.selectedRowKeys.indexOf(rowIndex) >= 0 || - this.getDefaultSelection().indexOf(rowIndex) >= 0); - } - return ( - - this.handleRadioSelect(record, rowIndex, e)} - value={rowIndex} checked={checked} - /> - - ); - } + renderSelectionBox = (type) => { + return (_, record, index) => { + let rowIndex = this.getRecordKey(record, index); // 从 1 开始 + const props = this.getCheckboxPropsByItem(record); + const handleChange = (e) => { + type === 'radio' ? this.handleRadioSelect(record, rowIndex, e) : + this.handleSelect(record, rowIndex, e); + }; - renderSelectionCheckBox = (_, record, index) => { - let rowIndex = this.getRecordKey(record, index); // 从 1 开始 - let checked; - if (this.state.selectionDirty) { - checked = this.state.selectedRowKeys.indexOf(rowIndex) >= 0; - } else { - checked = (this.state.selectedRowKeys.indexOf(rowIndex) >= 0 || - this.getDefaultSelection().indexOf(rowIndex) >= 0); - } - const props = this.getCheckboxPropsByItem(record); - return ( - - this.handleSelect(record, rowIndex, e)} - /> - - ); + return ( + + + + ); + }; } - getRecordKey(record, index?): string { + getRecordKey = (record, index?): string => { const rowKey = this.props.rowKey; if (typeof rowKey === 'function') { return rowKey(record, index); @@ -576,19 +569,6 @@ export default class Table extends React.Component, any> { return recordKey; } - checkSelection(data, type, byDefaultChecked) { - // type should be 'every' | 'some' - if (type === 'every' || type === 'some') { - return ( - byDefaultChecked - ? data[type](item => this.getCheckboxPropsByItem(item).defaultChecked) - : data[type]((item, i) => - this.state.selectedRowKeys.indexOf(this.getRecordKey(item, i)) >= 0) - ); - } - return false; - } - renderRowSelection() { const { prefixCls, rowSelection } = this.props; const columns = this.props.columns.concat(); @@ -599,51 +579,23 @@ export default class Table extends React.Component, any> { } return true; }); - let checked; - let indeterminate; - if (!data.length) { - checked = false; - indeterminate = false; - } else { - checked = this.state.selectionDirty - ? this.checkSelection(data, 'every', false) - : ( - this.checkSelection(data, 'every', false) || - this.checkSelection(data, 'every', true) - ); - indeterminate = this.state.selectionDirty - ? ( - this.checkSelection(data, 'some', false) && - !this.checkSelection(data, 'every', false) - ) - : ((this.checkSelection(data, 'some', false) && - !this.checkSelection(data, 'every', false)) || - (this.checkSelection(data, 'some', true) && - !this.checkSelection(data, 'every', true)) - ); - } - let selectionColumn; - if (rowSelection.type === 'radio') { - selectionColumn = { - key: 'selection-column', - render: this.renderSelectionRadio, - className: `${prefixCls}-selection-column`, - }; - } else { + const selectionColumn: TableColumnConfig = { + key: 'selection-column', + render: this.renderSelectionBox(rowSelection.type), + className: `${prefixCls}-selection-column`, + }; + if (rowSelection.type !== 'radio') { const checkboxAllDisabled = data.every(item => this.getCheckboxPropsByItem(item).disabled); - const checkboxAll = ( - ); - selectionColumn = { - key: 'selection-column', - title: checkboxAll, - render: this.renderSelectionCheckBox, - className: `${prefixCls}-selection-column`, - }; } if (columns.some(column => column.fixed === 'left' || column.fixed === true)) { selectionColumn.fixed = 'left'; diff --git a/components/table/createStore.tsx b/components/table/createStore.tsx new file mode 100644 index 0000000000..3476fac609 --- /dev/null +++ b/components/table/createStore.tsx @@ -0,0 +1,38 @@ +import assign from 'object-assign'; + +export interface Store { + setState: (Object) => void; + getState: () => any; + subscribe: (listener: () => void) => () => void; +} + +export default function createStore(initialState): Store { + let state = initialState; + const listeners: any[] = []; + + function setState(partial) { + state = assign({}, state, partial); + for (let i = 0; i < listeners.length; i++) { + listeners[i](); + } + } + + function getState() { + return state; + } + + function subscribe(listener) { + listeners.push(listener); + + return function unsubscribe() { + const index = listeners.indexOf(listener); + listeners.splice(index, 1); + }; + } + + return { + setState, + getState, + subscribe, + }; +} diff --git a/tests/table/SelectionBox.test.js b/tests/table/SelectionBox.test.js new file mode 100644 index 0000000000..905afe2e55 --- /dev/null +++ b/tests/table/SelectionBox.test.js @@ -0,0 +1,87 @@ +import React from 'react'; +import createStore from '../../components/table/createStore'; +import SelectionBox from '../../components/table/SelectionBox'; +import TestUtils from 'react-addons-test-utils'; + +describe('SelectionBox', () => { + it('unchecked by selectedRowKeys ', () => { + const store = createStore({ + selectedRowKeys: [], + selectionDirty: false, + }); + + const instance = TestUtils.renderIntoDocument( + {}} + defaultSelection={[]} + /> + ); + + expect(instance.state).toEqual({ checked: false }); + }); + + it('checked by selectedRowKeys ', () => { + const store = createStore({ + selectedRowKeys: ['1'], + selectionDirty: false, + }); + + const instance = TestUtils.renderIntoDocument( + {}} + defaultSelection={[]} + /> + ); + + expect(instance.state).toEqual({ checked: true }); + }); + + it('checked by defaultSelection', () => { + const store = createStore({ + selectedRowKeys: [], + selectionDirty: false, + }); + + const instance = TestUtils.renderIntoDocument( + {}} + defaultSelection={['1']} + /> + ); + + expect(instance.state).toEqual({ checked: true }); + }); + + it('checked when store change', () => { + const store = createStore({ + selectedRowKeys: [], + selectionDirty: false, + }); + + const instance = TestUtils.renderIntoDocument( + {}} + defaultSelection={[]} + /> + ); + + store.setState({ + selectedRowKeys: ['1'], + selectionDirty: true, + }); + + expect(instance.state).toEqual({ checked: true }); + }); +}) diff --git a/tests/table/Table.test.js b/tests/table/Table.test.js new file mode 100644 index 0000000000..9748d31af8 --- /dev/null +++ b/tests/table/Table.test.js @@ -0,0 +1,91 @@ +import React from 'react'; +import createStore from '../../components/table/createStore'; +import Table from '../../components/table'; +import TestUtils from 'react-addons-test-utils'; + +describe('Table', () => { + describe('row selection', () => { + it('allow select by checkbox', () => { + const columns = [{ + title: 'Name', + dataIndex: 'name', + }]; + + const data = [{ + name: 'Jack', + }, { + name: 'Lucy', + }]; + + const instance = TestUtils.renderIntoDocument( + + ); + + const checkboxes = TestUtils.scryRenderedDOMComponentsWithTag(instance, 'input'); + const checkboxAll = checkboxes[0]; + + checkboxAll.checked = true; + TestUtils.Simulate.change(checkboxAll); + + expect(instance.store.getState()).toEqual({ + selectedRowKeys: [0, 1], + selectionDirty: true, + }); + + checkboxes[1].checked = false; + TestUtils.Simulate.change(checkboxes[1]); + + expect(instance.store.getState()).toEqual({ + selectedRowKeys: [1], + selectionDirty: true, + }); + + checkboxes[1].checked = true; + TestUtils.Simulate.change(checkboxes[1]); + + expect(instance.store.getState()).toEqual({ + selectedRowKeys: [1, 0], + selectionDirty: true, + }); + }); + + it('pass getCheckboxProps to checkbox', () => { + const columns = [{ + title: 'Name', + dataIndex: 'name', + }]; + + const data = [{ + id: 0, + name: 'Jack', + }, { + id: 1, + name: 'Lucy', + }]; + + const rowSelection = { + getCheckboxProps: record => ({ + disabled: record.name === 'Lucy', + }), + }; + + const instance = TestUtils.renderIntoDocument( +
+ ); + + const checkboxes = TestUtils.scryRenderedDOMComponentsWithTag(instance, 'input'); + + expect(checkboxes[1].disabled).toBe(false); + expect(checkboxes[2].disabled).toBe(true); + }); + }); +})