diff --git a/src/ImmutableVirtualizedList/ImmutableVirtualizedList.js b/src/ImmutableVirtualizedList/ImmutableVirtualizedList.js index b2fffc2..961e5e0 100644 --- a/src/ImmutableVirtualizedList/ImmutableVirtualizedList.js +++ b/src/ImmutableVirtualizedList/ImmutableVirtualizedList.js @@ -1,4 +1,3 @@ -import Immutable from 'immutable'; import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; import { Text, VirtualizedList } from 'react-native'; @@ -26,14 +25,18 @@ class ImmutableVirtualizedList extends PureComponent { immutableData: (props, propName, componentName) => { // Note: It's not enough to simply validate PropTypes.instanceOf(Immutable.Iterable), // because different imports of Immutable.js across files have different class prototypes. - // TODO: Add support for Immutable.Map, etc. - if (Immutable.Map.isMap(props[propName])) { - return new Error(`Invalid prop ${propName} supplied to ${componentName}: Support for Immutable.Map is coming soon. For now, try an Immutable List, Set, or Range.`); - } else if (!utils.isImmutableIterable(props[propName])) { + if (!utils.isImmutableIterable(props[propName])) { return new Error(`Invalid prop ${propName} supplied to ${componentName}: Must be instance of Immutable.Iterable.`); } }, + /** + * A function that returns some {@link PropTypes.element} + * to be rendered as a section header. It will be passed a List + * or Map of the section's items, and the section key. + */ + renderSectionHeader: PropTypes.func, + /** * A plain string, or a function that returns some {@link PropTypes.element} * to be rendered in place of a `VirtualizedList` when there are no items in the list. @@ -62,10 +65,42 @@ class ImmutableVirtualizedList extends PureComponent { renderEmptyInList: 'No data.', }; + state = { + flattenedData: this.props.renderSectionHeader + ? utils.flattenMap(this.props.immutableData) + : undefined, + stickyHeaderIndices: this.props.renderSectionHeader + ? utils.getStickyHeaderIndices(this.props.immutableData) + : undefined, + }; + + componentWillReceiveProps(nextProps) { + const { immutableData } = this.props; + const { nextRenderSectionHeader, nextImmutableData } = nextProps; + + if (nextRenderSectionHeader && immutableData !== nextImmutableData) { + this.setState({ + flattenedData: utils.flattenMap(nextImmutableData), + stickyHeaderIndices: utils.getStickyHeaderIndices(nextImmutableData), + }); + } + } + getVirtualizedList() { return this.virtualizedListRef; } + keyExtractor = (item, index) => { + if (this.props.renderSectionHeader) { + return this.state.flattenedData + .keySeq() + .skip(index) + .first(); + } + + return String(index); + }; + scrollToEnd = (...args) => this.virtualizedListRef && this.virtualizedListRef.scrollToEnd(...args); @@ -81,6 +116,20 @@ class ImmutableVirtualizedList extends PureComponent { recordInteraction = (...args) => this.virtualizedListRef && this.virtualizedListRef.recordInteraction(...args); + renderItem = (info) => { + const { renderSectionHeader, renderItem } = this.props; + + if (renderSectionHeader) { + const renderMethod = this.state.stickyHeaderIndices.includes(info.index) + ? renderSectionHeader + : renderItem; + + return renderMethod(info, this.keyExtractor(info.item, info.index)); + } + + return renderItem(info); + }; + renderEmpty() { const { immutableData, renderEmpty, renderEmptyInList, contentContainerStyle, @@ -107,15 +156,30 @@ class ImmutableVirtualizedList extends PureComponent { } render() { - const { immutableData, renderEmpty, renderEmptyInList, ...passThroughProps } = this.props; + const { + immutableData, + renderEmpty, + renderEmptyInList, + renderSectionHeader, + renderItem, + ...passThroughProps + } = this.props; + + const { flattenedData, stickyHeaderIndices } = this.state; return this.renderEmpty() || ( { this.virtualizedListRef = component; }} - data={immutableData} - getItem={(items, index) => utils.getValueFromKey(index, items)} + data={renderSectionHeader ? flattenedData : immutableData} + getItem={(items, index) => ( + renderSectionHeader + ? items.skip(index).first() + : utils.getValueFromKey(index, items) + )} getItemCount={(items) => (items.size || 0)} - keyExtractor={(item, index) => String(index)} + keyExtractor={this.keyExtractor} + stickyHeaderIndices={renderSectionHeader ? stickyHeaderIndices : undefined} + renderItem={this.renderItem} {...passThroughProps} /> ); diff --git a/src/ImmutableVirtualizedList/__tests__/ImmutableVirtualizedList.test.js b/src/ImmutableVirtualizedList/__tests__/ImmutableVirtualizedList.test.js index ad3351d..cabb1dc 100644 --- a/src/ImmutableVirtualizedList/__tests__/ImmutableVirtualizedList.test.js +++ b/src/ImmutableVirtualizedList/__tests__/ImmutableVirtualizedList.test.js @@ -122,3 +122,45 @@ describe('ImmutableVirtualizedList with renderEmptyInList', () => { expect(tree.toJSON()).toMatchSnapshot(); }); }); + +describe('ImmutableVirtualizedList with section headers', () => { + describe('Map of Maps', () => { + const tree = renderer.create( + , + ); + + it('renders basic Map of Maps', () => { + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('flattens the data as expected', () => { + const { flattenedData } = tree.getInstance().state; + expect(flattenedData).toBeDefined(); + expect(flattenedData.size).toBe(4); + }); + }); + + describe('Map of Lists', () => { + const tree = renderer.create( + , + ); + + it('renders basic Map of Lists', () => { + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('flattens the data as expected', () => { + const { flattenedData } = tree.getInstance().state; + expect(flattenedData).toBeDefined(); + expect(flattenedData.size).toBe(9); + }); + }); +}); diff --git a/src/ImmutableVirtualizedList/__tests__/__snapshots__/ImmutableVirtualizedList.test.js.snap b/src/ImmutableVirtualizedList/__tests__/__snapshots__/ImmutableVirtualizedList.test.js.snap index e7cd66b..5492b4d 100644 --- a/src/ImmutableVirtualizedList/__tests__/__snapshots__/ImmutableVirtualizedList.test.js.snap +++ b/src/ImmutableVirtualizedList/__tests__/__snapshots__/ImmutableVirtualizedList.test.js.snap @@ -585,3 +585,263 @@ exports[`ImmutableVirtualizedList with renderEmptyInList renders normally when t `; + +exports[`ImmutableVirtualizedList with section headers Map of Lists renders basic Map of Lists 1`] = ` + + + + + first (undefined items) + + + + + {"item":"m","index":1,"separators":{}} + + + + + {"item":"a","index":2,"separators":{}} + + + + + {"item":"p","index":3,"separators":{}} + + + + + second (undefined items) + + + + + {"item":"foo","index":5,"separators":{}} + + + + + third (undefined items) + + + + + fourth (undefined items) + + + + + {"item":"bar","index":8,"separators":{}} + + + + +`; + +exports[`ImmutableVirtualizedList with section headers Map of Maps renders basic Map of Maps 1`] = ` + + + + + first (undefined items) + + + + + {"item":"data 1","index":1,"separators":{}} + + + + + {"item":"data 2","index":2,"separators":{}} + + + + + second (undefined items) + + + + +`; diff --git a/src/__tests__/utils.test.js b/src/__tests__/utils.test.js index 7d895ed..664d09e 100644 --- a/src/__tests__/utils.test.js +++ b/src/__tests__/utils.test.js @@ -30,4 +30,22 @@ describe('Utils', () => { const isEmpty = utils.isEmptyListView(data.EMPTY_DATA, true); expect(isEmpty).toBe(true); }); + + describe('getStickyHeaderIndices', () => { + it('returns an array of the correct size', () => { + expect( + utils.getStickyHeaderIndices(data.MAP_DATA_MAP_ROWS).length, + ).toBe(data.MAP_DATA_MAP_ROWS.size); + }); + + it('returns an array of header indices', () => { + expect( + utils.getStickyHeaderIndices(data.MAP_DATA_MAP_ROWS), + ).toEqual([0, 3]); + + expect( + utils.getStickyHeaderIndices(data.MAP_DATA_LIST_ROWS), + ).toEqual([0, 4, 6, 7]); + }); + }); }); diff --git a/src/utils.js b/src/utils.js index 5da0f6b..bfb5bc0 100644 --- a/src/utils.js +++ b/src/utils.js @@ -71,6 +71,37 @@ const utils = { return immutableData.every((item) => !item || item.isEmpty()); }, + /** + * @returns {Immutable.OrderedMap} A new Immutable Map with its section headers flattened. + */ + flattenMap(data) { + return data.reduce( + (flattened, section, key) => + flattened.set(key, section).merge( + Immutable.Map.isMap(section) + ? section + : section.toMap().mapKeys((i) => `${key}_${i}`), + ), + Immutable.OrderedMap().asMutable(), + ).asImmutable(); + }, + + /** + * @returns [Array] An array of all indices which should be sticky section headers. + */ + getStickyHeaderIndices(immutableData) { + const indicesReducer = (indices, section) => { + const lastIndex = indices[indices.length - 1]; + indices.push(lastIndex + section.size + 1); + return indices; + }; + + const indices = immutableData.reduce(indicesReducer, [0]); + indices.pop(); + + return indices; + }, + }; export default utils;