UNPKG

saagie-ui

Version:

Saagie UI from Saagie Design System

497 lines (430 loc) 14.7 kB
import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Datalist } from '../datalist/Datalist'; import { DatalistRow } from '../datalist/DatalistRow'; import { DatalistCol } from '../datalist/DatalistCol'; import { SearchBar } from '../../molecules/searchBar/SearchBar'; import { Button } from '../../atoms/button/Button'; import { Icon } from '../../atoms/icon/Icon'; import { FormControlSelect } from '../../atoms/formControlSelect/FormControlSelect'; import { DatalistColActions } from '../datalist/DatalistColActions'; import { FormCheck } from '../../atoms/formCheck/FormCheck'; import { PagePopin } from '../pagePopin/PagePopin'; import { PagePopinActionButton } from '../pagePopin/PagePopinActionButton'; import { SearchEmptyState } from '../../molecules/searchEmptyState/SearchEmptyState'; import { EmptyState } from '../../molecules/emptyState/EmptyState'; import { Modal } from '../modal/Modal'; import { ModalBody } from '../modal/ModalBody'; import { ModalCloseButton } from '../modal/ModalCloseButton'; import { ModalHeader } from '../modal/ModalHeader'; import { ModalTitle } from '../modal/ModalTitle'; const propTypes = { /** * The array of items that are available to the seletion. */ items: PropTypes.arrayOf(PropTypes.object), /** * The array of items that are already selected. */ addedItems: PropTypes.arrayOf(PropTypes.object), /** * The search filter function. */ searchFilter: PropTypes.func, /** * The search bar placeholder. */ searchPlaceholder: PropTypes.string, /** * Tell to the component to clear the SearchBar on new item addition. */ isClearingSearchOnAdd: PropTypes.bool, /** * Tell to the component that it should hide the checkboxes and removal buttons. */ isReadOnly: PropTypes.bool, /** * Tell to the component to focus the SearchBar on mount. */ isFocusingSearchOnMount: PropTypes.bool, /** * The label of the button that toggle the addition form. */ addButtonLabel: PropTypes.node, /** * The label when there is no more items to add. */ addButtonLabelEmpty: PropTypes.node, /** * The title of the addition form. */ addFormTitle: PropTypes.node, /** * The placeholder of the addition form control select. */ addFormSelectPlaceholder: PropTypes.string, /** * Function that takes an item, and should return the label that will be used * by the FormControlSelect. */ addFormSelectLabelMapping: PropTypes.func, /** * The label of the submittion button of the addition form. */ addFormButtonLabel: PropTypes.node, /** * Props to forward to empty state. */ emptyStateProps: PropTypes.object, /** * Props to forward to search empty state. */ searchEmptyStateProps: PropTypes.object, /** * Function that will give two parameter to render the row: item, index. */ children: PropTypes.func, /** * The object's key that will be used in filter and map to identify the unicity of an object. * If the values in the object's key are not unique, the DatalistRow will encounter not unique * key problem. */ uniqueObjectKey: PropTypes.string, /** * The component used for the root node. * Either a string to use a DOM element or a component. */ tag: PropTypes.elementType, /** * The callback function called on items addition. The items are given as * a parameter. */ onAdd: PropTypes.func, /** * The callback function called on items removal. The items are given as a * parameter. */ onRemove: PropTypes.func, /** * Callback when the addition form is opening. */ onAddFormOpen: PropTypes.func, /** * Callback when the addition form is closing. */ onAddFormClose: PropTypes.func, /** * Object that will be given to PagePopin. */ pagePopinProps: PropTypes.object, /** * The delay of the highlight of newly added items. */ highlightDelay: PropTypes.number, }; const defaultProps = { items: [], addedItems: [], searchFilter: () => true, searchPlaceholder: 'Find...', isClearingSearchOnAdd: false, isFocusingSearchOnMount: false, isReadOnly: false, addButtonLabel: 'Add items', addButtonLabelEmpty: 'No items to add', addFormTitle: '', addFormSelectPlaceholder: 'Search to add...', addFormSelectLabelMapping: null, addFormButtonLabel: 'Add', emptyStateProps: { content: 'No items available' }, searchEmptyStateProps: { entity: 'item' }, children: () => {}, tag: 'div', onAdd: () => {}, onRemove: () => {}, onAddFormOpen: () => {}, onAddFormClose: () => {}, pagePopinProps: {}, highlightDelay: 1000, uniqueObjectKey: 'id', }; /** * Set the focus on an input when the condition is met. * @param {import('react').RefObject} ref The ref to focus when the condition is met. * @param {boolean} condition The condition to met for the focus to be set. */ const useFocus = (ref, condition = true) => { useEffect(() => { if (condition && ref && ref.current) { ref.current.focus(); } }, [ref.current, condition]); }; /** * Tell if an array or a string is empty or not. * @param {Array<any>|string} element The array or string to check the length */ const isEmpty = (element) => (element.length === 0); export const TransferDatalist = ({ items, addedItems, uniqueObjectKey, searchFilter, searchPlaceholder, isClearingSearchOnAdd, isReadOnly, isFocusingSearchOnMount, addButtonLabel, addButtonLabelEmpty, addFormTitle, addFormSelectPlaceholder, addFormSelectLabelMapping, addFormButtonLabel, emptyStateProps, searchEmptyStateProps, children, onAdd, onRemove, onAddFormOpen, onAddFormClose, pagePopinProps, tag: Tag, highlightDelay, }) => { const [search, setSearch] = useState(''); const [isAddFormShown, setIsAddFormShown] = useState(false); const [itemsToAdd, setItemsToAdd] = useState([]); const [itemsToRemove, setItemsToRemove] = useState([]); const [activeItems, setActiveItems] = useState([]); const [isMenuOpen, setIsMenuOpen] = useState(false); const addFormSelectRef = useRef(undefined); const searchRef = useRef(undefined); // Handle the focus of the FormControlSelect useFocus(addFormSelectRef, isAddFormShown); // Handle the focus of the SearchBar useFocus(searchRef, !isAddFormShown && isFocusingSearchOnMount); // Handle the blue border effect on newly added items. useEffect(() => { let timer; if (activeItems.length) { timer = setTimeout(() => setActiveItems([]), highlightDelay); } return () => { clearTimeout(timer); }; }, [activeItems]); const classes = classnames( 'sui-o-transfer-datalist', isAddFormShown ? 'as--add-form-open' : '', ); const filteredAddedItems = addedItems.filter((item) => searchFilter(item, search)); const areItemsEqual = (item) => (it) => it[uniqueObjectKey] === item[uniqueObjectKey]; const areItemsNotEqual = (item) => (it) => it[uniqueObjectKey] !== item[uniqueObjectKey]; const getItemsWithoutItem = (item) => (itemList) => itemList.filter(areItemsNotEqual(item)); const availableItems = items .filter((item) => addedItems.every(areItemsNotEqual(item))) .map((item) => ({ label: addFormSelectLabelMapping ? addFormSelectLabelMapping(item) : item[uniqueObjectKey], value: item[uniqueObjectKey], item, })); const handleAddFormOpen = () => { setIsAddFormShown(true); onAddFormOpen(); }; const handleAddFormClose = () => { setIsAddFormShown(false); onAddFormClose(); }; const handleAdd = () => { const newItems = itemsToAdd.map((item) => item.item); onAdd(newItems); setItemsToAdd([]); handleAddFormClose(); setActiveItems(newItems); if (isClearingSearchOnAdd) { setSearch(''); } }; const handleRemove = (item) => { onRemove([item]); setItemsToRemove(getItemsWithoutItem(item)); }; const handleRemoveBulk = () => { onRemove(itemsToRemove); setItemsToRemove([]); }; const handleCheckItem = (item, isChecked) => { if (isChecked) { setItemsToRemove((s) => [...s, item]); } else { setItemsToRemove(getItemsWithoutItem(item)); } }; const isSelectAllChecked = () => { const mapAndSort = (a) => a.map((item) => item[uniqueObjectKey]).sort(); const addedItemsuniqueValues = mapAndSort(filteredAddedItems); const itemsToRemoveUniqueValue = mapAndSort(itemsToRemove); if (isEmpty(addedItemsuniqueValues)) { return false; } return JSON.stringify(addedItemsuniqueValues) === JSON.stringify(itemsToRemoveUniqueValue); }; const handleSelectAll = () => { if (isSelectAllChecked()) { setItemsToRemove([]); } else { setItemsToRemove(filteredAddedItems); } }; const handleKeyDown = (e) => { if (!isMenuOpen && e.key === 'Enter') { handleAdd(); } }; const isSelectAllIndeterminate = () => ( itemsToRemove.length > 0 && itemsToRemove.length < filteredAddedItems.length ); const isTabIndexable = (condition) => (condition ? '' : '-1'); return ( <> <Modal isOpen={isAddFormShown} onClose={handleAddFormClose} size="lg" isClickOutsideDisabled> <ModalHeader> <ModalCloseButton /> <ModalTitle>{addFormTitle}</ModalTitle> </ModalHeader> <ModalBody> <div className="sui-g-grid as--auto@xs as--bottom as--no-wrap@xs"> <div className="sui-g-grid__item as--fill@xs sui-o-transfer-datalist__form-grid-item-select"> <FormControlSelect menuPortalTarget={document.body} options={availableItems} placeholder={addFormSelectPlaceholder} value={itemsToAdd} onChange={(values) => setItemsToAdd(values)} onKeyDown={handleKeyDown} onMenuOpen={() => setIsMenuOpen(true)} onMenuClose={() => setIsMenuOpen(false)} isMulti ref={addFormSelectRef} tabIndex={isTabIndexable(isAddFormShown)} data-testid="transfer-datalist-form-control-select" /> </div> <div className="sui-g-grid__item as--no-flex@xs"> <Button color="primary" onClick={handleAdd} tabIndex={isTabIndexable(isAddFormShown)} minWidth="lg" data-testid="transfer-datalist-add-button" > {addFormButtonLabel} </Button> </div> </div> </ModalBody> </Modal> <Tag className={classes}> <div className="sui-o-transfer-datalist__header"> <div className="sui-o-transfer-datalist__header-actions"> <FormCheck className="sui-h-m-none" disabled={isEmpty(filteredAddedItems) || isReadOnly} isIndeterminate={isSelectAllIndeterminate()} checked={isSelectAllChecked()} onChange={handleSelectAll} name="transfer-datalist-select-all-form-check" /> {!isEmpty(addedItems) && ( <SearchBar placeholder={searchPlaceholder} onChange={setSearch} resultsLength={filteredAddedItems.length} value={search} ref={searchRef} data-testid="transfer-datalist-search-bar" /> )} </div> <div className="sui-o-transfer-datalist__header-add"> {availableItems && availableItems.length && !isReadOnly ? ( <Button color="secondary" onClick={handleAddFormOpen} isContextual data-testid="transfer-datalist-open-add-modal-button" > <Icon name="plus" size="sm" position="start" /> {addButtonLabel} </Button> ) : ( <div className="sui-o-transfer-datalist__header-add-empty"> {addButtonLabelEmpty} </div> )} </div> </div> <div className="sui-o-transfer-datalist__content"> {filteredAddedItems.length > 0 && ( <Datalist> {filteredAddedItems.map((item, index) => ( <DatalistRow isActive={!!activeItems.find(areItemsEqual(item))} key={item[uniqueObjectKey]} align="middle" > <DatalistCol size="xs" level="primary"> <FormCheck className="sui-h-m-none" onChange={(e) => handleCheckItem(item, e.target.checked)} checked={!!itemsToRemove.find(areItemsEqual(item))} disabled={isReadOnly} name={`transfer-datalist-form-check-${item[uniqueObjectKey]}`} /> </DatalistCol> {children(item, index)} <DatalistColActions size="sm"> <Button size="sm" onClick={() => handleRemove(item)} isSquare isDisabled={isReadOnly} data-testid="transfer-datalist-remove-element-button" > <Icon name="minus" /> </Button> </DatalistColActions> </DatalistRow> ))} </Datalist> )} {(isEmpty(filteredAddedItems) && !isEmpty(search)) && ( <SearchEmptyState className="sui-o-transfer-datalist__content-empty" searchTerm={search} {...searchEmptyStateProps} /> )} {(isEmpty(filteredAddedItems) && isEmpty(search)) && ( <EmptyState className="sui-o-transfer-datalist__content-empty" {...emptyStateProps} /> )} </div> </Tag> <PagePopin selected={itemsToRemove.length} onClose={() => setItemsToRemove([])} {...pagePopinProps} actions={( <PagePopinActionButton icon={<Icon name="minus" />} onClick={handleRemoveBulk} data-testid="transfer-datalist-page-popin-button" > Remove </PagePopinActionButton> )} /> </> ); }; TransferDatalist.propTypes = propTypes; TransferDatalist.defaultProps = defaultProps;