From 0631c86cbaf4dcfe35348707dea71eb0e2a99bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Sat, 22 Nov 2025 19:40:14 +0100 Subject: [PATCH] pl-fe: start adopting multiselect-react-dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../pl-fe/src/components/ui/multiselect.tsx | 688 ++++++++++++++++++ packages/pl-fe/src/features/forms/index.tsx | 4 +- 2 files changed, 690 insertions(+), 2 deletions(-) create mode 100644 packages/pl-fe/src/components/ui/multiselect.tsx diff --git a/packages/pl-fe/src/components/ui/multiselect.tsx b/packages/pl-fe/src/components/ui/multiselect.tsx new file mode 100644 index 000000000..76481d7f6 --- /dev/null +++ b/packages/pl-fe/src/components/ui/multiselect.tsx @@ -0,0 +1,688 @@ +/** +The MIT License (MIT) + +Copyright (c) 2019 Srigar Sukumar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + */ +// Adapted from [multiselect-react-dropdown](https://github.com/srigar/multiselect-react-dropdown) + +/* eslint-disable jsdoc/require-jsdoc */ +// @ts-nocheck +import React, { useRef, useEffect } from 'react'; + +// import './styles.css'; + +import Icon from './icon'; + +function useOutsideAlerter(ref, clickEvent) { + useEffect(() => { + function handleClickOutside(event) { + if (ref.current && !ref.current.contains(event.target)) { + clickEvent(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref]); +} + +/** +* Component that alerts if you click outside of it +*/ +const OutsideAlerter = (props) => { + const wrapperRef = useRef(null); + useOutsideAlerter(wrapperRef, props.outsideClick); + return
{props.children}
; +}; + +interface IMultiselectProps { + options: any; + disablePreSelectedValues?: boolean; + selectedValues?: any; + isObject?: boolean; + displayValue?: string; + showCheckbox?: boolean; + selectionLimit?: any; + placeholder?: string; + groupBy?: string; + loading?: boolean; + style?: object; + emptyRecordMsg?: string; + onSelect?: (selectedList:any, selectedItem: any) => void; + onRemove?: (selectedList:any, selectedItem: any) => void; + onSearch?: (value:string) => void; + onKeyPressFn?: (event:any, value:string) => void; + closeIcon?: string; + singleSelect?: boolean; + caseSensitiveSearch?: boolean; + id?: string; + closeOnSelect?: boolean; + avoidHighlightFirstOption?: boolean; + hidePlaceholder?: boolean; + showArrow?: boolean; + keepSearchTerm?: boolean; + customCloseIcon?: React.ReactNode | string; + customArrow?: any; + disable?: boolean; + className?: string; + selectedValueDecorator?: (v:string, option: any) => React.ReactNode | string; + optionValueDecorator?: (v:string, option: any) => React.ReactNode | string; + hideSelectedList?: boolean; +} + +export class Multiselect extends React.Component { + + static defaultProps: IMultiselectProps; + + constructor(props) { + super(props); + this.state = { + inputValue: '', + options: props.options, + filteredOptions: props.options, + unfilteredOptions: props.options, + selectedValues: Object.assign([], props.selectedValues), + preSelectedValues: Object.assign([], props.selectedValues), + toggleOptionsList: false, + highlightOption: props.avoidHighlightFirstOption ? -1 : 0, + showCheckbox: props.showCheckbox, + keepSearchTerm: props.keepSearchTerm, + groupedObject: [], + }; + // @ts-ignore + this.optionTimeout = null; + // @ts-ignore + this.searchWrapper = React.createRef(); + // @ts-ignore + this.searchBox = React.createRef(); + this.onChange = this.onChange.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + this.renderMultiselectContainer = this.renderMultiselectContainer.bind(this); + this.renderSelectedList = this.renderSelectedList.bind(this); + this.onRemoveSelectedItem = this.onRemoveSelectedItem.bind(this); + this.toggelOptionList = this.toggelOptionList.bind(this); + this.onArrowKeyNavigation = this.onArrowKeyNavigation.bind(this); + this.onSelectItem = this.onSelectItem.bind(this); + this.filterOptionsByInput = this.filterOptionsByInput.bind(this); + this.removeSelectedValuesFromOptions = this.removeSelectedValuesFromOptions.bind(this); + this.isSelectedValue = this.isSelectedValue.bind(this); + this.fadeOutSelection = this.fadeOutSelection.bind(this); + this.isDisablePreSelectedValues = this.isDisablePreSelectedValues.bind(this); + this.renderGroupByOptions = this.renderGroupByOptions.bind(this); + this.renderNormalOption = this.renderNormalOption.bind(this); + this.listenerCallback = this.listenerCallback.bind(this); + this.resetSelectedValues = this.resetSelectedValues.bind(this); + this.getSelectedItems = this.getSelectedItems.bind(this); + this.getSelectedItemsCount = this.getSelectedItemsCount.bind(this); + this.hideOnClickOutside = this.hideOnClickOutside.bind(this); + this.onCloseOptionList = this.onCloseOptionList.bind(this); + this.isVisible = this.isVisible.bind(this); + } + + initialSetValue() { + const { showCheckbox, groupBy, singleSelect } = this.props; + const { options } = this.state; + if (!showCheckbox && !singleSelect) { + this.removeSelectedValuesFromOptions(false); + } + // if (singleSelect) { + // this.hideOnClickOutside(); + // } + if (groupBy) { + this.groupByOptions(options); + } + } + + resetSelectedValues() { + const { unfilteredOptions } = this.state; + return new Promise((resolve) => { + this.setState({ + selectedValues: [], + preSelectedValues: [], + options: unfilteredOptions, + filteredOptions: unfilteredOptions, + }, () => { + // @ts-ignore + resolve(); + this.initialSetValue(); + }); + }); + } + + getSelectedItems() { + return this.state.selectedValues; + } + + getSelectedItemsCount() { + return this.state.selectedValues.length; + } + + componentDidMount() { + this.initialSetValue(); + // @ts-ignore + this.searchWrapper.current.addEventListener('click', this.listenerCallback); + } + + componentDidUpdate(prevProps) { + const { options, selectedValues } = this.props; + const { options: prevOptions, selectedValues: prevSelectedvalues } = prevProps; + if (JSON.stringify(prevOptions) !== JSON.stringify(options)) { + this.setState({ options, filteredOptions: options, unfilteredOptions: options }, this.initialSetValue); + } + if (JSON.stringify(prevSelectedvalues) !== JSON.stringify(selectedValues)) { + this.setState({ selectedValues: Object.assign([], selectedValues), preSelectedValues: Object.assign([], selectedValues) }, this.initialSetValue); + } + } + + listenerCallback() { + // @ts-ignore + this.searchBox.current.focus(); + } + + componentWillUnmount() { + // @ts-ignore + if (this.optionTimeout) { + // @ts-ignore + clearTimeout(this.optionTimeout); + } + // @ts-ignore + this.searchWrapper.current.removeEventListener('click', this.listenerCallback); + } + + // Skipcheck flag - value will be true when the func called from on deselect anything. + removeSelectedValuesFromOptions(skipCheck) { + const { isObject, displayValue, groupBy } = this.props; + const { selectedValues = [], unfilteredOptions, options } = this.state; + if (!skipCheck && groupBy) { + this.groupByOptions(options); + } + if (!selectedValues.length && !skipCheck) { + return; + } + if (isObject) { + const optionList = unfilteredOptions.filter(item => { + return selectedValues.findIndex( + v => v[displayValue] === item[displayValue], + ) === -1 + ? true + : false; + }); + if (groupBy) { + this.groupByOptions(optionList); + } + this.setState( + { options: optionList, filteredOptions: optionList }, + this.filterOptionsByInput, + ); + return; + } + const optionList = unfilteredOptions.filter( + item => selectedValues.indexOf(item) === -1, + ); + + this.setState( + { options: optionList, filteredOptions: optionList }, + this.filterOptionsByInput, + ); + } + + groupByOptions(options) { + const { groupBy } = this.props; + const groupedObject = options.reduce(function(r, a) { + const key = a[groupBy] || 'Others'; + r[key] = r[key] || []; + r[key].push(a); + return r; + }, Object.create({})); + + this.setState({ groupedObject }); + } + + onChange(event) { + const { onSearch } = this.props; + this.setState( + { inputValue: event.target.value }, + this.filterOptionsByInput, + ); + if (onSearch) { + onSearch(event.target.value); + } + } + + onKeyPress(event) { + const { onKeyPressFn } = this.props; + if (onKeyPressFn) { + onKeyPressFn(event, event.target.value); + } + } + + filterOptionsByInput() { + let { options } = this.state; + const { filteredOptions, inputValue } = this.state; + const { isObject, displayValue } = this.props; + if (isObject) { + options = filteredOptions.filter(i => this.matchValues(i[displayValue], inputValue)); + } else { + options = filteredOptions.filter(i => this.matchValues(i, inputValue)); + } + this.groupByOptions(options); + this.setState({ options }); + } + + matchValues(value, search) { + if (this.props.caseSensitiveSearch) { + return value.indexOf(search) > -1; + } + if (value.toLowerCase) { + return value.toLowerCase().indexOf(search.toLowerCase()) > -1; + } + return value.toString().indexOf(search) > -1; + } + + onArrowKeyNavigation(e) { + const { + options, + highlightOption, + toggleOptionsList, + inputValue, + selectedValues, + } = this.state; + const { disablePreSelectedValues } = this.props; + if (e.keyCode === 8 && !inputValue && !disablePreSelectedValues && selectedValues.length) { + this.onRemoveSelectedItem(selectedValues.length - 1); + } + if (!options.length) { + return; + } + if (e.keyCode === 38) { + if (highlightOption > 0) { + this.setState(previousState => ({ + highlightOption: previousState.highlightOption - 1, + })); + } else { + this.setState({ highlightOption: options.length - 1 }); + } + } else if (e.keyCode === 40) { + if (highlightOption < options.length - 1) { + this.setState(previousState => ({ + highlightOption: previousState.highlightOption + 1, + })); + } else { + this.setState({ highlightOption: 0 }); + } + } else if (e.key === 'Enter' && options.length && toggleOptionsList) { + if (highlightOption === -1) { + return; + } + this.onSelectItem(options[highlightOption]); + } + // TODO: Instead of scrollIntoView need to find better soln for scroll the dropwdown container. + // setTimeout(() => { + // const element = document.querySelector("ul.optionContainer .highlight"); + // if (element) { + // element.scrollIntoView(); + // } + // }); + } + + onRemoveSelectedItem(item) { + const { selectedValues } = this.state; + let { index = 0 } = this.state; + const { onRemove, showCheckbox, displayValue, isObject } = this.props; + if (isObject) { + index = selectedValues.findIndex( + i => i[displayValue] === item[displayValue], + ); + } else { + index = selectedValues.indexOf(item); + } + selectedValues.splice(index, 1); + onRemove(selectedValues, item); + this.setState({ selectedValues }, () => { + if (!showCheckbox) { + this.removeSelectedValuesFromOptions(true); + } + }); + if (!this.props.closeOnSelect) { + // @ts-ignore + this.searchBox.current.focus(); + } + } + + onSelectItem(item) { + const { selectedValues } = this.state; + const { selectionLimit, onSelect, singleSelect, showCheckbox } = this.props; + if (!this.state.keepSearchTerm){ + this.setState({ + inputValue: '', + }); + } + if (singleSelect) { + this.onSingleSelect(item); + onSelect([item], item); + return; + } + if (this.isSelectedValue(item)) { + this.onRemoveSelectedItem(item); + return; + } + if (selectionLimit === selectedValues.length) { + return; + } + selectedValues.push(item); + onSelect(selectedValues, item); + this.setState({ selectedValues }, () => { + if (!showCheckbox) { + this.removeSelectedValuesFromOptions(true); + } else { + this.filterOptionsByInput(); + } + }); + if (!this.props.closeOnSelect) { + // @ts-ignore + this.searchBox.current.focus(); + } + } + + onSingleSelect(item) { + this.setState({ selectedValues: [item], toggleOptionsList: false }); + } + + isSelectedValue(item) { + const { isObject, displayValue } = this.props; + const { selectedValues } = this.state; + if (isObject) { + return ( + selectedValues.filter(i => i[displayValue] === item[displayValue]) + .length > 0 + ); + } + return selectedValues.filter(i => i === item).length > 0; + } + + renderOptionList() { + const { groupBy, style, emptyRecordMsg, loading, loadingMessage = 'loading...' } = this.props; + const { options } = this.state; + if (loading) { + return ( +
    + {typeof loadingMessage === 'string' && {loadingMessage}} + {typeof loadingMessage !== 'string' && loadingMessage} +
+ ); + } + return ( +
    + {options.length === 0 && {emptyRecordMsg}} + {!groupBy ? this.renderNormalOption() : this.renderGroupByOptions()} +
+ ); + } + + renderGroupByOptions() { + const { isObject = false, displayValue, showCheckbox, style, singleSelect } = this.props; + const { groupedObject } = this.state; + return Object.keys(groupedObject).map(obj => { + return ( + +
  • {obj}
  • + {groupedObject[obj].map((option, i) => { + const isSelected = this.isSelectedValue(option); + return ( +
  • this.onSelectItem(option)} + > + {showCheckbox && !singleSelect && ( + + )} + {this.props.optionValueDecorator(isObject ? option[displayValue] : (option || '').toString(), option)} +
  • + ); + }, + )} +
    + ); + }); + } + + renderNormalOption() { + const { isObject = false, displayValue, showCheckbox, style, singleSelect } = this.props; + const { highlightOption } = this.state; + return this.state.options.map((option, i) => { + const isSelected = this.isSelectedValue(option); + return ( +
  • this.onSelectItem(option)} + > + {showCheckbox && !singleSelect && ( + + )} + {this.props.optionValueDecorator(isObject ? option[displayValue] : (option || '').toString(), option)} +
  • + ); + }); + } + + renderSelectedList() { + const { isObject = false, displayValue, style, singleSelect } = this.props; + const { selectedValues } = this.state; + return selectedValues.map((value, index) => ( + + {this.props.selectedValueDecorator(!isObject ? (value || '').toString() : value[displayValue], value)} + {!this.isDisablePreSelectedValues(value) && ( this.onRemoveSelectedItem(value)}> + + )} + + )); + } + + isDisablePreSelectedValues(value) { + const { isObject, disablePreSelectedValues, displayValue } = this.props; + const { preSelectedValues } = this.state; + if (!disablePreSelectedValues || !preSelectedValues.length) { + return false; + } + if (isObject) { + return ( + preSelectedValues.filter(i => i[displayValue] === value[displayValue]) + .length > 0 + ); + } + return preSelectedValues.filter(i => i === value).length > 0; + } + + fadeOutSelection(item) { + const { selectionLimit, showCheckbox, singleSelect } = this.props; + if (singleSelect) { + return; + } + const { selectedValues } = this.state; + if (selectionLimit === -1) { + return false; + } + if (selectionLimit !== selectedValues.length) { + return false; + } + if (selectionLimit === selectedValues.length) { + if (!showCheckbox) { + return true; + } else { + if (this.isSelectedValue(item)) { + return false; + } + return true; + } + } + } + + toggelOptionList() { + this.setState({ + toggleOptionsList: !this.state.toggleOptionsList, + highlightOption: this.props.avoidHighlightFirstOption ? -1 : 0, + }); + } + + onCloseOptionList() { + this.setState({ + toggleOptionsList: false, + highlightOption: this.props.avoidHighlightFirstOption ? -1 : 0, + inputValue: '', + }); + } + + onFocus(){ + if (this.state.toggleOptionsList) { + // @ts-ignore + clearTimeout(this.optionTimeout); + } else { + this.toggelOptionList(); + } + } + + onBlur(){ + this.setState({ inputValue: '' }, this.filterOptionsByInput); + // @ts-ignore + this.optionTimeout = setTimeout(this.onCloseOptionList, 250); + } + + isVisible(elem) { + return !!elem && !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length); + } + + hideOnClickOutside() { + const element = document.getElementsByClassName('multiselect-container')[0]; + const outsideClickListener = event => { + if (element && !element.contains(event.target) && this.isVisible(element)) { + this.toggelOptionList(); + } + }; + document.addEventListener('click', outsideClickListener); + } + + renderMultiselectContainer() { + const { inputValue, toggleOptionsList, selectedValues } = this.state; + const { placeholder, style, singleSelect, id, name, hidePlaceholder, disable, showArrow, className, customArrow, hideSelectedList } = this.props; + return ( +
    +
    {}} + > + {!hideSelectedList && this.renderSelectedList()} + + {(singleSelect || showArrow) && ( + <> + {customArrow ? {customArrow} : } + + )} +
    +
    { + e.preventDefault(); + }} + > + {this.renderOptionList()} +
    +
    + ); + } + + render() { + return ( + + {this.renderMultiselectContainer()} + + ); + } + +} + +Multiselect.defaultProps = { + options: [], + disablePreSelectedValues: false, + selectedValues: [], + isObject: true, + displayValue: 'model', + showCheckbox: false, + selectionLimit: -1, + placeholder: 'Select', + groupBy: '', + style: {}, + emptyRecordMsg: 'No Options Available', + onSelect: () => {}, + onRemove: () => {}, + onKeyPressFn: () => {}, + closeIcon: 'circle2', + singleSelect: false, + caseSensitiveSearch: false, + id: '', + name: '', + closeOnSelect: true, + avoidHighlightFirstOption: false, + hidePlaceholder: false, + showArrow: false, + keepSearchTerm: false, + customCloseIcon: '', + className: '', + customArrow: undefined, + selectedValueDecorator: v => v, + optionValueDecorator: v => v, +} as IMultiselectProps; diff --git a/packages/pl-fe/src/features/forms/index.tsx b/packages/pl-fe/src/features/forms/index.tsx index 56c24d4c7..0ff459d7b 100644 --- a/packages/pl-fe/src/features/forms/index.tsx +++ b/packages/pl-fe/src/features/forms/index.tsx @@ -1,9 +1,9 @@ import clsx from 'clsx'; -import MultiselectReactDropdown from 'multiselect-react-dropdown'; import React, { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import Icon from 'pl-fe/components/ui/icon'; +import { Multiselect } from 'pl-fe/components/ui/multiselect'; import Select from 'pl-fe/components/ui/select'; const messages = defineMessages({ @@ -146,7 +146,7 @@ const Mutliselect: React.FC = (props) => { }; const selectElem = ( -