From a9a6228c3a07a67cd0ce40f2e66b1471950405fe Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 15 Oct 2019 18:36:04 +1300 Subject: [PATCH 1/6] Add RefContext --- src/RefContext.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/RefContext.js diff --git a/src/RefContext.js b/src/RefContext.js new file mode 100644 index 0000000..2c63150 --- /dev/null +++ b/src/RefContext.js @@ -0,0 +1,25 @@ +/** + * mSupply Mobile + * Sustainable Solutions (NZ) Ltd. 2019 + */ + +/** + * Context of values relating to focus and scrolling with refs. + * + * Provider is DataTable.js - see for details. + * Consumers are any editable cell and each row. + * Context shape: + * { + * getRefIndex, + * getCellRef, + * focusNextCell, + * adjustToTop, + * } + * + */ + +import React from 'react' + +const RefContext = React.createContext() + +export default RefContext From f115989701f39ed6fbfbc0330949d74d1ed41007 Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 15 Oct 2019 18:36:13 +1300 Subject: [PATCH 2/6] Add utility methods --- src/utilities.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/utilities.js diff --git a/src/utilities.js b/src/utilities.js new file mode 100644 index 0000000..c5b823a --- /dev/null +++ b/src/utilities.js @@ -0,0 +1,22 @@ +/** + * mSupply Mobile + * Sustainable Solutions (NZ) Ltd. 2019 + */ + +/** + * Utility method to inject properties into a provided style object. + * If width is passed, add a flex property equal to width. + * If isLastCell is passed, add a borderRightWidth: 0 property to remove + * the border for the last cell in a row. + * If neither are used, do nothing. + * + * @param {Object} style Style object to inject styles into. + * @param {Number} width Value for the flex property to inject. + * @param {Bool} isLastCell Indicator for the cell being the last in a row. + */ +export const getAdjustedStyle = (style, width, isLastCell) => { + if (width && isLastCell) return { ...style, flex: width, borderRightWidth: 0 } + if (width) return { ...style, flex: width } + if (isLastCell) return { ...style, borderWidth: 0 } + return style +} From d313a2c36ee61d18d269053d524b89c7ecb45c8e Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 15 Oct 2019 18:36:27 +1300 Subject: [PATCH 3/6] Add new DataTable component --- src/DataTable.js | 157 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 19 deletions(-) diff --git a/src/DataTable.js b/src/DataTable.js index 27cfd0b..8ad99ce 100644 --- a/src/DataTable.js +++ b/src/DataTable.js @@ -1,37 +1,156 @@ -/* @flow weak */ - /** * mSupply Mobile - * Sustainable Solutions (NZ) Ltd. 2016 + * Sustainable Solutions (NZ) Ltd. 2019 */ import PropTypes from 'prop-types' -import React from 'react' +import React, { useMemo, useRef, useCallback } from 'react' import { StyleSheet, VirtualizedList, VirtualizedListPropTypes, + Keyboard, } from 'react-native' +import RefContext from './RefContext' -export const DataTable = React.memo(({ renderRow, ...otherProps }) => ( - -)) +/** + * Base DataTable component. Wrapper around VirtualizedList, providing + * a header component, scroll to top and focus features. + * All VirtualizedList props can be passed through, however renderItem + * is renamed renderRow. + * + * Managing focus and scrolling: + * Can manage focusing and auto-scrolling for editable cells through react context API. + * + * Four parameters are passed in through the refContext: + * + * - `getRefIndex` : Gets the ref index for an editable cell given the columnkey and row index. + * - `getCellRef` : Lazily creates a ref for a cell. + * - `focusNextCell` : Focus' the next editable cell. Call during onEditingSubmit. + * - `adjustToTop` : Scrolls so the focused row is at the top of the list. + * + * @param {Func} renderRow Renaming of VirtualizedList renderItem prop. + * @param {Func} renderHeader Function which should return a header component + * @param {Object} style Style Object for this component. + * @param {Object} data Array of data objects. + * @param {Object} columns Array of column objects. + */ +const DataTable = React.memo( + ({ renderRow, renderHeader, style, data, columns, ...otherProps }) => { + // Reference to the virtualized list for scroll operations. + const virtualizedListRef = useRef() -DataTable.propTypes = { - ...VirtualizedListPropTypes, - listViewStyle: PropTypes.object, - refCallback: PropTypes.func, - renderHeader: PropTypes.func, - renderRow: PropTypes.func.isRequired, -} -DataTable.defaultProps = {} + // Array of column keys for determining ref indicies. + const editableColumnKeys = useMemo( + () => + columns.reduce((columnKeys, column) => { + const { editable } = column + if (editable) return [...columnKeys, column.key] + return columnKeys + }, []), + [columns] + ) + const numberOfEditableColumns = editableColumnKeys.length + const numberOfRows = data.length + const numberOfEditableCells = numberOfEditableColumns * numberOfRows + + // Array for each editable cell. Needs to be stable, but updates shouldn't cause re-renders. + const cellRefs = useRef(Array.from({ length: numberOfEditableCells })) + + // Passes a cell it's ref index. + const getRefIndex = (rowIndex, columnKey) => { + const columnIndex = editableColumnKeys.findIndex(key => columnKey === key) + + return rowIndex * numberOfEditableColumns + columnIndex + } + + // Callback for an editable cell. Lazily creating refs. + const getCellRef = refIndex => { + if (cellRefs.current[refIndex]) return cellRefs.current[refIndex] + + const newRef = React.createRef() + cellRefs.current[refIndex] = newRef + + return newRef + } + + // Focuses the next editable cell in the list. On the last row, dismiss the keyboard. + const focusNextCell = refIndex => { + const lastRefIndex = numberOfEditableCells - 1 + if (refIndex === lastRefIndex) return Keyboard.dismiss() + + const nextCellRef = (refIndex + 1) % numberOfEditableCells + const cellRef = getCellRef(nextCellRef) + + return cellRef.current.focus() + } + + // Adjusts the passed row to the top of the list. + const adjustToTop = useCallback(rowIndex => { + virtualizedListRef.current.scrollToIndex({ index: rowIndex }) + }, []) + + // Contexts values. Functions passed to rows and editable cells to control focus/scrolling. + const contextValue = useMemo( + () => ({ + getRefIndex, + getCellRef, + focusNextCell, + adjustToTop, + }), + [numberOfEditableCells] + ) + + const renderItem = useCallback( + rowItem => renderRow(rowItem, focusNextCell, getCellRef, adjustToTop), + [renderRow] + ) + + return ( + + {renderHeader && renderHeader()} + + + ) + } +) const defaultStyles = StyleSheet.create({ virtualizedList: { flex: 1, }, }) + +DataTable.propTypes = { + ...VirtualizedListPropTypes, + renderRow: PropTypes.func.isRequired, + renderHeader: PropTypes.func, + getItem: PropTypes.func, + getItemCount: PropTypes.func, + initialNumToRender: PropTypes.number, + removeClippedSubviews: PropTypes.bool, + windowSize: PropTypes.number, + style: PropTypes.object, + columns: PropTypes.array, +} + +DataTable.defaultProps = { + renderHeader: null, + style: defaultStyles.virtualizedList, + getItem: (items, index) => items[index], + getItemCount: items => items.length, + initialNumToRender: 10, + removeClippedSubviews: true, + windowSize: 3, + columns: [], +} + +export default DataTable From 2724fb359ce80fe23aa45916254c5cedc8e52677 Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 15 Oct 2019 18:38:46 +1300 Subject: [PATCH 4/6] Add TouchableNoFeedback component --- src/TouchableNoFeedback.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/TouchableNoFeedback.js diff --git a/src/TouchableNoFeedback.js b/src/TouchableNoFeedback.js new file mode 100644 index 0000000..9c2926f --- /dev/null +++ b/src/TouchableNoFeedback.js @@ -0,0 +1,35 @@ +/** + * mSupply Mobile + * Sustainable Solutions (NZ) Ltd. 2019 + */ + +import React from 'react' +import { View, TouchableWithoutFeedback } from 'react-native' +import PropTypes from 'prop-types' + +/** + * TouchableWithoutFeedback doesn't have a style prop. View doesn't have an onPress + * Prop. This hack ensures events don't propogate to the parent, styling stays consistent + * and no feedback (i.e. gesture echo) is given to the user. + */ +const onPressNoOp = () => {} + +const TouchableNoFeedback = ({ children, style, ...touchableProps }) => ( + + {children} + +) + +TouchableNoFeedback.defaultProps = { + style: null, +} + +TouchableNoFeedback.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, + style: PropTypes.object, +} + +export default TouchableNoFeedback From 0d47df4fd059707b161493ba31526a9e50cab34c Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 15 Oct 2019 18:38:57 +1300 Subject: [PATCH 5/6] Add index file --- src/index.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/index.js diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..789515d --- /dev/null +++ b/src/index.js @@ -0,0 +1,21 @@ +import Cell from './Cell' +import EditableCell from './EditableCell' +import CheckableCell from './CheckableCell' +import TouchableCell from './TouchableCell' +import Row from './Row' +import HeaderRow from './HeaderRow' +import HeaderCell from './HeaderCell' +import DataTable from './DataTable' +import TouchableNoFeedback from './TouchableNoFeedback' + +export { + DataTable, + Cell, + EditableCell, + CheckableCell, + TouchableCell, + Row, + HeaderRow, + HeaderCell, + TouchableNoFeedback, +} From dfa2dcd8df664929030a7995677c46c0669f07fb Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 15 Oct 2019 18:48:28 +1300 Subject: [PATCH 6/6] Add remove unused files --- __tests__/DataTable.spec.js | 48 ----------------------------------- __tests__/Expansion.spec.js | 35 ------------------------- __tests__/TableButton.spec.js | 27 -------------------- src/Expansion.js | 24 ------------------ src/TableButton.js | 40 ----------------------------- 5 files changed, 174 deletions(-) delete mode 100644 __tests__/DataTable.spec.js delete mode 100644 __tests__/Expansion.spec.js delete mode 100644 __tests__/TableButton.spec.js delete mode 100644 src/Expansion.js delete mode 100644 src/TableButton.js diff --git a/__tests__/DataTable.spec.js b/__tests__/DataTable.spec.js deleted file mode 100644 index 8d83829..0000000 --- a/__tests__/DataTable.spec.js +++ /dev/null @@ -1,48 +0,0 @@ -jest.unmock('../DataTable'); -jest.unmock('enzyme'); -jest.unmock('sinon'); - -import { DataTable } from '../DataTable'; -import React from 'react'; -import { View, TextInput } from 'react-native'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; -import { ListView } from 'realm/react-native'; - -describe('DataTable', () => { - const dataSource = new ListView.DataSource({ - rowHasChanged: (row1, row2) => row1 !== row2, - }); - - beforeEach(() => { - dataSource.cloneWithRows(['row1', 'row2']); - }); - - it('renders a ListView', () => { - const wrapper = shallow( - - ); - expect(wrapper.find(ListView).length).toEqual(1); - expect(wrapper.find(TextInput).length).toEqual(0); // No searchBar - expect(wrapper.prop('renderHeader')).toBeFalsy(); // No prop - }); - - describe('renderHeader', () => { - const renderHeader = sinon.spy(() => foo); - const wrapper = shallow( - renderHeader()} - /> - ); - it('renders a header when given appropriate prop', () => { - expect(wrapper.contains('foo')).toEqual(true); - expect(wrapper.children().find(View).length).toEqual(1); - expect(wrapper.children().find(Text).length).toEqual(1); - }); - }); -}); diff --git a/__tests__/Expansion.spec.js b/__tests__/Expansion.spec.js deleted file mode 100644 index 3689c2e..0000000 --- a/__tests__/Expansion.spec.js +++ /dev/null @@ -1,35 +0,0 @@ -jest.unmock('../Expansion'); -jest.unmock('enzyme'); -jest.unmock('sinon'); - -import { Expansion } from '../Expansion'; -import React from 'react'; -import { View, TouchableOpacity } from 'react-native'; -import sinon from 'sinon'; -import { shallow } from 'enzyme'; - -describe('Expansion', () => { - it('renders a view', () => { - const wrapper = shallow( - - ); - expect(wrapper.find(View).length).toBe(1); - }); - it('renders some arbitrary components', () => { - const onBtnPress = sinon.spy(); - const wrapper = shallow( - - - Foo - - onBtnPress()}> - Bar - - - ); - expect(wrapper.contains('Foo')).toBe(true, 'Contains Foo'); - expect(wrapper.contains('Bar')).toBe(true, 'Contains Bar'); - wrapper.find(TouchableOpacity).simulate('press'); - expect(onBtnPress.calledOnce).toBe(true, 'Button press'); - }); -}); diff --git a/__tests__/TableButton.spec.js b/__tests__/TableButton.spec.js deleted file mode 100644 index 93a2bb1..0000000 --- a/__tests__/TableButton.spec.js +++ /dev/null @@ -1,27 +0,0 @@ -jest.unmock('../TableButton'); -jest.unmock('enzyme'); -jest.unmock('sinon'); - -import { TableButton } from '../TableButton'; -import React from 'react'; -import { TouchableOpacity } from 'react-native'; -import sinon from 'sinon'; -import { shallow } from 'enzyme'; - -describe('TableButton', () => { - it('renders a TouchableOpacity when given onPress prop', () => { - const wrapper = shallow( - - ); - expect(wrapper.find(TouchableOpacity).length).toBe(1); - }); - - it('Calls given func when pressed', () => { - const onBtnPress = sinon.spy(); - const wrapper = shallow( - onBtnPress()} /> - ); - wrapper.find(TouchableOpacity).simulate('press'); - expect(onBtnPress.calledOnce).toBe(true, 'Button press'); - }); -}); diff --git a/src/Expansion.js b/src/Expansion.js deleted file mode 100644 index e70c999..0000000 --- a/src/Expansion.js +++ /dev/null @@ -1,24 +0,0 @@ -/* @flow weak */ - -/** - * mSupply Mobile - * Sustainable Solutions (NZ) Ltd. 2016 - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { View, ViewPropTypes } from 'react-native'; - -export function Expansion(props) { - const { children, style, ...viewProps } = props; - return ( - - {children} - - ); -} - -Expansion.propTypes = { - style: ViewPropTypes.style, - children: PropTypes.any, -}; diff --git a/src/TableButton.js b/src/TableButton.js deleted file mode 100644 index e3b8fd4..0000000 --- a/src/TableButton.js +++ /dev/null @@ -1,40 +0,0 @@ - -/* @flow weak */ - -/** - * mSupply Mobile - * Sustainable Solutions (NZ) Ltd. 2016 - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { - StyleSheet, - TouchableOpacity, - ViewPropTypes, -} from 'react-native'; - -export function TableButton(props) { - const { style, onPress, children, ...touchableOpacityProps } = props; - return ( - - {children} - - ); -} - -TableButton.propTypes = { - style: ViewPropTypes.style, - onPress: PropTypes.func, - children: PropTypes.any, -}; - -const defaultStyles = StyleSheet.create({ - tableButton: { - flex: 1, - }, -});