import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import styled, { cx, css } from 'react-emotion'; import { List, Map } from 'immutable'; import { partial } from 'lodash'; import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc'; import { ObjectControl } from 'netlify-cms-widget-object'; import { TYPES_KEY, getTypedFieldForValue, resolveFieldKeyType, getErrorMessageForTypedFieldAndValue, } from './typedListHelpers'; import { ListItemTopBar, ObjectWidgetTopBar, colors, lengths, components, } from 'netlify-cms-ui-default'; function valueToString(value) { return value ? value.join(',').replace(/,([^\s]|$)/g, ', $1') : ''; } const ListItem = styled.div(); const SortableListItem = SortableElement(ListItem); const StyledListItemTopBar = styled(ListItemTopBar)` background-color: ${colors.textFieldBorder}; `; const NestedObjectLabel = styled.div` display: ${props => (props.collapsed ? 'block' : 'none')}; border-top: 0; color: ${props => (props.error ? colors.errorText : 'inherit')}; background-color: ${colors.textFieldBorder}; padding: 13px; border-radius: 0 0 ${lengths.borderRadius} ${lengths.borderRadius}; `; const styles = { collapsedObjectControl: css` display: none; `, listControlItem: css` margin-top: 18px; &:first-of-type { margin-top: 26px; } `, listControlItemCollapsed: css` padding-bottom: 0; `, }; const SortableList = SortableContainer(({ items, renderItem }) => { return
{items.map(renderItem)}
; }); const valueTypes = { SINGLE: 'SINGLE', MULTIPLE: 'MULTIPLE', MIXED: 'MIXED', }; export default class ListControl extends React.Component { validations = []; static propTypes = { metadata: ImmutablePropTypes.map, onChange: PropTypes.func.isRequired, onChangeObject: PropTypes.func.isRequired, onValidateObject: PropTypes.func.isRequired, value: ImmutablePropTypes.list, field: PropTypes.object, forID: PropTypes.string, controlRef: PropTypes.func, mediaPaths: ImmutablePropTypes.map.isRequired, getAsset: PropTypes.func.isRequired, onOpenMediaLibrary: PropTypes.func.isRequired, onAddAsset: PropTypes.func.isRequired, onRemoveInsertedMedia: PropTypes.func.isRequired, classNameWrapper: PropTypes.string.isRequired, setActiveStyle: PropTypes.func.isRequired, setInactiveStyle: PropTypes.func.isRequired, editorControl: PropTypes.func.isRequired, resolveWidget: PropTypes.func.isRequired, clearFieldErrors: PropTypes.func.isRequired, fieldsErrors: ImmutablePropTypes.map.isRequired, }; static defaultProps = { value: List(), }; constructor(props) { super(props); const { field, value } = props; const allItemsCollapsed = field.get('collapsed', true); const itemsCollapsed = value && Array(value.size).fill(allItemsCollapsed); this.state = { itemsCollapsed: List(itemsCollapsed), value: valueToString(value), }; } getValueType = () => { const { field } = this.props; if (field.get('fields')) { return valueTypes.MULTIPLE; } else if (field.get('field')) { return valueTypes.SINGLE; } else if (field.get(TYPES_KEY)) { return valueTypes.MIXED; } else { return null; } }; /** * Always update so that each nested widget has the option to update. This is * required because ControlHOC provides a default `shouldComponentUpdate` * which only updates if the value changes, but every widget must be allowed * to override this. */ shouldComponentUpdate() { return true; } handleChange = e => { const { onChange } = this.props; const oldValue = this.state.value; const newValue = e.target.value; const listValue = e.target.value.split(','); if (newValue.match(/,$/) && oldValue.match(/, $/)) { listValue.pop(); } const parsedValue = valueToString(listValue); this.setState({ value: parsedValue }); onChange(listValue.map(val => val.trim())); }; handleFocus = () => { this.props.setActiveStyle(); }; handleBlur = e => { const listValue = e.target.value .split(',') .map(el => el.trim()) .filter(el => el); this.setState({ value: valueToString(listValue) }); this.props.setInactiveStyle(); }; handleAdd = e => { e.preventDefault(); const { value, onChange } = this.props; const parsedValue = this.getValueType() === valueTypes.SINGLE ? null : Map(); this.setState({ itemsCollapsed: this.state.itemsCollapsed.push(false) }); onChange((value || List()).push(parsedValue)); }; handleAddType = (type, typeKey) => { const { value, onChange } = this.props; let parsedValue = Map().set(typeKey, type); this.setState({ itemsCollapsed: this.state.itemsCollapsed.push(false) }); onChange((value || List()).push(parsedValue)); }; processControlRef = ref => { if (!ref) return; this.validations.push(ref.validate); }; validate = () => { this.validations.forEach(validateListItem => { validateListItem(); }); }; /** * In case the `onChangeObject` function is frozen by a child widget implementation, * e.g. when debounced, always get the latest object value instead of using * `this.props.value` directly. */ getObjectValue = idx => this.props.value.get(idx) || Map(); handleChangeFor(index) { return (fieldName, newValue, newMetadata) => { const { value, metadata, onChange, field } = this.props; const collectionName = field.get('name'); const newObjectValue = this.getValueType() !== valueTypes.SINGLE ? this.getObjectValue(index).set(fieldName, newValue) : newValue; const parsedMetadata = { [collectionName]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata || {}), }; onChange(value.set(index, newObjectValue), parsedMetadata); }; } handleRemove = (index, event) => { event.preventDefault(); const { itemsCollapsed } = this.state; const { value, metadata, onChange, field, clearFieldErrors } = this.props; const collectionName = field.get('name'); const isSingleField = this.getValueType() === valueTypes.SINGLE; const metadataRemovePath = isSingleField ? value.get(index) : value.get(index).valueSeq(); const parsedMetadata = metadata && { [collectionName]: metadata.removeIn(metadataRemovePath) }; // Removed item object index is the last item in the list const removedItemIndex = value.count() - 1; this.setState({ itemsCollapsed: itemsCollapsed.delete(index) }); onChange(value.remove(index), parsedMetadata); clearFieldErrors(); // Remove deleted item object validation if (this.validations) { this.validations.splice(removedItemIndex, 1); } }; handleItemCollapseToggle = (index, event) => { event.preventDefault(); const { itemsCollapsed } = this.state; const collapsed = itemsCollapsed.get(index); this.setState({ itemsCollapsed: itemsCollapsed.set(index, !collapsed) }); }; handleCollapseAllToggle = e => { e.preventDefault(); const { value } = this.props; const { itemsCollapsed } = this.state; const allItemsCollapsed = itemsCollapsed.every(val => val === true); this.setState({ itemsCollapsed: List(Array(value.size).fill(!allItemsCollapsed)) }); }; objectLabel(item) { const { field } = this.props; if (this.getValueType() === valueTypes.MIXED) { return getTypedFieldForValue(field, item).get('label', field.get('name')); } const multiFields = field.get('fields'); const singleField = field.get('field'); const labelField = (multiFields && multiFields.first()) || singleField; const value = multiFields ? item.get(multiFields.first().get('name')) : singleField.get('label'); return (value || `No ${labelField.get('name')}`).toString(); } onSortEnd = ({ oldIndex, newIndex }) => { const { value } = this.props; const { itemsCollapsed } = this.state; // Update value const item = value.get(oldIndex); const newValue = value.delete(oldIndex).insert(newIndex, item); this.props.onChange(newValue); // Update collapsing const collapsed = itemsCollapsed.get(oldIndex); const updatedItemsCollapsed = itemsCollapsed.delete(oldIndex).insert(newIndex, collapsed); this.setState({ itemsCollapsed: updatedItemsCollapsed }); }; renderItem = (item, index) => { const { classNameWrapper, editorControl, onValidateObject, metadata, clearFieldErrors, fieldsErrors, controlRef, resolveWidget, } = this.props; const { itemsCollapsed } = this.state; const collapsed = itemsCollapsed.get(index); let field = this.props.field; if (this.getValueType() === valueTypes.MIXED) { field = getTypedFieldForValue(field, item); if (!field) { return this.renderErroneousTypedItem(index, item); } } return ( {this.objectLabel(item)} ); }; renderErroneousTypedItem(index, item) { const field = this.props.field; const errorMessage = getErrorMessageForTypedFieldAndValue(field, item); return ( {errorMessage} ); } renderListControl() { const { value, forID, field, classNameWrapper } = this.props; const { itemsCollapsed } = this.state; const items = value || List(); const label = field.get('label', field.get('name')); const labelSingular = field.get('label_singular') || field.get('label', field.get('name')); const listLabel = items.size === 1 ? labelSingular.toLowerCase() : label.toLowerCase(); return (
this.handleAddType(type, resolveFieldKeyType(field))} heading={`${items.size} ${listLabel}`} label={labelSingular.toLowerCase()} onCollapseToggle={this.handleCollapseAllToggle} collapsed={itemsCollapsed.every(val => val === true)} />
); } renderInput() { const { forID, classNameWrapper } = this.props; const { value } = this.state; return ( ); } render() { if (this.getValueType() !== null) { return this.renderListControl(); } else { return this.renderInput(); } } }