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/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
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/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
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,
- },
-});
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
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,
+}
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
+}