saagie-ui
Version:
Saagie UI from Saagie Design System
497 lines (430 loc) • 14.7 kB
JavaScript
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;