| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
- import PropTypes from 'prop-types'
- import { useTranslation } from 'react-i18next'
- import { matchSorter } from 'match-sorter'
- import { useCombobox } from 'downshift'
- import classnames from 'classnames'
- import MaterialIcon from '@/shared/components/material-icon'
- import Tag from '@/features/ui/components/bootstrap-5/tag'
- import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
- import { Spinner } from 'react-bootstrap-5'
- // Unicode characters in these Unicode groups:
- // "General Punctuation — Spaces"
- // "General Punctuation — Format character" (including zero-width spaces)
- const matchAllSpaces =
- /[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g
- export default function SelectCollaborators({
- loading,
- options,
- placeholder,
- multipleSelectionProps,
- }) {
- const { t } = useTranslation()
- const {
- getSelectedItemProps,
- getDropdownProps,
- addSelectedItem,
- removeSelectedItem,
- selectedItems,
- } = multipleSelectionProps
- const [inputValue, setInputValue] = useState('')
- const selectedEmails = useMemo(
- () => selectedItems.map(item => item.email),
- [selectedItems]
- )
- const unselectedOptions = useMemo(
- () => options.filter(option => !selectedEmails.includes(option.email)),
- [options, selectedEmails]
- )
- const filteredOptions = useMemo(() => {
- if (inputValue === '') {
- return unselectedOptions
- }
- return matchSorter(unselectedOptions, inputValue, {
- keys: ['name', 'email'],
- threshold: matchSorter.rankings.CONTAINS,
- baseSort: (a, b) => {
- // Prefer server-side sorting for ties in the match ranking.
- return a.index - b.index > 0 ? 1 : -1
- },
- })
- }, [unselectedOptions, inputValue])
- const inputRef = useRef(null)
- const focusInput = useCallback(() => {
- if (inputRef.current) {
- window.setTimeout(() => {
- inputRef.current.focus()
- }, 10)
- }
- }, [inputRef])
- const isValidInput = useMemo(() => {
- if (inputValue.includes('@')) {
- for (const selectedItem of selectedItems) {
- if (selectedItem.email === inputValue) {
- return false
- }
- }
- }
- return true
- }, [inputValue, selectedItems])
- function stateReducer(state, actionAndChanges) {
- const { type, changes } = actionAndChanges
- // force selected item to be null so that adding, removing, then re-adding the same collaborator is recognised as a selection change
- if (type === useCombobox.stateChangeTypes.InputChange) {
- return { ...changes, selectedItem: null }
- }
- return changes
- }
- const {
- isOpen,
- getLabelProps,
- getMenuProps,
- getInputProps,
- highlightedIndex,
- getItemProps,
- reset,
- } = useCombobox({
- inputValue,
- defaultHighlightedIndex: 0,
- items: filteredOptions,
- itemToString: item => item && item.name,
- stateReducer,
- onStateChange: ({ inputValue, type, selectedItem }) => {
- switch (type) {
- // add a selected item on Enter (keypress), click or blur
- case useCombobox.stateChangeTypes.InputKeyDownEnter:
- case useCombobox.stateChangeTypes.ItemClick:
- case useCombobox.stateChangeTypes.InputBlur:
- if (selectedItem) {
- setInputValue('')
- addSelectedItem(selectedItem)
- }
- break
- }
- },
- })
- const addNewItem = useCallback(
- (_email, focus = true) => {
- const email = _email.replace(matchAllSpaces, '')
- if (
- isValidInput &&
- email.includes('@') &&
- !selectedEmails.includes(email)
- ) {
- addSelectedItem({
- email,
- display: email,
- type: 'user',
- })
- setInputValue('')
- reset()
- if (focus) {
- focusInput()
- }
- return true
- }
- },
- [addSelectedItem, selectedEmails, isValidInput, focusInput, reset]
- )
- // close and reset the menu when there are no matching items
- useEffect(() => {
- if (isOpen && filteredOptions.length === 0) {
- reset()
- }
- }, [reset, isOpen, filteredOptions.length])
- return (
- <div className="tags-input tags-new">
- {/* eslint-disable-next-line jsx-a11y/label-has-for */}
- <label className="small" {...getLabelProps()}>
- <strong>
- {t('add_people')}
-
- </strong>
- {loading && (
- <Spinner
- animation="border"
- aria-hidden="true"
- size="sm"
- role="status"
- />
- )}
- </label>
- <div className="host">
- {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
- <div className="tags form-control" onClick={focusInput}>
- {selectedItems.map((selectedItem, index) => (
- <SelectedItem
- key={`selected-item-${index}`}
- removeSelectedItem={removeSelectedItem}
- selectedItem={selectedItem}
- focusInput={focusInput}
- index={index}
- getSelectedItemProps={getSelectedItemProps}
- />
- ))}
- <input
- data-testid="collaborator-email-input"
- {...getInputProps(
- getDropdownProps({
- className: classnames('input', {
- 'invalid-tag': !isValidInput,
- }),
- type: 'email',
- placeholder,
- size: inputValue.length
- ? inputValue.length + 5
- : placeholder.length,
- ref: inputRef,
- // preventKeyAction: showDropdown,
- onBlur: () => {
- addNewItem(inputValue, false)
- },
- onChange: e => {
- setInputValue(e.target.value)
- },
- onClick: () => focusInput,
- onKeyDown: event => {
- switch (event.key) {
- case 'Enter':
- // Enter: always prevent form submission
- event.preventDefault()
- event.stopPropagation()
- break
- case 'Tab':
- // Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful
- if (!isOpen && addNewItem(inputValue)) {
- event.preventDefault()
- event.stopPropagation()
- }
- break
- case ',':
- // comma: try to create a new item using inputValue
- event.preventDefault()
- addNewItem(inputValue)
- break
- }
- },
- onPaste: event => {
- const data =
- // modern browsers
- event.clipboardData?.getData('text/plain') ??
- // IE11
- window.clipboardData?.getData('text')
- if (data) {
- const emails = data
- .split(/[\r\n,; ]+/)
- .filter(item => item.includes('@'))
- if (emails.length) {
- // pasted comma-separated email addresses
- event.preventDefault()
- for (const email of emails) {
- addNewItem(email)
- }
- }
- }
- },
- })
- )}
- />
- </div>
- <div>
- <ul
- {...getMenuProps()}
- className={classnames('dropdown-menu select-dropdown-menu', {
- show: isOpen,
- })}
- >
- {isOpen &&
- filteredOptions.map((item, index) => (
- <Option
- key={item.email}
- index={index}
- item={item}
- selected={index === highlightedIndex}
- getItemProps={getItemProps}
- />
- ))}
- </ul>
- </div>
- </div>
- </div>
- )
- }
- SelectCollaborators.propTypes = {
- loading: PropTypes.bool.isRequired,
- options: PropTypes.array.isRequired,
- placeholder: PropTypes.string,
- multipleSelectionProps: PropTypes.shape({
- getSelectedItemProps: PropTypes.func.isRequired,
- getDropdownProps: PropTypes.func.isRequired,
- addSelectedItem: PropTypes.func.isRequired,
- removeSelectedItem: PropTypes.func.isRequired,
- selectedItems: PropTypes.array.isRequired,
- }).isRequired,
- }
- function Option({ selected, item, getItemProps, index }) {
- return (
- <li {...getItemProps({ item, index })}>
- <DropdownItem
- as="span"
- role={undefined}
- leadingIcon="person"
- className={classnames({
- active: selected,
- })}
- >
- {item.display}
- </DropdownItem>
- </li>
- )
- }
- Option.propTypes = {
- selected: PropTypes.bool.isRequired,
- item: PropTypes.shape({
- display: PropTypes.string.isRequired,
- }),
- index: PropTypes.number.isRequired,
- getItemProps: PropTypes.func.isRequired,
- }
- function SelectedItem({
- removeSelectedItem,
- selectedItem,
- focusInput,
- getSelectedItemProps,
- index,
- }) {
- const handleClick = useCallback(
- event => {
- event.preventDefault()
- event.stopPropagation()
- removeSelectedItem(selectedItem)
- focusInput()
- },
- [focusInput, removeSelectedItem, selectedItem]
- )
- return (
- <Tag
- prepend={<MaterialIcon type="person" />}
- closeBtnProps={{
- onClick: handleClick,
- }}
- {...getSelectedItemProps({ selectedItem, index })}
- >
- {selectedItem.display}
- </Tag>
- )
- }
- SelectedItem.propTypes = {
- focusInput: PropTypes.func.isRequired,
- removeSelectedItem: PropTypes.func.isRequired,
- selectedItem: PropTypes.shape({
- display: PropTypes.string.isRequired,
- }),
- getSelectedItemProps: PropTypes.func.isRequired,
- index: PropTypes.number.isRequired,
- }
|