///////////////////////////////////////////////////////////////////// // Module imports // ///////////////////////////////////////////////////////////////////// import * as React from 'react'; import { StyleSheet, SafeAreaView, ScrollView, View, TouchableOpacity, TouchableHighlight, Text, Dimensions, TextInput, FlatList, Button, Keyboard } from 'react-native'; import { Icon } from 'react-native-elements'; import { MaterialIndicator } from 'react-native-indicators'; ///////////////////////////////////////////////////////////////////// // Local imports // ///////////////////////////////////////////////////////////////////// // Theme import colors from '../../styles/colors'; import typography from '../../styles/typography'; ///////////////////////////////////////////////////////////////////// // Styling // ///////////////////////////////////////////////////////////////////// const screenWidth = Math.round(Dimensions.get('window').width); const screenHeight = Math.round(Dimensions.get('window').height); const styles = StyleSheet.create({ container: { position: 'absolute', flexDirection: 'column', justifyContent: 'center', zIndex: 5 }, parent: { marginTop: 12, borderWidth: 1, borderRadius: 5, borderColor: colors.onsurface_disabled, paddingLeft: 8, paddingRight: 4, flexDirection: "row", justifyContent: "space-between", width: screenWidth * 0.905, }, focusedParent: { marginTop: 12, borderWidth: 1, borderRadius: 5, borderColor: colors.onsurface_disabled, paddingLeft: 8, flexDirection: "row", justifyContent: "space-between", backgroundColor: colors.onprimary_highEmphasis, width: screenWidth * 0.905 }, searchBar: { height: 38, width: '89%', backgroundColor: 'transparent', alignSelf: "center", }, focusedSearchBar: { height: 38, width: '89%', backgroundColor: colors.onprimary_highEmphasis }, clearBtn: { justifyContent: "center", alignSelf: "center", marginRight: 4, }, focusedClearBtn: { justifyContent: "center", alignSelf: "center", marginRight: 8, marginTop: 2 }, dropdown: { position: "relative", backgroundColor: colors.onprimary_highEmphasis, width: screenWidth * 0.904, height: screenHeight * 0.18, alignItems: "flex-start", justifyContent: "flex-start", elevation: 8, paddingTop: 4, zIndex: 100, }, ghostInput: { top: screenHeight, display: "none" }, dropdownItem: { paddingTop: 6, paddingBottom: 6, width: screenWidth * 0.91, }, dropdownItemTitle: { fontSize: 14, paddingTop: 2, paddingLeft: 13, paddingRight: 13, paddingBottom: 2 }, loadingIndicator: { alignItems: "center", justifyContent: "center", paddingLeft: (screenWidth * 0.85) / 2 }, noResults: { width: screenWidth * 0.904, height: screenHeight * 0.14, alignItems: "center", justifyContent: "center" } }); ///////////////////////////////////////////////////////////////////// // API Search // ///////////////////////////////////////////////////////////////////// const searchCharacters = async ( search ) => { try { const response = await fetch( `https://clinicaltables.nlm.nih.gov/api/rxterms/v3/search?terms=${search.toLowerCase()}&ef=STRENGTHS_AND_FORMS` ); const data = await response.json(); return data; } catch (error) { console.log(`error ocurred: ${error}`); } } // Hook const useDebounce = ( value, delay ) => { const [debouncedValue, setDebouncedValue] = React.useState(value); React.useEffect( () => { // Update debounced value after delay const handler = setTimeout(() => { setDebouncedValue(value); }, delay); // Cancel the timeout if value changes (also on delay change or unmount) // This is how we prevent debounced value from updating if value is changed ... // .. within the delay period. Timeout gets cleared and restarted. return () => { clearTimeout(handler); }; }, [value, delay] // Only re-call effect if value or delay changes ); return debouncedValue; } ///////////////////////////////////////////////////////////////////// // Auto Complete Search Bar // ///////////////////////////////////////////////////////////////////// const AutoSearchBar = (props) => { // Search term const [searchTerm, setSearchTerm] = React.useState(""); // Text to display in search bar const [text, onChangeText] = React.useState(""); // API search results const [searchResults, setSearchResults] = React.useState(['']); // Searching status (whether there is pending API request) const [isSearching, setIsSearching] = React.useState(false); // Keep track of search bar focus const [hasFocus, onChangeFocus] = React.useState(false); const [showingDropdown, setShowingDropdown] = React.useState(false); // Dropdown item id const [selectedId, setSelectedId] = React.useState(null); const [dropdownData, setDropdownData] = React.useState(null); // Debounce search term so that it only gives us latest value ... // ... if searchTerm has not been updated within last 500ms. // The goal is to only have the API call fire when user stops typing ... // ... so that we aren't hitting our API rapidly. const debouncedSearchTerm = useDebounce(searchTerm, 500); const toggleDropdown = () => { setShowingDropdown(!showingDropdown); }; const hideDropdown = () => { setShowingDropdown(false); }; const renderDropdownItem = ({ item }) => ( { onChangeText(item.name + ' (' + item.dose + ')'); props.onSelectionChange(item.name, item.form) Keyboard.dismiss(); }} > {item.name + ' (' + item.dose + ')'} ); const renderDropdown = () => { return ( {isSearching && ( )} {!isSearching && ( )} ListEmptyComponent={( No medications found )} keyExtractor={item => item.id} extraData={searchResults} keyboardShouldPersistTaps={'handled'} nestedScrollEnabled /> )} ); }; const onSearchBarFocus = () => { onChangeFocus(true); if(text !== '') { toggleDropdown(); } } const onSearchBarBlur = () => { onChangeFocus(false); setShowingDropdown(false); } // What to do when text is being typed const onSearchBarTextChange = (text) => { if(text !== '' && !showingDropdown) { toggleDropdown(); } else if(text === '' && showingDropdown){ toggleDropdown(); } } const clearSearchBarTxt = () => { onChangeText(''); hideDropdown(); } const prettifySearchResults = (results) => { const suggestions = []; // Only prettify if a non-empty result is returned from API if (results.toString()[0] !== '0'){ // Get the list of medication names const medicationNames = results[1].map(result => { const name = result.match(/^[^ ]+/); const prettyName = name.toString().toLowerCase(); return prettyName.charAt(0).toUpperCase() + prettyName.slice(1); }); // Get list of the corresponding strengths and forms of above med list const strengthsAndForms = results[2].STRENGTHS_AND_FORMS; // Combine medication name with its corresponding strengths and forms const medicationDoses = strengthsAndForms.map((medication, index) => { // Get full name of medication form if needed medication.map(dose => { let formArray = dose.match(/(\b[A-Z][a-z]+|\b[A-Z]\b)/g); let form = 'dose'; let suggestDose = 'none'; if(formArray.length > 1){ form = formArray.join(' '); } else { form = formArray.toString(); } // Some strengths and forms are returned with extra spaces. // Get rid of those let prettyDose = dose.trim(); if (form === 'Cap') { form = "Capsule"; prettyDose = prettyDose.replace("Cap", "Capsule"); } if (form === 'Tab' || form === 'Microencapsulated Tab') { form = "Tablet"; prettyDose = prettyDose.replace("Tab", "Tablet"); } if (form === 'Sol' || form === 'Irrig Sol') { form = "Solution"; prettyDose = prettyDose.replace("Sol", "Solution"); } if (form === 'Susp') { form = "Suspension"; prettyDose = prettyDose.replace("Susp", "Suspension"); } if (form === 'Pwdr') { form = "Powder"; prettyDose = prettyDose.replace("Pwdr", "Powder"); } if (form === 'Sugar Free Pwdr') { form = "Sugar-Free Powder"; prettyDose = prettyDose.replace("Sugar-Free Pwdr", "Sugar-Free Powder"); } // Store pretty medication information suggestions.push({ id: medicationNames[index] + '_' + dose, name: medicationNames[index], dose: prettyDose, form: form, suggestDose: suggestDose }); }); return medication; }); } // Remove duplicates from results const prettyResults = suggestions.filter((v, i, a) => a.findIndex(t => (t.id === v.id)) === i) setSearchResults(prettyResults); } // Effect for API call React.useEffect( () => { if (debouncedSearchTerm) { setIsSearching(true); searchCharacters(debouncedSearchTerm).then((results) => { // results is a string. searchCharacters(debouncedSearchTerm) should be an array setIsSearching(false); if(results === undefined || results.length === 0) { setSearchResults([]); } else { prettifySearchResults(results); } }); } else { setSearchResults([]); setIsSearching(false); } }, [debouncedSearchTerm] // Only call effect if debounced search term changes ); return ( { setSearchTerm(text); onChangeText(text); onSearchBarTextChange(text); }} value={text} autoCorrect={false} placeholder='Medication name' onFocus={onSearchBarFocus} onBlur={onSearchBarBlur} /> {showingDropdown ? renderDropdown() : null} ); }; export default AutoSearchBar;