UNPKG

optimizely-oui

Version:

Optimizely's Component Library.

356 lines (313 loc) 12.8 kB
import Immutable from "immutable"; import PropTypes from "prop-types"; import React from "react"; import { DragDropContext } from "react-dnd"; import HTML5Backend from "react-dnd-html5-backend"; import classNames from "classnames"; import SortDragLayer from "./SortDragLayer"; import SortableGroup from "./SortableGroup"; /** * getKeypathFromPosition * @description Converts an index-based keypath to be Immutable-compatible * @param {Array.<Number>} position Array of indeces pointing to item/group * @returns {Array.<Number|String>} Keypath pointing to item/group data in state */ function getKeypathFromPosition(position) { // Insert 'children' in between each index in position return position.reduce((currentKeypath, currentIndex, currentDepth) => { if (currentDepth === position.length - 1) { return [...currentKeypath, currentIndex]; } return [...currentKeypath, currentIndex, "children"]; }, []); } /** * removeItem * @description Removes item at position and returns resulting item list * @param {Array.<Number>} position Array of indeces pointing to item * @param {Immutable.List} items List of items in Sortable component * @returns {Immutable.List} List of items without item at given position */ function removeItem(position, items) { const keypathToItem = getKeypathFromPosition(position); let updatedItems = items; updatedItems = updatedItems.removeIn(keypathToItem); return updatedItems; } /** * insertItem * @description Inserts item at position and returns resulting item list * @param {Array.<Number>} position Array of indeces pointing to item * @param {Immutable.List} items List of items in Sortable component * @param {Immutable.Map} newItem New item to insert into Sortable component * @returns {Immutable.List} List of items including new item at given position */ function insertItem(position, items, newItem) { const keypathToItem = getKeypathFromPosition(position); let updatedItems = items; updatedItems = updatedItems.updateIn(keypathToItem.slice(0, -1), list => list.splice(keypathToItem.slice(-1), 0, newItem) ); return updatedItems; } /** * getRealNextPosition * @description Adjust next position when item is removed and added during drag * @param {Array.<Number>} prev Array of indeces pointing prev item position * @param {Array.<Number>} next Array of indeces pointing next item position * @returns {Array.<Number>} Array of corrected indeces pointing next position */ function getRealNextPosition(prev, next) { /** * Account for removal of item at original position when dragging downward or * into a group by decrementing affected indeces in the next position array */ if (prev.length <= next.length) { return next.map((nextIndex, i) => { return nextIndex > prev[i] ? nextIndex - 1 : nextIndex; }); } return next; } /** * enforceGrouping * @description Ensures all items exist within a sortable group * @param {Immutable.List} items List of items in Sortable component * @param {Function} generateGroupId Creates a new unique group key * @returns {Immutable.List} Items wrapped in groups at top level */ function enforceGrouping(items, generateGroupId) { /** * Ensure that all top-level drag items in the sortable list are groups and * move all top-level items into groups. */ return items.map(item => { // If a group exists but has not been formatted yet, format it if (Immutable.List.isList(item)) { return Immutable.fromJS({ type: "group", children: item, id: generateGroupId(), }); } // If a group already exists, pass the group through if (item.get("type") === "group") { return item; } // If the item is a standalone ungrouped item, place it in a group return Immutable.fromJS({ type: "group", children: [item], id: generateGroupId(), }); }); } /** * prohibitGrouping * @description Ensures all items exist at the top level (not grouped) * @param {Immutable.List} items List of items in Sortable component * @returns {Immutable.List} Items extracted from all grouping levels */ function prohibitGrouping(items) { return items.reduce((updatedItems, currentItem) => { // If a group exists and is unformatted, extract any items from it if (Immutable.List.isList(currentItem)) { return updatedItems.concat(currentItem); } // If an item exists, pass the item through if (currentItem.get("type") === "item") { return updatedItems.push(currentItem); } // If a group exists and is formatted, extract any items from it if ( currentItem.get("type") === "group" && currentItem.has("children") && currentItem.get("children").size > 0 ) { return updatedItems.concat(currentItem.get("children")); } return updatedItems; }, Immutable.List()); } /** * formatItemsForOutput * @description Removes internal group data from items before passing items * to the consumer * @param {Immutable.List} items Items to format * @returns {Immutable.List} The formatted items list */ function formatItemsForOutput(items) { return items.map(item => (item.get("type") === "group" ? item.get("children") : item)); } class Sortable extends React.Component { static propTypes = { /** Whether or not to force items into groups */ allowGrouping: PropTypes.bool, /** CSS class names. */ className: PropTypes.string, /** * Initial value of the <Sortable /> component. List of items and groups. * Can either be an Immutable.List or a JS array containing Immutable.Maps * or JS objects for items/groups, respectively. * * Default value format: [ <groups or items> ] * Item format: { type: 'item', id: 1, text: 'Hello' } * Group format: [ <items only> ] */ defaultValue: PropTypes.oneOfType([ PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, type: PropTypes.oneOf(["item"]).isRequired, children: PropTypes.array, text: PropTypes.string, }), PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, type: PropTypes.oneOf(["item"]).isRequired, children: PropTypes.array, text: PropTypes.string, }) ), ]) ), PropTypes.instanceOf(Immutable.List), ]).isRequired, /** * Handler that is called when a drag interaction ends. This function * receives the Immutable.List value of the drag and drop component as * an argument. */ onChange: PropTypes.func, /** * Function for rendering custom components in the drag-and-drop context. * This function will receive one object argument with the following * properties: * * - baseTestSection (String): Test section name passed to <Sortable /> * - connectDragSource (Function): Wrap your item component with * this function to make it draggable * - item (Immutable.Map): Contains item data (id, text) * - isDragPreview (Boolean): Whether the current item is a preview * - isDragging (Boolean): Whether the current item is being dragged * - position (Array<Number>): Indeces representing the nested item position */ renderItem: PropTypes.func.isRequired, /** Test section name */ testSection: PropTypes.string, }; static defaultProps = { allowGrouping: false, onChange: () => null, testSection: "sortable", }; constructor(props) { super(props); const { allowGrouping, defaultValue: items } = this.props; const coercedItems = Immutable.List.isList(items) ? items : Immutable.fromJS(items); this.groupKeyCounter = 0; this.state = { items: allowGrouping ? enforceGrouping(coercedItems, this.generateGroupId) : prohibitGrouping(coercedItems), }; } componentDidUpdate(prevProps, prevState) { const { allowGrouping: currentAllowGrouping } = this.props; const prevAllowGrouping = prevProps.allowGrouping; const { items } = this.state; if (prevAllowGrouping && !currentAllowGrouping) { this.setState({ items: prohibitGrouping(items), }); } else if (!prevAllowGrouping && currentAllowGrouping) { this.setState({ items: enforceGrouping(items, this.generateGroupId), }); } if (!currentAllowGrouping) { const coercedNewItems = Immutable.List.isList(this.props.defaultValue) ? this.props.defaultValue : Immutable.fromJS(this.props.defaultValue); if (items.size !== coercedNewItems.size) { this.setState({ items: currentAllowGrouping ? enforceGrouping(coercedNewItems, this.generateGroupId) : prohibitGrouping(coercedNewItems), }); } } } shouldComponentUpdate(nextProps, nextState) { return ( !nextState.items.equals(this.state.items) || nextProps.allowGrouping !== this.props.allowGrouping || nextProps.defaultValue !== this.props.defaultValue ); } generateGroupId = () => { const result = this.groupKeyCounter; this.groupKeyCounter += 1; return result; }; moveItem = (currentPosition, nextPosition) => { const currentItemKeypath = getKeypathFromPosition(currentPosition); const currentItem = this.state.items.getIn(currentItemKeypath); const realNextPosition = getRealNextPosition(currentPosition, nextPosition); let updatedItems = this.state.items; // Move the item at the current position to the next position in the state updatedItems = removeItem(currentPosition, updatedItems); updatedItems = insertItem(realNextPosition, updatedItems, currentItem); this.setState({ items: updatedItems, }); return Promise.resolve({ position: realNextPosition }); }; endDrag = () => { const { allowGrouping, onChange } = this.props; let { items } = this.state; // After dragging is complete, remove all empty top-level groups let updatedItems = items; updatedItems = updatedItems.filter(item => { return item.get("type") !== "group" || item.get("children").size > 0; }); if (allowGrouping) { updatedItems = enforceGrouping(updatedItems, this.generateGroupId); } this.setState({ items: updatedItems }); onChange(formatItemsForOutput(updatedItems)); }; render() { const { allowGrouping, className, defaultValue, onChange, renderItem, testSection, ...props } = this.props; const { items } = this.state; return ( <div className={classNames("oui-sortable", className)} data-test-section={testSection} {...props}> <SortableGroup items={items} onMove={this.moveItem} endDrag={this.endDrag} position={[]} renderItem={renderItem} baseTestSection={testSection} id="root" /> <SortDragLayer renderItem={renderItem} baseTestSection={testSection} /> </div> ); } } /** * Please note: in order to test this component with Enzyme, this component must * by wrapped in a test version of HTML5Backend. Therefore, an unwrapped version * of this component must be accessible to unit test files. */ export const UnwrappedSortable = Sortable; export default DragDropContext(HTML5Backend)(Sortable);