import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import Button from '../Button/Button' import Checkbox from '../Form/Checkbox' import DragHandle from '../DragHandle/DragHandle' import EmptyState from '../EmptyState/EmptyState' import { type EmptyStateProps } from '../EmptyState/EmptyState' import { type FormLabelProps } from '../FormLabel/FormLabel' import { hasValue, notEmpty } from '../../services/HelperServiceTyped' import Icon from '../Icons/Icon' import isClient from '../../services/isClient' import Popover from '../Popover/Popover' import { type PopoverProps } from '../Popover/Popover' import SearchBar from '../SearchBar/SearchBar' import { type SearchBarProps } from '../SearchBar/SearchBar' import SectionHeader from '../Form/SectionHeader/SectionHeader' import { useFocus } from '../../hooks/useFocus/useFocus' import FormLabel from '../FormLabel/FormLabel' import styles from './_multi-select.module.scss' import { c } from '../../translations/LibraryTranslationService' import ScrollingContainer from '../ScrollingContainer/ScrollingContainer' import SelectLoading from '../Form/Select/SelectLoading' import DraggableList from '../DragAndDrop/DraggableList' import CheckboxList from '../TreeListBox/CheckboxList' import { CheckboxState, type DateCheckState, debouncedSearch, type GroupScrollConfig, type IgnoreNodeHierarchyProps, type TableColumnConfigProps, toggleExpand, updateItemStates, } from '../Form/NestedCheckboxHelper' type DisabledItemsSection = | { /** Hide Disabled items section */ enabled: false } | { /** Show Disabled items section */ enabled: true /** Title displayed at the left side of the section header */ primaryTitle?: string /** Title displayed at the right side of the section header */ secondaryTitle?: string /** Optionally determine which key in DataItem to use for placing items in the disabled items section. */ disabledItemsKey?: keyof DataItem } type SearchProps = Omit & { /** Optional function that will make a callout with the search `value` passed back */ onChange?: (value: string) => void /** Boolean value to indicate if search bar to be shown */ show?: boolean } export type MultiSelectDataItemBase = { /** Required prop that will be used as a "label". This prop must remain flexible as we do not know what an api response will look like. This will force at least one field to passed into the `DataItem` generic. */ [key: string]: unknown /** Optional text that needs to be shown near the actual text */ secondaryOption?: React.ReactNode /** Optional flag to indicate if the option cannot be checked or unchecked */ disabled?: boolean } type MultiSelectBaseProps = { /** Array of options displayed in the UI */ options: DataItem[] /** Array of user selected options that we want selected when the component mounts. These strings should come from the strings provided in the `options` prop. */ selectedOptions: DataItem[] /** Key that is used to populate the selectable text */ labelKey: keyof DataItem /** Function to set the array of selected options */ callout: (selectedList: DataItem[]) => void /** Optionally add FormLabel props */ formLabelProps?: FormLabelProps /** Search Bar props. Can be used to override dynamically */ searchBarProps?: Omit & { /** The value should be optional */ value?: string } /** Custom text that will display if no items match the search input */ emptyStateProps?: EmptyStateProps /** Optional prop to disabled the use of this component */ disabled?: boolean /** Optionally show the ListLoading component when options are not readily available */ loading?: boolean /** Optionally hide the Select All */ hideSelectAll?: boolean /** Optionally make the selected items reorderable using the DraggableList component. */ isReorderable?: boolean /** Optionally opt-out of alphaetical sorting */ sortAlphabetically?: boolean /** Optionally opt-out for showing disabled items in disabled section below unselected items*/ disabledItemsSection?: DisabledItemsSection /** Optional function to run when the MultiSelect receives focus */ onFocus?: () => void /** Optional prop to add a test id to the MultiSelect for QA testing */ qaTestId?: string } type MultiSelectWithOptionsProps = MultiSelectBaseProps & { fetchData?: never hasMore?: never searchValue?: never isFetching?: never dropdownState?: never } type MultiSelectWithAPIOptionsProps = MultiSelectBaseProps & { /** To trigger API call initially and on pagination */ fetchData: () => void /** Boolean value to indicate if pagination exists */ hasMore: boolean /** Optionally used to indicate API fetch status. */ isFetching?: boolean /** Callback that gives the current open / closed state of the Tippy */ dropdownState?: (status: 'open' | 'close') => void } type MultiSelectOptionsProps = | MultiSelectWithAPIOptionsProps | MultiSelectWithOptionsProps export type ExposedProps = MultiSelectOptionsProps & { /** Optionally make the component "exposed" by not needing a click to view the options. */ exposed: boolean /** Optionally override the maxHeight for the exposed container. */ maxHeight?: number | string appendTo?: never selectPlaceholder?: never /** Option to reset list search on callout */ resetSearch?: boolean nestedConfig?: never /** Optionally remove the border for the exposed MultiSelect. */ removeBorder?: boolean } type StandardProps = MultiSelectOptionsProps & { /** Useful when we want to attach this to another component making use of Tippy */ appendTo?: 'parent' | ((element: Element) => Element) /** Custom placeholder text for the Select Dropdown */ selectPlaceholder: string exposed?: never maxHeight?: never resetSearch?: never nestedConfig?: never removeBorder?: never } type NestedPropsBase = MultiSelectOptionsProps & ( | Omit, 'nestedConfig'> | Omit, 'nestedConfig'> ) type NestedProps = NestedPropsBase & { /** Useful for showing nested hierarchy selection experience */ nestedConfig: { /** Data to be displayed in the nested hierarchy */ data: DataItem[] /** Table configuration that will handle column headers and rendering column children. */ tableConfig: TableColumnConfigProps[] /** Array of objects to keep the track of selected or unselected options */ itemStates: DateCheckState[] /** Function to set the array of selected options */ setItemStates: React.Dispatch> /** Object containing keys to identify the data and its parent child relationship */ identifierKeys: { displayKey: string idKey: string parentKey: string } /** Optional per-group scroll and infinite-scroll pagination config */ groupScrollConfig?: GroupScrollConfig /** Optional predicate to exclude items from rendering in CheckboxList without affecting the empty-state check */ filterItems?: (item: DataItem) => boolean } & IgnoreNodeHierarchyProps } export type MultiSelectProps = | ExposedProps | StandardProps | NestedProps export type MultiSelectHeaders = { id: number label: string isChecked: boolean } type OrderMapType = { [key: string]: number } const MultiSelect = ({ appendTo, dropdownState, labelKey, selectedOptions, options, callout, isFetching, isReorderable, fetchData, selectPlaceholder, searchBarProps = { value: '', placeholder: c('search'), }, emptyStateProps = { primaryText: c('noOptionsAvailable') }, exposed, maxHeight, resetSearch = false, disabled, loading, hideSelectAll, hasMore, formLabelProps, sortAlphabetically = true, disabledItemsSection = { enabled: false, }, nestedConfig, removeBorder, onFocus, qaTestId = 'multi-select', }: MultiSelectProps): React.JSX.Element => { const [expandedItems, setExpandedItems] = useState<{ [key: string | number]: boolean }>({}) const [filteredItems, setFilteredItems] = useState( nestedConfig?.data ?? [], ) const { displayKey = '', idKey = '', parentKey = '', } = nestedConfig?.identifierKeys ?? {} // Used for lookup purpose as it has better performance than native Array.findIndex() const optionsInitialOrder = useMemo(() => { const dataOptions = options.map((item) => item[labelKey] as string) const optionsOrderMap: OrderMapType = {} for (let index = 0; index < dataOptions.length; index++) { optionsOrderMap[dataOptions[index]] = index } return optionsOrderMap }, [labelKey, options]) // We will never sort the selected list if the component is reorderable const shouldSortAlphabetically = !isReorderable && sortAlphabetically const [disabledItemsKey, setDisabledItemsKey] = useState('disabled') useEffect(() => { if ( disabledItemsSection.enabled && disabledItemsSection?.disabledItemsKey && disabledItemsSection.disabledItemsKey !== disabledItemsKey ) { setDisabledItemsKey(disabledItemsSection.disabledItemsKey) } }, [disabledItemsSection, setDisabledItemsKey, disabledItemsKey]) const { value: searchValue, show: showSearch, placeholder: searchPlaceholder, onChange: onSearchChange, autoFocus: autoFocusSearch, } = searchBarProps const [search, setSearch] = useState(searchValue ?? '') const selectBoxRef = useRef(null) useEffect(() => { searchValue && setSearch(searchValue) resetSearch && setSearch('') }, [resetSearch, searchValue]) const onToggleExpand = useCallback( (idOrIds: string | number | (string | number)[]) => { toggleExpand(idOrIds, setExpandedItems) }, [], ) useEffect(() => { if (nestedConfig) { // Call the debouncedSearch function when the search text changes debouncedSearch({ query: search, identifierKeys: nestedConfig.identifierKeys ?? { displayKey: '', idKey: '', parentKey: '', }, setFilteredItems: setFilteredItems, nestedConfigData: nestedConfig.data ?? [], toggleExpand: onToggleExpand, }) } }, [ search, nestedConfig, nestedConfig?.data, nestedConfig?.identifierKeys, onToggleExpand, ]) const [selectedList, setSelectedList] = useState(() => { if (shouldSortAlphabetically) { // Sort selectedOptions alphabetically when initializing selectedList return selectedOptions.sort((a, b) => { const labelA = a[labelKey] as string const labelB = b[labelKey] as string return labelA.localeCompare(labelB) }) } else { return selectedOptions ?? [] } }) // Update selectedList when selectedOptions changes (with stable comparison) useEffect(() => { if (shouldSortAlphabetically) { // Sort selectedOptions alphabetically when updating selectedList const sortedOptions = [...selectedOptions].sort((a, b) => { const labelA = a[labelKey] as string const labelB = b[labelKey] as string return labelA.localeCompare(labelB) }) setSelectedList(sortedOptions) } else { setSelectedList([...selectedOptions]) } }, [selectedOptions, labelKey, shouldSortAlphabetically]) // Function to filter selected items based on search const filteredSelectedList = useMemo(() => { return selectedList.filter((item) => { const labelValue = item[labelKey] const searchResult = typeof labelValue === 'string' && labelValue ? labelValue.toLowerCase().includes(search.toLowerCase()) : false return fetchData ? // Sometimes there are fetched options that don't exactly match or include the search text but have still been selected searchResult || options?.find((option) => option[labelKey] === item[labelKey]) : searchResult }) }, [selectedList, search, labelKey, options, fetchData]) const getUnselectedList = useCallback( ( selectedTexts: DataItem[keyof DataItem][], totalItems: DataItem[], searchText: string, ) => { return totalItems.filter((item) => { const labelValue = item[labelKey] return ( (disabledItemsSection?.enabled ? !item[disabledItemsKey] : true) && !selectedTexts.includes(item[labelKey]) && (fetchData ? true : typeof labelValue === 'string' && labelValue ? labelValue.toLowerCase().includes(searchText.toLowerCase()) : false) ) }) }, [fetchData, labelKey, disabledItemsSection?.enabled, disabledItemsKey], ) const getDisabledList = useCallback( (totalItems: DataItem[], searchText: string) => { return totalItems.filter((item) => { const labelValue = item[labelKey] return ( item[disabledItemsKey] && (fetchData ? true : typeof labelValue === 'string' && labelValue ? labelValue.toLowerCase().includes(searchText.toLowerCase()) : false) ) }) }, [fetchData, labelKey, disabledItemsKey], ) const [unselectedList, setUnselectedList] = useState(() => getUnselectedList( filteredSelectedList.map((item) => item[labelKey]), options, search, ), ) const [disabledItemsList, setDisabledItemsList] = useState(() => getDisabledList(options, search), ) useEffect(() => { setUnselectedList( getUnselectedList( filteredSelectedList.map((item) => item[labelKey]), options, search, ), ) setDisabledItemsList(getDisabledList(options, search)) }, [ search, options, getUnselectedList, getDisabledList, filteredSelectedList, labelKey, ]) const selectedTexts = useMemo( () => selectedList.map((item) => item[labelKey]), [labelKey, selectedList], ) const searchCallout = (searchText: string) => { setSearch(searchText) onSearchChange?.(searchText) // Update unselectedList when search text changes setUnselectedList(getUnselectedList(selectedTexts, options, searchText)) } const selectedAndDisabled = useMemo( () => selectedList.filter((item) => item.disabled), [selectedList], ) const [actionVisible, setActionVisible] = useState(false) const openClass = actionVisible ? styles.open : '' const showActionContent = useCallback(() => { setTimeout(() => setActionVisible(true), 250) // To track the mounting of PopoverContent after the animation so that the searchBarRef is set correctly dropdownState?.('open') }, [dropdownState]) const hideActionContent = useCallback(() => { setActionVisible(false) dropdownState?.('close') if (selectedOptions) { callout?.(selectedList) } setSearch('') }, [callout, dropdownState, selectedList, selectedOptions]) const onClearClick = () => { setSelectedList([]) callout?.(selectedAndDisabled) } const getClickText = () => selectedTexts?.length ? ( `(${selectedTexts.length}) ${selectedTexts.map((el) => el).join(', ')}` ) : ( {selectPlaceholder} ) const rowCallout = (text: string, shouldCheck: boolean) => { // Immediately update unselectedList to remove the item if (shouldCheck) { setUnselectedList((prev) => prev.filter((item) => item[labelKey] !== text), ) } let selected = [...selectedList] // Start with the current selectedList if (shouldCheck) { const itemToAdd = options.find((item) => item[labelKey] === text) if (itemToAdd) { if (isReorderable) { // If we support DraggableList, we just append the selected item to the end of the list selected.push(itemToAdd) } else { // If not reorderable, maintain the order const indexToInsert = optionsInitialOrder[text] selected.splice(indexToInsert, 0, itemToAdd) // Insert at the correct index } } } else { // Immediately update unselectedList to add the item back const itemToUnselect = selectedList.find( (item) => item[labelKey] === text, ) if (itemToUnselect) { setUnselectedList((prev) => [...prev, itemToUnselect]) } selected = selectedList.filter((item) => item[labelKey] !== text) } if (shouldSortAlphabetically) { // Sort the selected list alphabetically selected.sort((a, b) => { const labelA = a[labelKey] as string const labelB = b[labelKey] as string return labelA.localeCompare(labelB) }) } setSelectedList(selected) // Update the selectedList state callout?.(selected) // Call the provided callback with the updated list } const enabledUnselectedRecords = useMemo( () => unselectedList.filter((item) => !item.disabled), [unselectedList], ) const isSelectAllChecked = enabledUnselectedRecords.length === 0 const checkAll = useCallback(() => { if (isSelectAllChecked) { // When options are fetched from the API in the consumer component setSelectedList([]) callout?.(selectedAndDisabled) if (nestedConfig) { const unCheckedAllState = nestedConfig.itemStates.map((item) => { item.state = CheckboxState.UNCHECKED return item }) if (unCheckedAllState) { nestedConfig.setItemStates(unCheckedAllState) } } } else { // When options are fetched from the API in the consumer component const selected = [...selectedList, ...enabledUnselectedRecords] setSelectedList(selected) callout?.(selected) if (nestedConfig) { const checkedAllState = nestedConfig.itemStates.map((item) => { item.state = CheckboxState.CHECKED return item }) if (checkedAllState) { nestedConfig.setItemStates(checkedAllState) } } } }, [ callout, enabledUnselectedRecords, isSelectAllChecked, selectedAndDisabled, selectedList, nestedConfig, ]) // infinite scroll & pagination const [hasMoreNode, setHasMoreNode] = useState(null) useEffect(() => { if (!loading && !isFetching && hasMoreNode && hasMore) { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.intersectionRatio > 0) { fetchData() } }) }, { threshold: 0.3, }, ) observer.observe(hasMoreNode) return () => { observer.disconnect() } } }, [hasMore, fetchData, isFetching, loading, hasMoreNode]) const searchBarRef = useFocus(!!actionVisible) const MultiSelectContent = () => { const height = maxHeight ? maxHeight : 300 const hasSearch = hasValue(showSearch) ? showSearch : options?.length > 10 const noBorder = !exposed || !!removeBorder const showGradient = hasSearch const showHeaders = filteredSelectedList.length > 0 const getHeader = (type: 'selected' | 'unselected' | 'disabled') => { let count: number let headerTitle = c(type) switch (type) { case 'selected': count = selectedList.length break case 'unselected': count = unselectedList.length break case 'disabled': count = disabledItemsList.length headerTitle = disabledItemsSection?.enabled ? (disabledItemsSection?.primaryTitle ?? c('notAvailable')) : '' break } return (
{c('clear')} ) : type === 'disabled' && disabledItemsSection?.enabled ? ( {disabledItemsSection?.secondaryTitle} ) : null } {...(type === 'selected' && notEmpty(search) ? { tooltip: { tooltipContent: c('tooltipMultiSelectSearch'), }, } : {})} />
) } const findCategoryById = ( categories: DataItem[], targetId: number, ): DataItem | null => { for (const category of categories) { if (category.id === targetId) { return category // Found the category } if ((category.children as DataItem[]).length > 0) { const result = findCategoryById( category.children as DataItem[], targetId, ) if (result) { return result // Found in child categories } } } return null // Not found } const findExtremeParentIds = ( categories: DataItem[], ids: number[], ): number[] => { const allParents = new Set() // Track potential parent IDs const allChildren = new Set() // Track child IDs within the list const traverse = (categories: DataItem[]): void => { for (const category of categories) { if (ids.includes(category.id as number)) { allParents.add(category.id as number) // Add current category ID as a potential parent // Add its children's IDs to the children set if they are in the ids array if (category.children && Array.isArray(category.children)) { category.children.forEach((child) => { if (ids.includes(child.id as number)) { allChildren.add(child.id as number) } }) } } if ( category.children && Array.isArray(category.children) && category.children.length > 0 ) { traverse(category.children) // Recursively process children } } } // Start traversing the hierarchy traverse(categories as DataItem[]) // The extreme parents are those in allParents but not in allChildren return Array.from(allParents).filter((id) => !allChildren.has(id)) } const clickHandler = (id: string | number) => { const newState = updateItemStates( nestedConfig?.ignoreNodeHierarchy ?? false, nestedConfig?.itemStates ?? [], nestedConfig?.data ?? [], id, idKey, parentKey, ) const filteredCheckedItemIds = newState .filter((item) => item.state === CheckboxState.CHECKED) .map((item) => parseInt(String(item.id))) const extremeParentIds = findExtremeParentIds( options, filteredCheckedItemIds, ) const selectedOptions = extremeParentIds .map((id) => findCategoryById(options, id)) .filter((option) => option !== null) as DataItem[] setSelectedList(selectedOptions) nestedConfig?.setItemStates(newState) callout?.(selectedOptions) } const getStateForId = (id: string | number) => { return ( nestedConfig?.itemStates?.find((i) => i.id === id)?.state || CheckboxState.UNCHECKED ) } const renderListItem = (listItem: DataItem, isChecked: boolean) => { const selectText = listItem[labelKey] as string const isSecondaryValuePresent = listItem.secondaryOption const RowNode = ( { !listItem.disabled && rowCallout((itemText ?? '')?.toString(), shouldCheck) }} checked={isChecked} customClass={styles.listItem} label={selectText} stateName={selectText} disabled={listItem.disabled} /> ) return (
{RowNode}
{isSecondaryValuePresent ? ( ) : ( <> )} {isReorderable && isChecked && filteredSelectedList.length > 1 ? ( ) : null}
) } // Calculate popover width: use select box width when no search, otherwise use the larger of select box or min search width (216px = 200px minWidth + 16px padding) const popoverWidth = selectBoxRef.current?.offsetWidth ? hasSearch ? Math.max(selectBoxRef.current.offsetWidth, 216) : selectBoxRef.current.offsetWidth : undefined return (
{/* Search Bar */} {hasSearch ? (
) : null} {/* List Items (With selected and unselected Group Headers) */}
{loading ? ( ) : nestedConfig ? ( search && filteredItems?.length === 0 ? ( ) : ( ) ) : ( <> {/* Selected Group Header */} {showHeaders ? getHeader('selected') : null} {/* Selected List Items */} {filteredSelectedList.length ? ( { setSelectedList(newList) // Update selectedList with the new order callout?.(newList) // Call the provided callback with the updated list }} listItems={filteredSelectedList} wrapperClass={styles.selectedRowsWrapper} > {filteredSelectedList.map((listItem, index) => { return ( {renderListItem(listItem, true)} ) })} ) : null} {/* Unselected Group Header */} {showHeaders ? getHeader('unselected') : null} {/* Unselected List Items */} {options.length === 0 || (search && unselectedList.length === 0) ? ( ) : ( unselectedList.map((listItem, index) => ( {renderListItem(listItem, false)} )) )} {disabledItemsList.length > 0 && disabledItemsSection?.enabled ? getHeader('disabled') : null} {disabledItemsSection?.enabled && disabledItemsList.length > 0 && disabledItemsList.map((listItem, index) => ( {renderListItem({ ...listItem, disabled: true }, false)} ))} {/* Infinite Scroll Loader */} {hasMore ? (
) : null} )}
{/* Select All */} {!hideSelectAll ? (
) : null}
) } return (
{formLabelProps ? ( ) : null} {exposed ? ( MultiSelectContent() ) : ( {({ visible, setVisible }) => (
{ setVisible(false) hideActionContent() } : () => { setVisible(true) showActionContent() } : undefined } className={`${styles.popoverToggle} ${ disabled ? styles.disabled : '' }`} >
{getClickText()}
)}
)}
) } export default MultiSelect