import { concat, isEmpty, sortBy as lodashSortBy, times, uniqBy, xorBy, } from 'lodash'; import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; import { ThemeProvider } from 'styled-components'; import i18next from 'i18next'; import Checkbox from '../Checkbox'; import { CheckboxContainer, Container, DropdownButton, DropdownItem, DropdownItemTag, DropdownItemText, DropdownList, DropdownListContainer, Icon, InputContainer, LabelContainer, LabelRequiredCharacter, LoadingDropdownItem, LoadingDropdownListContainer, MultiselectValidationButton, SearchInput, SearchInputItem, SpinnerImg, Text, } from './styledComponents'; import SpinnerIcon from '../images/icon-loading-state-lgrey.svg'; import carretUpIconBlack from '../images/icon-carret-up-ipblack.svg'; import carretUpIconRed from '../images/icon-carret-up-red.svg'; import carretDownIconRed from '../images/icon-carret-down-red.svg'; import carretDownIconBlack from '../images/icon-carret-down-ipblack.svg'; import carretDownIconDmGrey from '../images/icon-carret-down-dmgrey.svg'; import carretDownIconWhite from '../images/icon-carret-down-white.svg'; import checkIconBlack from '../images/icon-check-ipblack.svg'; import checkIconWhite from '../images/icon-check-white.svg'; import searchIconBlack from '../images/icon-search-ipblack.svg'; import searchIconDmGrey from '../images/icon-search-dmgrey.svg'; import searchIconWhite from '../images/icon-search-white.svg'; import closeIconBlack from '../images/icon-close-ipblack.svg'; import iconNewTabBlack from '../images/icon-new-tab-black.svg'; import { Item, Props } from './interfaces'; import { getTheme } from '../utils/theme'; import { normalizeString } from '../utils/format'; import Tooltip from '../Tooltip'; const MIN_NB_ITEMS_BEFORE_DISPLAYING_SEARCH = 1; const NUMBER_OF_LOADING_DROPDOWN_ITEMS_TO_DISPLAY = 9; const TYPES_OF_DATA_TO_FETCH = { RESET_FETCH: 'RESET_FETCH', LOAD_MORE_DATA: 'LOAD_MORE_DATA', }; // Used for reference : https://stackoverflow.com/questions/32553158/detect-click-outside-react-component const useOutsideAlerter = (ref, handleCloseDropdown) => { useEffect(() => { const handleClickOutside = (event) => { if (ref.current && !ref.current.contains(event.target)) { handleCloseDropdown(); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }); }; const Dropdown = (props: Props): JSX.Element => { const { theme, items, width, height, customStyle, iconSrc, placeholder, label, selectedItems, isDisabled, isRequired, isErrorState, isSearchDisabled, searchPlaceholder, iconCustomStyle, isUniqueSelection, onSelectionChange, onTriggerDropdownList, button, sortBy, labelStyle, customListStyle, tooltipText, shouldInterpretTooltipHTML, link, searchDebounceTimeInMs, pagination, customDropdownListStyle, languageCode, displayName, largeDropdownList, maxSelection, } = props; const [shouldDisplayDropdownList, setShouldDisplayDropdownList] = useState(false); const [dropdownSearchValue, setDropdownSearchValue] = useState(''); // To avoid side effects with the non paginated dropdownSearchValue const [paginationDropdownSearchValue, setPaginationDropdownSearchValue] = useState(''); const [isListLoading, setIsListLoading] = useState( pagination?.isLoading || false ); const [isSearchLoading, setIsSearchLoading] = useState( pagination?.isLoading || false ); const [isLoadingMoreResult, setIsLoadingMoreResult] = useState(false); const [matchingItems, setMatchingItems] = useState(items); const [isSearching, setIsSearching] = useState(false); const [timeoutId, setTimeoutId] = useState(); const [tmpSelectedItems, setTmpSelectedItems] = useState(selectedItems); const updatedTheme = getTheme(theme, 'dropdown'); const wrapperRef = useRef(null); const searchInputRef = useRef(null); const [scrollValue, setScrollValue] = useState(0); const getDisplayValue = (item: Item): string => { if (displayName && item.name) { return item.name; } return item.value; }; const isSelected = (elements: Item[], element: Item): boolean => { return elements.some(({ id }) => id === element.id); }; const isDisabledItem = (item: Item): boolean => item.isDisabled || (!!maxSelection && tmpSelectedItems.length >= maxSelection && !isSelected(tmpSelectedItems, item)); const handleCloseDropdown = () => { if (pagination?.handleFetchMoreData) { pagination.handleFetchMoreData(null); } setShouldDisplayDropdownList(false); setDropdownSearchValue(''); setPaginationDropdownSearchValue(''); setMatchingItems(items); }; useOutsideAlerter(wrapperRef, handleCloseDropdown); useEffect(() => { i18next.changeLanguage(languageCode); }, [languageCode]); useEffect(() => { const dropdownListElem = document.getElementById('dropdown-list'); if (dropdownListElem) { dropdownListElem.removeEventListener('scroll', () => {}); dropdownListElem.addEventListener('scroll', () => { setScrollValue(dropdownListElem.scrollTop); }); } if (isLoadingMoreResult) { setIsLoadingMoreResult(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [matchingItems]); /* **************** */ /* Callback methods */ /* **************** */ useEffect(() => { if (dropdownSearchValue === '') { setMatchingItems(items); return; } const cleanDropdownSearchValue = normalizeString(dropdownSearchValue); const filteredMatchingItems = items.filter((item) => normalizeString(item.value).includes(cleanDropdownSearchValue) ); setMatchingItems(filteredMatchingItems); }, [items, dropdownSearchValue]); useEffect(() => { if (pagination?.isLoading && isLoadingMoreResult) { setIsListLoading(false); setIsSearchLoading(false); return; } if (pagination?.isLoading && !isSearching) { setIsSearchLoading(true); return; } setIsSearchLoading(false); }, [isSearching, pagination?.isLoading, isLoadingMoreResult]); const sortItemsList = (itemsList: Item[]): Item[] => { /* The value -1 is present for the case of 'None' in the application whose item has an id equal to null. As this generic component does not accept null ids and in order not to match with another id, the value is set to -1. */ const mandatoryFirstItem = itemsList.filter((item) => item.id === -1); const itemsWithoutMandatory = itemsList.filter((item) => item.id !== -1); if (!sortBy) { return mandatoryFirstItem.concat(itemsWithoutMandatory); } return mandatoryFirstItem.concat(sortBy(itemsWithoutMandatory)); }; const customDebounce = (value) => { if (timeoutId) { clearTimeout(timeoutId); } if (pagination) { setIsListLoading(true); } const currentTimout = setTimeout(() => { if (pagination?.handleFetchMoreData) { setIsListLoading(false); if (value === '') { pagination.handleFetchMoreData(TYPES_OF_DATA_TO_FETCH.RESET_FETCH); return; } pagination.handleFetchMoreData(value); return; } setDropdownSearchValue(value.toLowerCase()); }, searchDebounceTimeInMs); setTimeoutId(currentTimout); }; const changeSearchValue = (value): void => { if (isDisabled) { return; } setIsSearching(!isEmpty(value)); if (button) { setDropdownSearchValue(value.toLowerCase()); return; } if (pagination) { setPaginationDropdownSearchValue(value.toLowerCase()); } customDebounce(value); }; const resetSearchValue = (): void => { if (searchInputRef.current) { searchInputRef.current.value = ''; setDropdownSearchValue(''); setMatchingItems(items); if (pagination) { setPaginationDropdownSearchValue(''); } } }; const triggerDropdownList = (): void => { if (isDisabled) { return; } if (onTriggerDropdownList) { onTriggerDropdownList(!shouldDisplayDropdownList); } setShouldDisplayDropdownList(!shouldDisplayDropdownList); setTmpSelectedItems(selectedItems); resetSearchValue(); }; const isSelectAllChecked = (): boolean => { return matchingItems.every((item) => { if (item.isDisabled) { return true; } return isSelected(tmpSelectedItems, item); }); }; const selectItem = (item: Item): void => { if (isDisabledItem(item)) { return; } if (isUniqueSelection) { const shouldUnselectItem = isSelected(selectedItems, item) && !isRequired; onSelectionChange( shouldUnselectItem ? null : item, // newly selected (or not) item item, // item on which user clicked shouldUnselectItem // whether user unselected item ); triggerDropdownList(); return; } let isAlreadySelected = false; const updatedSelection: Item[] = tmpSelectedItems.reduce( (result: Item[], current: Item) => { if (current.id === item.id) { isAlreadySelected = true; return result; } result.push(current); return result; }, [] ); if (!isAlreadySelected) { updatedSelection.push(item); } setTmpSelectedItems(updatedSelection); }; const selectAllMatchingItems = (): void => { if (!isSelectAllChecked()) { const allItemsSelected = concat( tmpSelectedItems, matchingItems.filter((item) => !item.isDisabled) ); setTmpSelectedItems(uniqBy(allItemsSelected, 'id')); return; } setTmpSelectedItems( xorBy( tmpSelectedItems, matchingItems.filter((item) => !item.isDisabled), 'id' ) ); }; const isValidationDisabled = (): boolean => { if (isRequired && tmpSelectedItems.length === 0) { return true; } if (xorBy(tmpSelectedItems, selectedItems, 'id').length === 0) { return true; } return false; }; const validateSelection = (): void => { if (!isValidationDisabled) { return; } triggerDropdownList(); onSelectionChange(tmpSelectedItems); }; const handleKeyDownSearchValue = (event): void => { // Early return if key not handled if (event.key !== 'Enter') { return; } // Eearly return if there is not only on matching item if (matchingItems.length !== 1) { return; } selectItem(matchingItems[0]); }; /* ************** */ /* Render methods */ /* ************** */ const getInputText = (): string => { if (!selectedItems.length) { return placeholder; } const selectedItemsSorted = sortItemsList(selectedItems); if (isUniqueSelection || selectedItems.length === 1) { return getDisplayValue(selectedItemsSorted[0]); } return `${getDisplayValue(selectedItemsSorted[0])} (+${ selectedItemsSorted.length - 1 })`; }; const getInputCarretIcon = (): React.FunctionComponent< React.SVGAttributes > => { if (isErrorState) { return shouldDisplayDropdownList ? carretUpIconRed : carretDownIconRed; } if (selectedItems.length) { return shouldDisplayDropdownList ? carretUpIconBlack : carretDownIconBlack; } if (shouldDisplayDropdownList) { return carretUpIconBlack; } if (isDisabled) { return carretDownIconWhite; } return carretDownIconDmGrey; }; const getInputSearchIcon = (): React.FunctionComponent< React.SVGAttributes > => { if (isSearchLoading) { return searchIconWhite; } if (!dropdownSearchValue) { return searchIconDmGrey; } return searchIconBlack; }; const shouldDisplaySearchInput = () => { return items.length >= MIN_NB_ITEMS_BEFORE_DISPLAYING_SEARCH; }; const renderDropdownLabel = () => { return ( {isRequired && ( * )} {label} {tooltipText && tooltipText.length !== 0 && ( )} {link && ( )} ); }; useEffect(() => { const dropdownListElem = document.getElementById('dropdown-list'); if (dropdownListElem && !pagination?.isLoading) { dropdownListElem.scroll(0, scrollValue); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pagination && pagination?.isLoading]); const renderLoadingDropdownList = () => { const arrayOfLoadingDropdownItems: JSX.Element[] = []; times(NUMBER_OF_LOADING_DROPDOWN_ITEMS_TO_DISPLAY, (index) => arrayOfLoadingDropdownItems.push( ) ); return ( {arrayOfLoadingDropdownItems.map( (loadingDropdownItem) => loadingDropdownItem )} ); }; const renderInputDropdown = () => { const inputText = getInputText(); return ( {iconSrc && } {inputText} ); }; const renderDropdownList = () => { if (pagination?.isLoading) { return ( changeSearchValue(e.target.value)} ref={searchInputRef} onKeyDown={handleKeyDownSearchValue} disabled={isDisabled} autoFocus={shouldDisplayDropdownList} /> {renderLoadingDropdownList()} ); } if (!items.length || !matchingItems || !matchingItems.length) { return ( {(!!pagination || (!!items.length && shouldDisplaySearchInput() && !isSearchDisabled)) && ( changeSearchValue(e.target.value)} ref={searchInputRef} disabled={isDisabled} /> {dropdownSearchValue && ( )} )} {i18next.t('GENERAL_NO_RESULT')} {!!button && ( button.handleClick()} disabled={button.isDisabled} isMultiSelect={!isUniqueSelection} > {button.text} )} ); } const matchingItemsSorted = sortItemsList(matchingItems); if (isUniqueSelection) { return ( {!isSearchDisabled && shouldDisplaySearchInput() && ( changeSearchValue(e.target.value)} ref={searchInputRef} onKeyDown={handleKeyDownSearchValue} disabled={isDisabled} autoFocus={shouldDisplayDropdownList} /> {dropdownSearchValue && ( )} )} {isListLoading ? renderLoadingDropdownList() : !!matchingItems.length && ( {matchingItemsSorted.map((item, idx) => { return ( selectItem(item)} isDisabled={item.isDisabled} > {!!item.renderValue && item.renderValue()} {!item.renderValue && ( {getDisplayValue(item)} )} {isSelected(selectedItems, item) && ( )} {item.tags && item.tags.length > 0 && ( {item.tags.join(', ')} )} ); })} {pagination?.displayShowMoreResult && ( { setIsLoadingMoreResult(true); pagination?.handleFetchMoreData( paginationDropdownSearchValue || TYPES_OF_DATA_TO_FETCH.LOAD_MORE_DATA ); }} > {isLoadingMoreResult ? ( <> {i18next.t('GENERAL.SHOW_MORE_RESULTS')} ) : ( i18next.t('GENERAL.SHOW_MORE_RESULTS') )} )} )} {!!button && isSearching && ( button.handleClick()} disabled={button.isDisabled} > {button.text} )} ); } const isSearchDisplay = !isSearchDisabled && shouldDisplaySearchInput(); return ( {isSearchDisplay && ( changeSearchValue(e.target.value)} ref={searchInputRef} disabled={isDisabled} autoFocus={shouldDisplayDropdownList} /> {dropdownSearchValue && ( )} )} {isListLoading ? renderLoadingDropdownList() : !!matchingItems.length && ( {!dropdownSearchValue ? i18next.t('COMPONENT_DROPDOWN_SELECT_ALL') : i18next.t('COMPONENT_DROPDOWN_SELECT_ALL_SEARCH')} {matchingItemsSorted.map((item, idx) => { return ( selectItem(item)} isDisabled={isDisabledItem(item)} isSearchDisplay={isSearchDisplay} isMultiSelect > {!!item.renderValue && item.renderValue()} {!item.renderValue && ( {getDisplayValue(item)} )} {item.tags && item.tags.length > 0 && ( {item.tags.join(', ')} )} ); })} {pagination?.displayShowMoreResult && ( { setIsLoadingMoreResult(true); pagination?.handleFetchMoreData( paginationDropdownSearchValue || TYPES_OF_DATA_TO_FETCH.LOAD_MORE_DATA ); }} > {isLoadingMoreResult ? ( <> {i18next.t('GENERAL.SHOW_MORE_RESULTS')} ) : ( i18next.t('GENERAL.SHOW_MORE_RESULTS') )} )} )} {!!button && isSearching && ( button.handleClick()} disabled={button.isDisabled} isMultiSelect > {button.text} )} {i18next.t('GENERAL_APPLY')} ); }; return ( {label && renderDropdownLabel()} {renderInputDropdown()} {shouldDisplayDropdownList && renderDropdownList()} ); }; Dropdown.propTypes = { // eslint-disable-next-line react/forbid-prop-types theme: PropTypes.objectOf(PropTypes.any), width: PropTypes.string, height: PropTypes.string, customStyle: PropTypes.shape({ zIndex: PropTypes.string, }), items: PropTypes.arrayOf( PropTypes.shape({ renderValue: PropTypes.func, value: PropTypes.string.isRequired, name: PropTypes.string, id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, isDisabled: PropTypes.bool, tags: PropTypes.arrayOf(PropTypes.string), }) ), selectedItems: PropTypes.arrayOf( PropTypes.shape({ renderValue: PropTypes.func, value: PropTypes.string.isRequired, name: PropTypes.string, }) ), placeholder: PropTypes.string, label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), isDisabled: PropTypes.bool, isRequired: PropTypes.bool, isSearchDisabled: PropTypes.bool, isUniqueSelection: PropTypes.bool, onSelectionChange: PropTypes.func, onTriggerDropdownList: PropTypes.func, iconSrc: PropTypes.string, iconCustomStyle: PropTypes.shape({ width: PropTypes.string, height: PropTypes.string, }), searchPlaceholder: PropTypes.string, button: PropTypes.shape({ text: PropTypes.string.isRequired, handleClick: PropTypes.func.isRequired, iconSrc: PropTypes.string, isDisable: PropTypes.bool, }), sortBy: PropTypes.func, // eslint-disable-next-line react/forbid-prop-types labelStyle: PropTypes.objectOf(PropTypes.any), // eslint-disable-next-line react/forbid-prop-types customListStyle: PropTypes.objectOf(PropTypes.any), tooltipText: PropTypes.string, shouldInterpretTooltipHTML: PropTypes.bool, searchDebounceTimeInMs: PropTypes.number, isErrorState: PropTypes.bool, // eslint-disable-next-line react/forbid-prop-types customDropdownListStyle: PropTypes.objectOf(PropTypes.any), pagination: PropTypes.shape({ handleFetchMoreData: PropTypes.func.isRequired, displayShowMoreResult: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, }), languageCode: PropTypes.string, displayName: PropTypes.bool, largeDropdownList: PropTypes.bool, maxSelection: PropTypes.number, }; Dropdown.defaultProps = { theme: null, items: [], iconSrc: null, width: '240px', height: '40px', customStyle: {}, placeholder: i18next.t('COMPONENT_DROPDOWN_INPUT_PLACEHOLDER'), label: null, selectedItems: [], isSearchDisabled: false, isUniqueSelection: true, iconCustomStyle: { width: '16px', height: '16px' }, isDisabled: false, isRequired: false, isErrorState: false, searchPlaceholder: i18next.t('COMPONENT_DROPDOWN_SEARCH'), button: null, labelStyle: {}, customListStyle: {}, tooltipText: null, shouldInterpretTooltipHTML: false, sortBy: (itemsList: Item[]): Item[] => lodashSortBy(itemsList, (item) => { if (typeof item.value === 'string') { return item.value.toLowerCase(); } return 0; }), onTriggerDropdownList: null, searchDebounceTimeInMs: 300, onSelectionChange: () => true, pagination: null, customDropdownListStyle: {}, languageCode: 'fr', displayName: false, largeDropdownList: false, maxSelection: null, }; export default Dropdown;