wix-style-react
Version:
wix-style-react
420 lines (364 loc) • 12 kB
JavaScript
import React from 'react';
import { bool, func, node, number, oneOf, string } from 'prop-types';
import WixComponent from '../BaseComponents/WixComponent';
import Loader from '../Loader/Loader';
import HeaderLayout from '../MessageBox/HeaderLayout';
import FooterLayout from '../MessageBox/FooterLayout';
import Selector from '../Selector/Selector';
import Search from '../Search/Search';
import InfiniteScroll from '../utils/InfiniteScroll';
import Text from '../Text';
import { dataHooks } from './ModalSelectorLayout.helpers';
import Checkbox from '../Checkbox';
import css from './ModalSelectorLayout.scss';
const DEFAULT_EMPTY = (
<div className={css.defaultEmptyStateWrapper}>
<Text>{"You don't have any items"}</Text>
</div>
);
/**
* Use this component when needed to select one / multiple items having complex descriptions.
* E.g.: choosing products to promote via ShoutOuts
*/
export default class ModalSelectorLayout extends WixComponent {
static displayName = 'ModalSelectorLayout';
static propTypes = {
/** Title of the modal */
title: node,
/** Fixed text displayed above the list */
subtitle: node,
/** OK button callback, called with the currently selected item */
onOk: func,
/** X button callback */
onClose: func,
/** Cancel button callback */
onCancel: func,
/**
* paging function that should have a signature of
* ```typescript
* (searchQuery: string, offset: number, limit: number) =>
* Promise<{
* items: Array<{
* id: number | string,
* title: string,
* subtitle?: string,
* extraText?: string,
* extraNode?: string,
* disabled?: boolean // show item as disabled, dont count it in "select all", exclude from `onOk`
* selected?: boolean // force item as selected
* image?: node
* }>,
* 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: func.isRequired,
/** Cancel button's text */
cancelButtonText: string,
/** OK button's text */
okButtonText: string,
/** Image icon size */
imageSize: 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: 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: node.isRequired,
/**
* 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
* */
noResultsFoundStateFactory: func,
/** Number of items loaded each time the user scrolls down */
itemsPerPage: number,
/** Whether to display the search input or not */
withSearch: bool,
height: string,
/** display checkbox and allow multi selection */
multiple: bool,
/** string to be displayed in footer when `multiple` prop is used and no items are selected */
selectAllText: string,
/** string to be displayed in footer when `multiple` prop is used and some or all items ar selected */
deselectAllText: string,
};
static defaultProps = {
title: 'Choose Your Items',
okButtonText: 'Select',
cancelButtonText: 'Cancel',
searchPlaceholder: 'Search...',
imageSize: 'large',
imageShape: 'rectangular',
itemsPerPage: 50,
withSearch: true,
height: '100%',
emptyState: DEFAULT_EMPTY,
noResultsFoundStateFactory: searchValue => (
<div className={css.defaultNoResultsFoundStateWrapper}>
<Text>No items matched your search {`"${searchValue}"`}</Text>
</div>
),
selectAllText: 'Select All',
deselectAllText: 'Deselect All',
};
state = {
isLoaded: false,
isSearching: false,
items: [],
searchValue: '',
selectedItems: [],
shouldShowNoResultsFoundState: false,
isEmpty: false,
};
render() {
const {
title,
subtitle,
onClose,
searchPlaceholder,
emptyState,
noResultsFoundStateFactory,
withSearch,
height,
} = this.props;
const {
items,
isLoaded,
isEmpty,
isSearching,
searchValue,
shouldShowNoResultsFoundState,
} = this.state;
return (
<div className={css.modalContent} style={{ height }}>
<HeaderLayout title={title} onCancel={onClose} />
{isLoaded && !isEmpty && (
<div className={css.subheaderWrapper}>
{subtitle && (
<div className={css.subtitleWrapper}>
<Text dataHook={dataHooks.subtitle}>{subtitle}</Text>
</div>
)}
{withSearch && (
<div className={css.searchWrapper}>
<Search
dataHook={dataHooks.search}
placeholder={searchPlaceholder}
value={searchValue}
onChange={e => this._onSearchChange(e)}
/>
</div>
)}
</div>
)}
<div className={css.modalBody} data-hook={dataHooks.modalBody}>
{((items.length === 0 && !isLoaded) || isSearching) && (
<div className={css.mainLoaderWrapper}>
<Loader size="medium" dataHook={dataHooks.mainLoader} />
</div>
)}
{isEmpty && (
<div
data-hook={dataHooks.emptyState}
className={css.emptyStateWrapper}
children={emptyState}
/>
)}
{(!isLoaded || items.length > 0 || isSearching) && (
<InfiniteScroll
key={searchValue}
loadMore={() => this._loadMore()}
hasMore={this._hasMore()}
useWindow={false}
children={this._renderItems()}
loader={
items.length > 0 && (
<div className={css.nextPageLoaderWrapper}>
<Loader size="small" dataHook={dataHooks.nextPageLoader} />
</div>
)
}
/>
)}
{shouldShowNoResultsFoundState && (
<div
data-hook={dataHooks.noResultsFoundState}
className={css.noResultsFoundStateWrapper}
children={noResultsFoundStateFactory(searchValue)}
/>
)}
</div>
{this._renderFooter()}
</div>
);
}
_renderItems() {
const { items, selectedItems } = this.state;
const { imageSize, imageShape, multiple } = this.props;
const isSelected = item => !!selectedItems.find(({ id }) => item.id === id);
const onToggle = item =>
this.setState({
selectedItems: multiple
? isSelected(item)
? selectedItems.filter(({ id }) => item.id !== id)
: selectedItems.concat(item)
: [item],
});
if (items.length > 0) {
return (
<ul data-hook={dataHooks.list} className={css.list}>
{items.map(item => (
<Selector
id={item.id}
key={item.id}
dataHook={dataHooks.selector}
imageSize={imageSize}
imageShape={imageShape}
toggleType={multiple ? 'checkbox' : 'radio'}
image={item.image}
title={item.title}
subtitle={item.subtitle}
extraNode={
item.extraNode ? (
item.extraNode
) : (
<Text secondary>{item.extraText}</Text>
)
}
isSelected={isSelected(item)}
isDisabled={item.disabled}
onToggle={() => !item.disabled && onToggle(item)}
/>
))}
</ul>
);
}
}
_onSearchChange(e) {
this.setState({
searchValue: e.target.value,
isSearching: true,
items: [],
});
}
_loadMore() {
const { dataSource, itemsPerPage } = this.props;
const { items, searchValue } = this.state;
dataSource(searchValue, items.length, itemsPerPage).then(
({ items: itemsFromNextPage, totalCount }) => {
if (this.state.searchValue === searchValue) {
// react only to the resolve of the relevant search
const newItems = [...items, ...itemsFromNextPage];
const selectedItems = this.state.selectedItems.concat(
itemsFromNextPage.filter(({ selected }) => selected),
);
const shouldShowNoResultsFoundState =
newItems.length === 0 && searchValue;
const isEmpty = newItems.length === 0 && !searchValue;
this.setState({
items: newItems,
selectedItems,
isLoaded: true,
isEmpty,
isSearching: false,
totalCount,
shouldShowNoResultsFoundState,
});
}
},
);
}
_hasMore() {
const { items, isLoaded, totalCount, isSearching } = this.state;
return (
(items.length === 0 && !isLoaded) ||
items.length < totalCount ||
isSearching
);
}
_getEnabledItems = items => items.filter(({ disabled }) => !disabled);
_renderFooter = () => {
const { selectedItems } = this.state;
const {
onCancel,
onOk,
cancelButtonText,
okButtonText,
multiple,
} = this.props;
const enabledItems = this._getEnabledItems(selectedItems);
return (
<FooterLayout
onCancel={onCancel}
onOk={() => onOk(multiple ? enabledItems : enabledItems[0])}
cancelText={cancelButtonText}
confirmText={okButtonText}
enableOk={!!selectedItems.length}
children={multiple && this._renderFooterSelector()}
/>
);
};
_renderFooterSelector = () => {
const { selectAllText, deselectAllText } = this.props;
const { selectedItems, items } = this.state;
const enabledItems = this._getEnabledItems(items);
const selectedEnabled = selectedItems.filter(({ disabled }) => !disabled);
const cases = {
select: {
text: selectAllText,
number: enabledItems.length,
onChange: () =>
this.setState({ selectedItems: selectedItems.concat(enabledItems) }),
indeterminate: false,
checked: false,
},
deselect: {
text: deselectAllText,
number: selectedEnabled.length,
onChange: () =>
this.setState({
selectedItems: selectedItems.filter(({ disabled }) => disabled),
}),
indeterminate: selectedEnabled.length < enabledItems.length,
checked: true,
},
};
const {
text,
number: num,
onChange,
checked,
indeterminate,
} = selectedEnabled.length ? cases.deselect : cases.select;
return (
<Checkbox
dataHook="footer-selector"
checked={checked}
onChange={onChange}
indeterminate={indeterminate}
>
<Text weight="normal">{` ${text} (${num})`}</Text>
</Checkbox>
);
};
}