import React, { useEffect, useRef, useMemo, useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { QSMRootState } from '../../store/qsm-store'; import { setFieldValue, setSearchResults, setActiveSearchField, setMobileFilterType, setActiveSearchFieldProps } from '../../store/qsm-slice'; import useMediaQuery from '../../../shared/utils/use-media-query-util'; import SearchInput from '../search-input'; import Icon from '../../../shared/components/icon'; import { BaseFieldConfig, TypeaheadOption, OptionType } from '../../types'; interface Props { fieldConfig: BaseFieldConfig; enableMobileFilter?: boolean; highlightTarget?: string; isSecondInput?: boolean; isDoubleInput?: boolean; readOnlyForced?: boolean; isDisabled?: boolean; } const SearchInputGroup: React.FC = ({ fieldConfig, enableMobileFilter = true, highlightTarget = '', isSecondInput = false, isDoubleInput = false, readOnlyForced = false, isDisabled = false }) => { const dispatch = useDispatch(); const ref = useRef(null); const small = useMediaQuery('(max-width: 768px)'); if (!fieldConfig) { return null; } const { fieldKey, label, placeholder, options, autoComplete } = fieldConfig; const [isLoading, setIsLoading] = useState(false); const selector = useMemo(() => (state: QSMRootState) => ((state.qsm as any)[fieldKey] ?? '') as string, [fieldKey]); const value = useSelector(selector); const { searchResults, activeSearchField } = useSelector((state: QSMRootState) => state.qsm); const selectedOption = options.find((option) => option.value === value); const typeOfSelectedOption: OptionType = selectedOption?.type ?? 'other'; const match = useCallback( (input: string) => { if (!input) { return []; } const lowered = input.toLowerCase(); return options .filter((option) => option.value.toLowerCase().includes(lowered) || option.iataCode?.toLowerCase().includes(lowered)) .sort((a, b) => { const aExactIata = a.iataCode?.toLowerCase() === lowered; const bExactIata = b.iataCode?.toLowerCase() === lowered; if (aExactIata && !bExactIata) return -1; if (!aExactIata && bExactIata) return 1; return 0; }); }, [options] ); const handleInputChange = useCallback( (input: string) => { dispatch(setFieldValue({ fieldKey, value: input })); dispatch(setSearchResults([])); if (small) return; dispatch(setActiveSearchField(fieldKey)); // if field has custom onChange (API search) if (fieldConfig.onChange) { fieldConfig.onChange(input); setIsLoading(true); return; } // fallback to local filtering dispatch(setSearchResults(match(input))); }, [dispatch, fieldKey, small, match, fieldConfig] ); useEffect(() => { setIsLoading(false); }, [options]); useEffect(() => { if (!value || activeSearchField !== fieldKey) return; dispatch(setSearchResults(match(value))); }, [options, value, activeSearchField, fieldConfig, fieldKey]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!['Tab', 'Enter'].includes(e.key)) return; if (value.length === 3 && autoComplete) { const exactIataMatch = findExactIataMatch(options, value); if (exactIataMatch) { if (e.key === 'Enter') { e.preventDefault(); } dispatch(setFieldValue({ fieldKey, value: exactIataMatch.value })); dispatch(setSearchResults([])); dispatch(setActiveSearchField(null)); } } }, [value, autoComplete, options, dispatch, fieldKey] ); const handleOptionSelect = useCallback( (option: TypeaheadOption) => { dispatch(setFieldValue({ fieldKey, value: option.value })); dispatch(setSearchResults([])); dispatch(setActiveSearchField(null)); }, [dispatch, fieldKey] ); const click = () => { if (isDisabled) return; dispatch(setActiveSearchField(fieldKey)); dispatch(setSearchResults([])); if (small && enableMobileFilter) { dispatch( setActiveSearchFieldProps({ fieldKey, label, placeholder, value, options }) ); dispatch(setMobileFilterType('search')); } else if (value.trim()) { dispatch(setSearchResults(match(value))); } }; const findExactIataMatch = (options: TypeaheadOption[], input: string): TypeaheadOption | undefined => options.find((option) => option.iataCode && option.iataCode.toLowerCase() === input.toLowerCase()); useEffect(() => { const outside = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { dispatch(setActiveSearchField(null)); } }; const esc = (e: KeyboardEvent) => e.key === 'Escape' && dispatch(setActiveSearchField(null)); if (!small && activeSearchField === fieldKey) { document.addEventListener('mousedown', outside); document.addEventListener('keydown', esc); } return () => { document.removeEventListener('mousedown', outside); document.removeEventListener('keydown', esc); }; }, [dispatch, small, activeSearchField, fieldKey]); return ( ); }; export default SearchInputGroup;