wix-style-react
Version:
401 lines (340 loc) • 10.8 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import SelectorListContent from './Content';
import Box from '../Box';
import Search from '../Search';
import Text from '../Text';
import ToggleAllCheckbox from './ToggleAllCheckbox';
import { dataHooks } from './SelectorList.helpers';
import { st, classes } from './SelectorList.st.css';
/**
* Use this component when needed to select one / multiple items having complex descriptions.
* E.g.: choosing products to promote via ShoutOuts
*/
export default class SelectorList extends React.PureComponent {
static displayName = 'SelectorList';
static propTypes = {
/** applied as data-hook HTML attribute that can be used to create driver in testing */
dataHook: PropTypes.string,
/**
* paging function that should have a signature of
* ```typescript
* (searchQuery: string, offset: number, limit: number) =>
* Promise<{
* items: Array<{
* id: number | string,
* title: node,
* subtitle?: string,
* extraText?: string,
* extraNode?: node,
* disabled?: boolean // show item as disabled, dont count it in "select all", exclude from `onOk`
* selected?: boolean // force item as selected
* image?: node
* subtitleNode?: node,
* belowNode?: node,
* showBelowNodeOnSelect?: boolean,
* }>,
* totalCount: number
* }>
* ```
* `offset` - next requested item's index<br>
* `limit` - number of items requested<br>
* `totalCount` - total number of items that suffice the current search query
* */
dataSource: PropTypes.func.isRequired,
/** Image icon size */
imageSize: PropTypes.oneOf([
'tiny',
'small',
'portrait',
'large',
'cinema',
]),
/**
* Image icon shape, `rectangular` or `circle`.<br>
* NOTE: `circle` is not compatible with `imageSize` of `portrait` or `cinema`
* */
imageShape: (props, propName, componentName) => {
if (
['portrait', 'cinema'].includes(props.imageSize) &&
props[propName] === 'circle'
) {
return new Error(
`${componentName}: prop "imageSize" with value of "${props.imageSize}" is incompatible with prop imageShape with value of "circle" — use "rectangular" instead.`,
);
}
},
/** Placeholder text of the search input */
searchPlaceholder: PropTypes.string,
/**
* Component/element that will be rendered when there is nothing to display,
* i.e. empty `{items:[], totalCount: 0}` was returned on the first call to `dataSource`
* */
emptyState: PropTypes.node,
/**
* Function that will get the current `searchQuery` and should return the component/element
* that will be rendered when there are no items that suffice the entered search query
* */
renderNoResults: PropTypes.func,
/** Number of items loaded each time the user scrolls down */
itemsPerPage: PropTypes.number,
/** Whether to display the search input or not */
withSearch: PropTypes.bool,
/** Search debounce in milliseconds */
searchDebounceMs: PropTypes.number,
/** Height classes property, sets the height of the list container */
height: PropTypes.string,
/** Max-height classes property, sets the maximum height of the list container. */
maxHeight: PropTypes.string,
/** display checkbox and allow multi selection */
multiple: PropTypes.bool,
/** callback that triggers on select and return selected item object*/
onSelect: PropTypes.func,
/** string to be displayed in footer when `multiple` prop is used and no items are selected */
selectAllText: PropTypes.string,
/** string to be displayed in footer when `multiple` prop is used and some or all items ar selected */
deselectAllText: PropTypes.string,
/** amount of items to load on initial render or after search */
initialAmountToLoad: PropTypes.number,
/** Fixed text displayed above the list */
subtitle: PropTypes.node,
};
static defaultProps = {
searchPlaceholder: 'Search...',
imageSize: 'large',
imageShape: 'rectangular',
itemsPerPage: 50,
withSearch: true,
height: '100%',
maxHeight: '100%',
};
state = {
isLoaded: false,
isSearching: false,
items: [],
searchValue: '',
selectedItems: [],
noResultsFound: false,
isEmpty: false,
};
componentDidMount() {
this._loadInitialItems();
}
/** Resets list items and loads first page from dataSource while persisting searchValue */
reloadInitialItems() {
const searchValue = this.state.searchValue;
this.setState({
items: [],
searchValue,
isSearching: Boolean(searchValue),
isLoaded: false,
});
this._loadInitialItems(searchValue);
}
_renderList = () => {
const {
dataHook,
emptyState,
renderNoResults,
height,
maxHeight,
imageSize,
imageShape,
multiple,
} = this.props;
const {
items,
isLoaded,
isEmpty,
isSearching,
searchValue,
noResultsFound,
selectedItems,
} = this.state;
const hasMore = this._hasMore();
const contentProps = {
items,
selectedItems,
onToggle: this._onToggle,
emptyState,
renderNoResults,
isEmpty,
isLoading: !isLoaded || isSearching,
noResultsFound,
imageSize,
imageShape,
multiple,
loadMore: this._loadMore,
hasMore,
checkIsSelected: this._checkIsSelected,
searchValue,
};
const shouldRenderSubheader = isLoaded && !isEmpty;
return (
<Box
direction="vertical"
overflow="hidden"
{...{
dataHook,
height,
maxHeight,
}}
>
{shouldRenderSubheader && this._renderSubheader()}
<SelectorListContent {...contentProps} />
</Box>
);
};
_renderSubheader = () => {
const { subtitle, withSearch, searchDebounceMs, searchPlaceholder } =
this.props;
const { searchValue } = this.state;
return (
<div
className={st(classes.subheaderWrapper, {
withSearch,
})}
>
{subtitle && (
<div className={classes.subtitleWrapper}>
{typeof subtitle === 'string' ? (
<Text dataHook={dataHooks.subtitle}>{subtitle}</Text>
) : (
subtitle
)}
</div>
)}
{withSearch && (
<Search
dataHook={dataHooks.search}
placeholder={searchPlaceholder}
onChange={this._onSearchChange}
onClear={this._onClear}
debounceMs={searchDebounceMs}
value={searchValue}
/>
)}
</div>
);
};
_renderToggleAllCheckbox = () => {
const { selectAllText, deselectAllText } = this.props;
const { items, selectedItems } = this.state;
const enabledItemsAmount = this._getEnabledItems(items).length;
const selectedEnabledItemsAmount =
this._getEnabledItems(selectedItems).length;
const checkboxProps = {
selectAllText,
deselectAllText,
enabledItemsAmount,
selectedEnabledItemsAmount,
selectAll: this._selectAll,
deselectAll: this._deselectAll,
};
return <ToggleAllCheckbox {...checkboxProps} />;
};
render() {
const { children } = this.props;
const { selectedItems } = this.state;
if (typeof children === 'function') {
return children({
renderList: this._renderList,
renderToggleAllCheckbox: this._renderToggleAllCheckbox,
selectedItems,
});
}
return this._renderList();
}
_updateSearchValue = searchValue =>
this.setState(
{
searchValue,
isSearching: true,
items: [],
},
() => this._loadInitialItems(searchValue),
);
_onSearchChange = event => this._updateSearchValue(event.target.value);
_onClear = () => this._updateSearchValue('');
_checkIsSelected = item => {
const { selectedItems } = this.state;
return !!selectedItems.find(({ id }) => item.id === id);
};
_toggleItem = item => {
const { multiple } = this.props;
this.setState(({ selectedItems }) => ({
selectedItems: multiple
? this._checkIsSelected(item)
? selectedItems.filter(({ id }) => item.id !== id)
: selectedItems.concat(item)
: [item],
}));
};
_onToggle = item => {
const { onSelect } = this.props;
this._toggleItem(item);
if (onSelect) {
onSelect(item);
}
};
_selectAll = () => {
const { selectedItems, items } = this.state;
const enabledItems = this._getEnabledItems(items);
this.setState({ selectedItems: selectedItems.concat(enabledItems) });
};
_deselectAll = () =>
this.setState(({ selectedItems }) => ({
selectedItems: selectedItems.filter(({ disabled }) => disabled),
}));
_updateItems = ({ items: nextPageItems, totalCount, searchValue }) => {
const { items, selectedItems } = this.state;
// react only to the resolve of the relevant search
if (searchValue !== this.state.searchValue) {
return;
}
const newItems = [...items, ...nextPageItems];
const newSelectedItems = selectedItems.concat(
nextPageItems.filter(({ selected }) => selected),
);
const noResultsFound = newItems.length === 0 && Boolean(searchValue);
const isEmpty = newItems.length === 0 && !searchValue;
this.setState({
items: newItems,
selectedItems: newSelectedItems,
isLoaded: true,
isEmpty,
isSearching: false,
totalCount,
noResultsFound,
});
};
_loadInitialItems = (searchValue = '') => {
const { dataSource, itemsPerPage } = this.props;
const initialAmountToLoad = this.props.initialAmountToLoad || itemsPerPage;
dataSource(searchValue, 0, initialAmountToLoad).then(dataSourceProps =>
this._updateItems({
...dataSourceProps,
searchValue,
}),
);
};
_loadMore = () => {
const { dataSource, itemsPerPage } = this.props;
const { items, searchValue } = this.state;
dataSource(searchValue, items.length, itemsPerPage).then(dataSourceProps =>
this._updateItems({
...dataSourceProps,
searchValue,
}),
);
};
_hasMore() {
const { items, isLoaded, totalCount, isSearching } = this.state;
return (
(items.length === 0 && !isLoaded) ||
items.length < totalCount ||
isSearching
);
}
_getEnabledItems = items => items.filter(({ disabled }) => !disabled);
}