import React , { useRef , useState } from 'react' ; import Autocomplete , { createFilterOptions } from '@material-ui/lab/Autocomplete' ; import { createStyles , makeStyles , Theme } from '@material-ui/core/styles' ; import TextField from '@material-ui/core/TextField' ; import { isPresent } from "polar-shared/src/Preconditions" ; import { arrayStream } from 'polar-shared/src/util/ArrayStreams' ; import Chip from '@material-ui/core/Chip' ; import { MUIRelatedOptions } from "./MUIRelatedOptions" ; import { PremiumFeature } from "../../ui/premium_feature/PremiumFeature" ; import isEqual from "react-fast-compare" ; import { NULL_FUNCTION } from "polar-shared/src/util/Functions" ; const useStyles = makeStyles ( ( theme ) => createStyles ( { root : { width : 500 , } , } ) ) ; /**
 * An option with a value.
 */ export interface ValueAutocompleteOption < T > { /**
     * A unique internal ID to prevent duplicates being selected.
     */ readonly id : string ; /**
     * A label to show in the UI.
     */ readonly label : string ; /**
     * The actual value which can be a more complex object.
     */ readonly value : T ; } interface CreateAutocompleteOption { readonly inputValue : string ; readonly label : string ; } type InternalAutocompleteOption < T > = CreateAutocompleteOption | ValueAutocompleteOption < T > ; function isCreateAutocompleteOption < T > ( option ) { return isPresent ( ( option as any ) . inputValue ) ; } function isValueAutocompleteOption < T > ( option ) { return isPresent ( ( option as any ) . value ) ; } export type RelatedOptionsCalculator < T > = ( options : ReadonlyArray < ValueAutocompleteOption < T > > ) => ReadonlyArray < ValueAutocompleteOption < T > > ; export interface MUICreatableAutocompleteProps < T > { readonly className ? : string ; readonly style ? : React . CSSProperties ; readonly label ? : string ; readonly options : ReadonlyArray < ValueAutocompleteOption < T > > ; readonly defaultOptions ? : ReadonlyArray < ValueAutocompleteOption < T > > ; readonly placeholder ? : string ; readonly autoFocus ? : boolean ; /**
     * Used when converting an option entered by the user to an object with
     * a label so that new items can be created.
     */ readonly createOption : ( label : string ) => ValueAutocompleteOption < T > ; readonly onChange : ( selected : ReadonlyArray < T > ) => void ; readonly relatedOptionsCalculator ? : RelatedOptionsCalculator < T > ; /**
     * Called when the autocomplete options are open with true and then false
     * when we close it.
     */ readonly onOpen ? : ( open : boolean ) => void ; } interface IState < T > { readonly values : ReadonlyArray < ValueAutocompleteOption < T > > ; readonly options : ReadonlyArray < ValueAutocompleteOption < T > > ; } function sortOptions < T > ( options ) { const hierarchical = options . filter ( current => current . label . startsWith ( '/' ) ) ; const flat = options . filter ( current => ! current . label . startsWith ( '/' ) ) ; function doSort ( a , b ) { return a . label . localeCompare ( b . label ) ; } return [ ... flat . sort ( doSort ) , ... hierarchical . sort ( doSort ) ] ; } export default function MUICreatableAutocomplete < T > ( props ) { const classes = useStyles ( ) ; const options = React . useMemo ( ( ) => sortOptions ( props . options ) , [ props . options ] ) ; const [ state , setState ] = useState < IState < T > > ( { values : props . defaultOptions || [ ] , options } ) ; const [ open , setOpen ] = useState < boolean > ( false ) ; const openRef = React . useRef ( false ) ; const [ inputValue , setInputValue ] = React . useState ( "" ) ; const highlighted = useRef < ValueAutocompleteOption < T > | null > ( null ) ; const optionsIndex = React . useMemo ( ( ) => { return arrayStream ( options ) . toMap ( current => current . id ) ; } , [ options ] ) ; /**
     * Centrally set the values so we can also reset other states, fire events,
     * etc.
     */ const setValues = React . useCallback ( ( values , options ? ) => { setState ( { ... state , values , options : options || state . options } ) ; props . onChange ( values . map ( current => current . value ) ) ; highlighted . current = null ; } , [ props , state ] ) ; const handleChange = React . useCallback ( ( newValues ) => { const convertToAutocompleteOptions = ( rawOptions ) : ReadonlyArray < ValueAutocompleteOption < T > > => { const toAutocompleteOption = ( option ) : ValueAutocompleteOption < T > => { if ( isCreateAutocompleteOption ( option ) ) { return props . createOption ( option . inputValue ) ; } else { return option ; } } ; return rawOptions . map ( toAutocompleteOption ) ; } ; const convertToOptions = ( newValues ) => { const optionsMap = arrayStream ( state . options ) . toMap ( current => current . id ) ; for ( const newValue of newValues ) { optionsMap [ newValue . id ] = newValue ; } return Object . values ( optionsMap ) ; } ; if ( newValues === null ) { setValues ( [ ] ) ; return ; } const toArray = ( ) => { if ( Array . isArray ( newValues ) ) { return newValues ; } return [ newValues ] ; } ; const convertedValues = convertToAutocompleteOptions ( toArray ( ) ) ; const convertedOptions = convertToOptions ( convertedValues ) ; setValues ( convertedValues , convertedOptions ) ; } , [ props , setValues , state . options ] ) ; const filter = createFilterOptions < ValueAutocompleteOption < T > > ( ) ; const computeRelatedOptions = ( ) : ReadonlyArray < ValueAutocompleteOption < T > > | undefined => { if ( props . relatedOptionsCalculator ) { const values = arrayStream ( state . values ) . filter ( isValueAutocompleteOption ) . map ( current => current as ValueAutocompleteOption < T > ) . collect ( ) ; return props . relatedOptionsCalculator ( values ) ; } else { return undefined ; } } ; const relatedOptions = computeRelatedOptions ( ) ; const fireOnOpen = React . useCallback ( ( ) => { const onOpen = props . onOpen || NULL_FUNCTION ; onOpen ( openRef . current ) ; setOpen ( openRef . current ) ; } , [ props . onOpen ] ) ; const handleClose = React . useCallback ( ( ) => { highlighted . current = null ; openRef . current = false ; fireOnOpen ( ) ; } , [ fireOnOpen ] ) ; const createNewOptionOnEnter = React . useCallback ( ( ) => { function findOption ( ) { return arrayStream ( options ) . filter ( current => current . label . toLowerCase ( ) === inputValue ) . first ( ) ; } function createOption ( ) { return { inputValue , label : inputValue } ; } const newOption = findOption ( ) || createOption ( ) ; setInputValue ( '' ) ; setOpen ( false ) ; return handleChange ( [ ... state . values , newOption ] ) ; } , [ handleChange , inputValue , options , state . values ] ) ; const handleKeyUp = React . useCallback ( ( event ) => { function abortEvent ( ) { event . preventDefault ( ) ; event . stopPropagation ( ) ; } if ( event . key === 'Tab' ) { if ( highlighted . current ) { const values = [ ... state . values , highlighted . current ] ; setValues ( values ) ; handleClose ( ) ; } abortEvent ( ) ; } if ( inputValue !== '' ) { if ( event . key === 'Enter' ) { createNewOptionOnEnter ( ) ; } } } , [ createNewOptionOnEnter , handleClose , inputValue , setValues , state . values ] ) ; function handleOpen ( ) { openRef . current = true ; fireOnOpen ( ) ; } /**
     * Return true if the given selected values contains the potentially NEW
     * item so we can not show duplicates.
     */ function hasExistingOption ( newValue ) { if ( isPresent ( optionsIndex [ newValue ] ) ) { return true ; } return arrayStream ( state . values ) . filter ( current => current . id === newValue ) . first ( ) !== undefined ; } function getOptionSelected < T > ( option , value ) { return option . id === value . id ; } const handleHighlightChange = React . useCallback ( ( event , option ) => { highlighted . current = option ; } , [ ] ) ; return ( < div className = { classes . root + ' ' + ( props . className || '' ) } style = { props . style } > < Autocomplete multiple getOptionSelected = { getOptionSelected } inputValue = { inputValue } onKeyUp = { handleKeyUp } value = { [ ... state . values ] } options = { [ ... state . options ] } open = { open } onClose = { handleClose } onOpen = { handleOpen } getOptionLabel = { ( option ) => option . label } clearOnBlur = { false } onInputChange = { ( event , nextInputValue , reason ) => { if ( reason !== 'reset' ) { setInputValue ( nextInputValue ) ; } } } onChange = { ( event , value , reason , details ) => { handleChange ( value ) ; setInputValue ( '' ) ; } } filterSelectedOptions filterOptions = { ( options , params ) => { const filtered = filter ( options , params ) ; if ( inputValue !== '' && ! hasExistingOption ( inputValue ) ) { const createOption = { ... props . createOption ( inputValue ) , label : params . inputValue } ; filtered . push ( createOption ) ; } return filtered ; } } renderTags = { ( value , getTagProps ) => value . map ( ( option , index ) => ( < Chip key = { option . label } label = { option . label } size = "small" { ... getTagProps ( { index } ) } / > ) ) } onHighlightChange = { handleHighlightChange } renderInput = { ( params ) => ( < TextField { ... params } variant = "outlined" autoFocus = { props . autoFocus } label = { props . label } placeholder = { props . placeholder || '' } / > ) } /> < PremiumFeature required = 'plus' size = 'sm' feature = "related tags" > < > < MUIRelatedOptions relatedOptions = { relatedOptions || [ ] } onAddRelatedOption = { newOption => handleChange ( [ ... state . values , newOption ] ) } / > < /> < /PremiumFeature> < /div>); }