import { Dispatch, SetStateAction, useCallback, useEffect, useState, } from 'react' import Downshift, { ControllerStateAndHelpers, DownshiftProps, DownshiftState, StateChangeOptions, } from 'downshift' import { useDebouncedCallback } from 'use-debounce' import { isUndefined } from 'util' import { pipe } from 'fp-ts/lib/function' import * as O from 'fp-ts/lib/Option' import { Do } from 'fp-ts-contrib/lib/Do' import { isEmptyString } from '@monorail/sharedHelpers/typeGuards' import { Nullable } from '@monorail/sharedHelpers/typeLevel' import { DropdownItemValue, DropdownType } from './helpers' import { DropdownParser } from './parsers' export type StateReducer = ( state: DownshiftState, ) => (changes: StateChangeOptions) => StateChangeOptions export declare interface BehaviorController { stateReducer: StateReducer getItems: (text: string) => Array downshiftProps?: Partial & DownshiftProps> } export type BehaviorControllerHook = ( collection: Array, parser: DropdownParser, ) => BehaviorController export const useAsFilter = ( collection: Array, parser: DropdownParser, ): BehaviorController => { /** Filtered Items on search **/ const getItems = useCallback( (text: string) => !isEmptyString(text) ? collection.filter(parser.includes(text)) : collection, [collection, parser], ) const stateReducer: StateReducer = () => changes => { switch (changes.type) { case Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem: return { ...changes, inputValue: '', } case Downshift.stateChangeTypes.changeInput: return { ...changes, isOpen: true, } default: return changes } } /** Handle keyboard input for Filter dropdown */ const onInputChange = ( inputValue: string, { selectedItem, setHighlightedIndex }: ControllerStateAndHelpers, ) => { const items = getItems(inputValue) const selectedIndex = pipe( O.fromNullable(selectedItem), O.map(item => items.indexOf(item)), O.filter(index => index >= 0), O.getOrElse(() => items.findIndex(parser.isActive)), ) setHighlightedIndex(selectedIndex, { isOpen: true }) } /** Handle input text change */ const onStateChange = ( options: StateChangeOptions, downshiftProps: ControllerStateAndHelpers, ) => { switch (options.type) { case Downshift.stateChangeTypes.changeInput: if (!isUndefined(options.inputValue)) { onInputChange(options.inputValue || '', downshiftProps) } break default: break } } return { getItems, downshiftProps: { onStateChange }, stateReducer, } } export const useAsSelect = ( collection: Array, parser: DropdownParser, ): BehaviorController => { /** Input text value **/ // Only used for Non Searchable const [inputText, setInputText] = useState('') const [clearInputTextDebounced] = useDebouncedCallback( () => setInputText(''), 750, ) const updateInputText = (text: string) => { clearInputTextDebounced() setInputText(text) } const getReducedStateForSelect: StateReducer = state => changes => { switch (changes.type) { case Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem: return { ...changes, inputValue: state.inputValue, } case Downshift.stateChangeTypes.changeInput: return { ...changes, isOpen: state.isOpen, } default: return changes } } const stateReducer: StateReducer = state => changes => { const reducedState = getReducedStateForSelect(state)(changes) if (reducedState.inputValue) { updateInputText(reducedState.inputValue) } return reducedState } /** Handle keyboard input for Select dropdown */ const getIndexByTextMatch = (text: string, items: Array) => { const activeItems = items.filter(parser.isActive) const index = activeItems.findIndex(parser.includes(text)) return index >= 0 && activeItems.length !== items.length ? items.indexOf(activeItems[index]) : index } const onInputChange = ( inputValue: string = '', downshiftProps: ControllerStateAndHelpers, ) => { const { isOpen, setHighlightedIndex, selectItem } = downshiftProps if (!isEmptyString(inputValue)) { const index = getIndexByTextMatch(inputValue, collection) if (index >= 0) { if (isOpen) { setHighlightedIndex(index) } else { selectItem(collection[index], { inputValue }) } } } } const onStateChange = ( options: StateChangeOptions, downshiftProps: ControllerStateAndHelpers, ) => { switch (options.type) { case Downshift.stateChangeTypes.changeInput: if (!isUndefined(options.inputValue)) { onInputChange(options.inputValue || '', downshiftProps) } break default: break } } return { getItems: () => collection, downshiftProps: { onStateChange, inputValue: inputText }, stateReducer, } } export const useControlledDropdown = (props: { value?: T | DropdownItemValue collection: Array parser: DropdownParser }): [ O.Option, Dispatch>>, (prevItem: Nullable, item: Nullable) => boolean, ] => { const { value, collection, parser } = props /** Selected Dropdown Item **/ const [selectedItem, setSelectedItem] = useState>(O.none) const hasItemChanged = (prevItem: O.Option, newItem: O.Option) => pipe( prevItem, O.alt(() => newItem), O.fold( () => false, () => pipe( Do(O.option) .bind('a', prevItem) .bind('b', newItem) .return(({ a, b }) => !parser.compare(a)(b)), O.getOrElse(() => true), ), ), ) const updateSelectedItem = (item: O.Option) => { if (hasItemChanged(selectedItem, item)) { setSelectedItem(item) } } const compare = (prevItem: Nullable, item: Nullable) => hasItemChanged(O.fromNullable(prevItem), O.fromNullable(item)) /* eslint-disable react-hooks/exhaustive-deps */ useEffect(() => { const newValue = O.fromNullable(value) /* * We need to check if the value or * the selectedItem are in the collection. */ const updatedItem = pipe( selectedItem, O.chain(item => pipe(newValue, O.filter(parser.compare(item)))), O.alt(() => newValue), O.mapNullable(item => collection.find(parser.compare(item))), ) updateSelectedItem(updatedItem) }, [value, collection]) /* eslint-enable react-hooks/exhaustive-deps */ return [selectedItem, setSelectedItem, compare] }