DEV Community

Cristian Echeverria
Cristian Echeverria

Posted on • Updated on

React Native Taxi App: Booking Information. Places API

Source
Let's continue working with the Booking process. We're going to use the same UserScreen component for:

  1. Departure Information
  2. Booking Information

We already worked on Departure Information on previous parts of this tutorial. For the Booking Information let's start creating a new file for the component inside src/components/BookingInformation.js:

// src/components/BookingInformation.js
import React from "react"
import styled from "styled-components/native"
import FeatherIcon from "react-native-vector-icons/Feather"
import { formatPlaceName } from "../utils"
import { usePlace } from "../context/PlacesManager"

const Container = styled.View`
  flex: 1.5;
  background-color: #fff;
  padding-vertical: 20px;
  padding-horizontal: 20px;
`

export const Location = styled.View`
  flex-direction: row;
  align-items: center;
`
const LocationPlaceholder = styled.Text`
  color: #717171;
  font-size: 14px;
  margin-left: 5px;
  font-weight: 600;
`;

const Text = styled.Text`
  color: #000;
  font-size: 16px;
  font-weight: 600;
  margin-left: 5px;
`

export default function BookingInformation() {
  const {
    place: { currentPlace },
  } = usePlace()

  return (
    <Container>
      <Location>
        <FeatherIcon name="map-pin" size={15} color="gray" />
        <Text testID="current-place-description">
          {formatPlaceName(currentPlace.description)}
        </Text>
      </Location>

      <FeatherIcon
        name="more-vertical"
        size={15}
        color="gray"
        marginTop={-10}
      />

      <Location>
        <FeatherIcon name="more-vertical" size={15} color="gray" />
        <LocationPlaceholder testID="destination-label">
          Destination address
        </LocationPlaceholder>
      </Location>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

We created a couple of Styled components also imported usePlace custom hook from PlacesManager Context Provider to show the selected currentPlace description. The main idea is to show the BookingInformation component from UserScreen when we toggle the Book Now button from the DepartureInformation component.

I'm going to create another custom hook component for show/hide functionality in our App. For that, let's create a new folder, taxiApp/src/hooks/index.js:

// taxiApp/src/hooks/index.js
import {useState} from 'react';

export const useShowState = (initialOpen = false) => {
  const [isOpen, setIsOpen] = useState(initialOpen);

  const onToggle = () => {
    setIsOpen((prevState) => !prevState);
  };

  return [isOpen, onToggle];
};
Enter fullscreen mode Exit fullscreen mode

Now, let's use useShowState custom hook inside our UserScreen component.

// taxiApp/src/screens/UserScreen.js
/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 *
 * @format
 * @flow strict-local
 */

import React, {useEffect, useState} from 'react';
import {StatusBar, Platform, Image} from 'react-native';
import styled from 'styled-components/native';
import MapView, {PROVIDER_GOOGLE} from 'react-native-maps';
import {check, request, PERMISSIONS, RESULTS} from 'react-native-permissions';
import Geolocation from 'react-native-geolocation-service';
import {customStyleMap, MenuButtonLeft} from '../styles';
import FeatherIcon from 'react-native-vector-icons/Feather';
import DepartureInformation from '../components/DepartureInformation';
import Geocoder from 'react-native-geocoding';
import {usePlace} from '../context/PlacesManager';
import {GOOGLE_MAPS_API_KEY} from '../utils/constants';
import marker from '../assets/icons-marker.png';
// Import BookingInformation and useShowState custom hook
import BookingInformation from '../components/BookingInformation';
import {useShowState} from '../hooks';

Geocoder.init(GOOGLE_MAPS_API_KEY, {language: 'en'});

const Container = styled.SafeAreaView`
  flex: 1;
  background-color: #fff;
`;

const mapContainer = {
  flex: 7,
};

const FixedMarker = styled.View`
  left: 50%;
  margin-left: -16px;
  margin-top: -125px;
  position: absolute;
  top: 50%;
`;

const markerStyle = {
  height: 36,
  width: 36,
};

const UserScreen = ({navigation}) => {
  const [location, setLocation] = useState(null);
  const {place, dispatchPlace} = usePlace();
  // Create a local state using the custom Hook
  const [showBooking, toggleShowBookingViews] = useShowState(false);

  const handleLocationPermission = async () => {
    let permissionCheck = '';
    if (Platform.OS === 'ios') {
      permissionCheck = await check(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);

      if (permissionCheck === RESULTS.DENIED) {
        const permissionRequest = await request(
          PERMISSIONS.IOS.LOCATION_WHEN_IN_USE,
        );
        permissionRequest === RESULTS.GRANTED
          ? console.warn('Location permission granted.')
          : console.warn('Location perrmission denied.');
      }
    }

    if (Platform.OS === 'android') {
      permissionCheck = await check(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);

      if (permissionCheck === RESULTS.DENIED) {
        const permissionRequest = await request(
          PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
        );
        permissionRequest === RESULTS.GRANTED
          ? console.warn('Location permission granted.')
          : console.warn('Location perrmission denied.');
      }
    }
  };

  useEffect(() => {
    handleLocationPermission();
  }, []);

  useEffect(() => {
    Geolocation.getCurrentPosition(
      position => {
        const {latitude, longitude} = position.coords;
        Geocoder.from({
          latitude: latitude,
          longitude: longitude,
        }).then(res => {
          const {
            formatted_address,
            place_id,
            geometry: {
              location: {lat, lng},
            },
          } = res.results[0];
          setLocation({latitude, longitude});
          dispatchPlace({
            type: 'SET_CURRENT_PLACE',
            description: formatted_address,
            placeId: place_id,
            latitude: lat,
            longitude: lng,
          });
        });
      },
      error => {
        console.log(error.code, error.message);
      },
      {enableHighAccuracy: true, timeout: 15000, maximumAge: 10000},
    );
  }, [dispatchPlace]);

  const onRegionChange = ({latitude, longitude}) => {
    Geocoder.from({
      latitude,
      longitude,
    }).then(res => {
      const {
        formatted_address,
        place_id,
        geometry: {
          location: {lat, lng},
        },
      } = res.results[0];

      dispatchPlace({
        type: 'SET_CURRENT_PLACE',
        description: formatted_address,
        placeId: place_id,
        latitude: lat,
        longitude: lng,
      });
    });
  };

  useEffect(() => {
    navigation.setOptions({
      headerLeft: () => (
        <MenuButtonLeft
          onPress={() => navigation.navigate('Menu')}
          testID="modal-menu">
          <FeatherIcon name="menu" size={25} color="#000" />
        </MenuButtonLeft>
      ),
    });
  }, [navigation]);

  return (
    <Container>
      <StatusBar barStyle="dark-content" />
      {location && (
        <MapView
          testID="map"
          style={mapContainer}
          provider={PROVIDER_GOOGLE}
          initialRegion={{
            latitude: location.latitude,
            longitude: location.longitude,
            latitudeDelta: 0.0922,
            longitudeDelta: 0.0421,
          }}
          onRegionChangeComplete={onRegionChange}
          showsUserLocation={true}
          customMapStyle={customStyleMap}
          paddingAdjustmentBehavior="automatic"
          showsMyLocationButton={true}
          showsBuildings={true}
          maxZoomLevel={17.5}
          loadingEnabled={true}
          loadingIndicatorColor="#fcb103"
          loadingBackgroundColor="#242f3e"
        />
      )}

      <FixedMarker testID="fixed-marker">
        <Image style={markerStyle} source={marker} />
      </FixedMarker>

      {/* Logic to when to show BookingInformation or DepartureInformation */}
      {showBooking ? (
        <BookingInformation />
      ) : (
        <DepartureInformation toggleShowBookingViews={toggleShowBookingViews} />
      )}
{/* See that we pass toggleShowBookingViews as prop */}
    </Container>
  );
};

export default UserScreen;
Enter fullscreen mode Exit fullscreen mode

As you see, we imported BookingInformation component and useShowState custom hook to UserScreen. The custom hook will create a local state that will handle when we show/hide BookingInformation and DepartureInformation.

We also passed toggleShowBookingViews function as prop to DepartureInformation. The reason is to toggle the Book Now button and update the showBooking state.

Now let's move to DepartureInformation and handle the new prop we just passed.

// taxiApp/src/components/DepartureInformation.js
...
export default function DepartureInformation({toggleShowBookingViews}) {
...
  return (
    <Container platform={Platform.OS}>
      ...

      <BookNow>
        <BookNowButton
          onPress={toggleShowBookingViews}
          testID="book-now-button">
          <ButtonText>Book now</ButtonText>
        </BookNowButton>
      </BookNow>
    </Container>
  );
}

DepartureInformation.propTypes = {
  toggleShowBookingViews: PropTypes.func,
};
Enter fullscreen mode Exit fullscreen mode

We receive the passed prop toggleShowBookingViews and then replace the console.log() function with the new prop function in the BookNowButton component.

So, if everything is fine when you press the Book Now button, you should see BookingInformation component UI, as shown in the gif below.

Book Now Button Toggle

Add a Booking Information Input

We need to add an input where the user can type the Destination Place. The idea is to have a Modal where we display a TextInput. Before that, let's add a Pressable component that will open the Modal.

// taxiApp/src/components/BookingInformation.js
import React from 'react';
import styled from 'styled-components/native';
import FeatherIcon from 'react-native-vector-icons/Feather';
import {formatPlaceName} from '../utils';
import {usePlace} from '../context/PlacesManager';
// Import custom hook for show/hide elements.
import {useShowState} from '../hooks';

const Container = styled.View`
  flex: 1.5;
  background-color: #fff;
  padding-vertical: 20px;
  padding-horizontal: 20px;
`;

export const Location = styled.View`
  flex-direction: row;
  align-items: center;
`;

const LocationPlaceholder = styled.Text`
  color: #717171;
  font-size: 14px;
  margin-left: 5px;
  font-weight: 600;
`;

const Text = styled.Text`
  color: #000;
  font-size: 16px;
  font-weight: 600;
  margin-left: 5px;
`;

// New Pressable component
const LocationPressable = styled.Pressable`
  flex-direction: row;
  align-items: center;
  margin-bottom: 10px;
`;

// New styled component
const AddDestinationText = styled.Text`
  color: #000;
  font-size: 20px;
  font-weight: 600;
  margin-left: 5px;
`;

// New styled component
const TextRight = styled(Text)`
  margin-left: auto;
`;

export default function BookingInformation() {
  // Add destinationPlace from PlacesManager
  const {
    place: {currentPlace, destinationPlace},
  } = usePlace();
  // Create a local state for toggle a Modal
  const [isModalVisible, togglePlaceModal] = useShowState();

  return (
    <Container>
      <Location>
        <FeatherIcon name="map-pin" size={15} color="gray" />
        <Text testID="current-place-description">
          {formatPlaceName(currentPlace.description)}
        </Text>
      </Location>

      <FeatherIcon
        name="more-vertical"
        size={15}
        color="gray"
        marginTop={-10}
      />

      <Location>
        <FeatherIcon name="more-vertical" size={15} color="gray" />
        <LocationPlaceholder testID="destination-label">
          Destination address
        </LocationPlaceholder>
      </Location>
      {/* Add new components for toggle a Modal */}
      <LocationPressable onPress={togglePlaceModal}>
        <FeatherIcon name="circle" size={15} color="gray" />
        <AddDestinationText testID="destination-place-description">
          {formatPlaceName(destinationPlace.description) || 'Add destination'}
        </AddDestinationText>
        <TextRight>
          <FeatherIcon name="search" size={15} color="#000" />
        </TextRight>
      </LocationPressable>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

We import the custom hook for handle show/hide Modal. Add a couple of new Styled Components, including Pressable from React Native. Also, add destinationPlace from PlacesManager Context Provider.

Destination Address Modal

Alright, we need to create a new component called SearchAddressModal, inside that Modal we will have a TextInput for search the user destination place. SearchAddressModal will be called from the BookingInformation component.

React Native Modal

We'll use a new package called react-native-modal for our Modal component, let's install it:

npm i react-native-modal --save-exact
Enter fullscreen mode Exit fullscreen mode

Create a new file inside taxiApp/src/components/SearchAddressModal.js:

// taxiApp/src/components/SearchAddressModal.js
import React from 'react';
import {StatusBar, TextInput} from 'react-native';
import styled from 'styled-components/native';
import Modal from 'react-native-modal';
import FeatherIcon from 'react-native-vector-icons/Feather';

const Container = styled.SafeAreaView`
  flex: 1;
`;

const BackButton = styled.TouchableOpacity`
  margin-top: 10px;
`;

const ModalChildrenView = styled.View`
  flex-direction: row;
  align-items: center;
`;

const SearchContainer = styled.View`
  flex-direction: row;
  align-items: center;
`;

const ClearDestinationButton = styled.TouchableOpacity`
  margin-left: auto;
`;

const Input = styled(TextInput)`
  color: #000000;
  font-size: 20px;
  font-weight: 600;
  height: 50px;
  width: 90%;
  padding: 10px;
`;

export default function SearchAddressModal({isModalVisible, toggleModal}) {
  return (
    <Modal
      isVisible={isModalVisible}
      backdropColor="white"
      backdropOpacity={1}
      animationIn="slideInUp"
      testID="search-address-modal">
      <StatusBar barStyle="dark-content" />
      <Container>
        <BackButton testID="back-button" onPress={toggleModal}>
          <FeatherIcon name="arrow-left" size={20} color="gray" />
        </BackButton>

        <ModalChildrenView>
          <SearchContainer>
            <FeatherIcon name="map-pin" size={20} color="gray" />
            <Input
              placeholder="Add destination"
              placeholderTextColor="#000000"
            />
            <ClearDestinationButton testID="clear-button" onPress={() => {}}>
              <FeatherIcon name="x-circle" color="grey" size={20} />
            </ClearDestinationButton>
          </SearchContainer>
        </ModalChildrenView>
      </Container>
    </Modal>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you see now, we have a new component that uses Modal from react-native-modal. For now, this component doesn't have a local state; instead, we receive a couple of props from the BookingInformation component to check the Modal's state and close the Modal.

We added a TextInput that for now doesn't work because it doesn't have a state. The local state for the Input component will come as prop from BookingInformation.

Open the Modal from Booking Information

Move into BookingInformation and import the new component, also pass the props the new Modal component need to be visible.

// taxiApp/src/components/BookingInformation.js
import SearchAddressModal from './SearchAddressModal';

...
export default function BookingInformation() {
...
  const [isModalVisible, togglePlaceModal] = useShowState();

  return (
    <>
      <Container>
        ...
      </Container>
     {/* Import the new Modal component */}
      <SearchAddressModal
        isModalVisible={isModalVisible}
        toggleModal={togglePlaceModal}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you can see we imported the new Modal component we created and we are rendering outside our <Container></Container> component, that's why we use <></> before and after Container component.

We also pass the two props <SearchAddressModal /> component is expecting in order to show/hide.

 <SearchAddressModal
        isModalVisible={isModalVisible}
        toggleModal={togglePlaceModal}
      />
Enter fullscreen mode Exit fullscreen mode

If everything is fine, when you press the "Add destination" component, you should see that the Modal is visible. Inside the Modal, you can close it by pressing the back arrow button.

Show/Hide Modal

Add local state for Modal Input

Let's add a local state that will make the Input component work, as I mentioned above. This local state will come from BookingInformation and passed to the Modal component.

// taxiApp/src/components/BookingInformation.js
import React, {useState} from 'react';
...

export default function BookingInformation() {
  const {
    place: {currentPlace, destinationPlace},
  } = usePlace();
  const [isModalVisible, togglePlaceModal] = useShowState();
 // Input Modal state
  const [newAddress, setNewAddress] = useState(null);

  return (
    <>
      <Container>
        ...
      </Container>

      <SearchAddressModal
        isModalVisible={isModalVisible}
        toggleModal={togglePlaceModal}
     +  newAddress={newAddress}
     +  setNewAddress={setNewAddress}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, we have to move into SearchAddressModal component and receive the two props and use them for the Input component.

// taxiApp/src/components/SearchAddressModal.js
...

export default function SearchAddressModal({
  isModalVisible,
  toggleModal,
+ newAddress,
+ setNewAddress,
}) {
  return (
    <Modal
      isVisible={isModalVisible}
      backdropColor="white"
      backdropOpacity={1}
      animationIn="slideInUp"
      testID="search-address-modal">
      <StatusBar barStyle="dark-content" />
      <Container>
        <BackButton testID="back-button" onPress={toggleModal}>
          <FeatherIcon name="arrow-left" size={20} color="gray" />
        </BackButton>

        <ModalChildrenView>
          <SearchContainer>
            <FeatherIcon name="map-pin" size={20} color="gray" />
            <Input
              placeholder="Add destination"
              placeholderTextColor="#000000"
   +          value={newAddress}
   +          onChangeText={text => setNewAddress(text)}
            />
            <ClearDestinationButton
              testID="clear-button"
   +          onPress={() => setNewAddress('')}>
              <FeatherIcon name="x-circle" color="grey" size={20} />
            </ClearDestinationButton>
            </ClearDestinationButton>
          </SearchContainer>
        </ModalChildrenView>
      </Container>
    </Modal>
  );
}
Enter fullscreen mode Exit fullscreen mode

After that, we should be able to type inside the Input component. Also, we should be able to clear what we type when we press the "x-circle" button.

Google Places API

We will use Places API from Google to search for our destination place when we type using the Input component on the Modal screen.

There's a good package for React Native called react-native-google-places-autocomplete that you can use, I tested it, and it works fine. It already comes with an Input component.

But, for this project to have more control, I decided to do it step by step.

We'll need to enable Places API in our Google Console Cloud project, the same steps we followed for enabling Geocoding API and Maps SDK for Android and iOS.

Places API

The process is quite simple, we need to fetch from and endpoint passing the Google Maps API Key and the destination. We'll create a new function utility for that inside our src/utils/index.js file:

import {GOOGLE_MAPS_API_KEY} from './constants';

...
// This function receive two arguments
export const APIPlaceAutocomplete = (destination, currentPlace) => {
  const URL = `https://maps.googleapis.com/maps/api/place/autocomplete/json?key=${GOOGLE_MAPS_API_KEY}&input=${destination}&location=${currentPlace.latitude},${currentPlace.longitude}&radius=2000`;

  if (destination.length > 0) {
    return fetch(URL)
      .then(resp => resp.json())
      .catch(error => error);
  } else {
    return 'No destination Address provided';
  }
};
Enter fullscreen mode Exit fullscreen mode

So, we are fetching from https://maps.googleapis.com/maps/api/place/autocomplete/json passing a couple of parameters:

  • key
  • input
  • location
  • radius

We have to call this function from the SearchAddressModal component passing the parameters it needs to make the successful call to the endpoint.

Fetch Places API using Lodash Debounce

If we call the Google Places API while we type using the Input component from the Modal, we will make the call every time we type any single word, which is useless and unsuitable for optimization.

That's why we're going to use Debounce from the Lodash library. Install Lodash:

npm i --save-exact lodash
Enter fullscreen mode Exit fullscreen mode

Open SearchAddressModal component:

+ import React, {useState, useEffect, useCallback} from 'react';
...
import {debounce} from 'lodash';
import {APIPlaceAutocomplete} from '../utils';

...

export default function SearchAddressModal({
  isModalVisible,
  toggleModal,
  newAddress,
  setNewAddress,
+ currentPlace,
}) {
+  const [predictions, setPredictions] = useState([]);

+  useEffect(() => {
    if (newAddress) {
      debounceSearch(newAddress);
    } else {
      setPredictions([]);
    }
  }, [newAddress, debounceSearch]);

+  const debounceSearch = useCallback(
    debounce(address => {
      APIPlaceAutocomplete(address, currentPlace)
        .then(results => {
          setPredictions(results.predictions);
        })
        .catch(e => console.warn(e));
    }, 1000),
    [],
  );

  return (
    <Modal
        ...
    </Modal>
  );
}
Enter fullscreen mode Exit fullscreen mode

We first import useState, useEffect and useCallback from React. We also import debounce from lodash and the recent created function utility APIPlaceAutocomplete.

We also receive a new prop, currentPlace. This prop we could inject into the Modal component using the custom hook from PlacesManager, but I decided to receive it from BookingInformation.

Using useState, we create a local state called predictions, which is an empty array, here we will display the list of predictions from Google Places API.

useEffect(() => {
    if (newAddress) {
      debounceSearch(newAddress);
    } else {
      setPredictions([]);
    }
  }, [newAddress, debounceSearch]);
Enter fullscreen mode Exit fullscreen mode

If we have a newAddress, we call the function debounceSearch with the newAddress. Otherwise, we call setPredictions with an empty array.

const debounceSearch = useCallback(
    debounce(address => {
      APIPlaceAutocomplete(address, currentPlace)
        .then(results => {
          setPredictions(results.predictions);
        })
        .catch(e => console.warn(e));
    }, 1000),
    [],
  );
Enter fullscreen mode Exit fullscreen mode

We use useCallback with debounce, which means that every 1 second, we will call the APIPlaceAutocomplete function, passing the two parameters the function needs.

So, let's pass currentPlace from BookingInformation as prop to SearchAddressModal component.

...
export default function BookingInformation() {
...

  return (
    <>
      ...

      <SearchAddressModal
        isModalVisible={isModalVisible}
        toggleModal={togglePlaceModal}
        newAddress={newAddress}
        setNewAddress={setNewAddress}
   +    currentPlace={currentPlace}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Render List of Predictions with Flatlist

We need to render the list of predictions that we stored inside the predictions local state. First, let's create a new component inside src/components/Prediction.js:

import React from 'react';
import {TouchableOpacity} from 'react-native';
import styled from 'styled-components/native';

const Text = styled.Text`
  padding: 5px;
  font-size: 14px;
`;

export default function Prediction({description, place_id}) {
  return (
    <TouchableOpacity
      key={place_id}
      testID={`prediction-row-${place_id}`}
      onPress={() => {}}>
      <Text>{description}</Text>
    </TouchableOpacity>
  );
}
Enter fullscreen mode Exit fullscreen mode

And now let's move into SearchAddressModal and start using it along with Flatlist component from React Native.

...
+ import Prediction from './Prediction';

...

const Predictions = styled.View`
  margin-bottom: 20px;
`;

export default function SearchAddressModal({
  isModalVisible,
  toggleModal,
  newAddress,
  setNewAddress,
  currentPlace,
}) {
  const [predictions, setPredictions] = useState([]);

  useEffect(() => {
    if (newAddress) {
      debounceSearch(newAddress);
    } else {
      setPredictions([]);
    }
  }, [newAddress, debounceSearch]);

  const debounceSearch = useCallback(
    debounce(address => {
      APIPlaceAutocomplete(address, currentPlace)
        .then(results => {
          setPredictions(results.predictions);
        })
        .catch(e => console.warn(e));
    }, 1000),
    [currentPlace, setPredictions],
  );

+  const renderPredictions = ({item}) => <Prediction {...item} />;

  return (
    <Modal
      ...

        <ModalChildrenView>
          ...
        </ModalChildrenView>
+       <Predictions>
          {predictions.length > 0 && (
            <FlatList
              data={predictions}
              renderItem={renderPredictions}
              keyExtractor={item => item.place_id}
            />
          )}
        </Predictions>
      </Container>
    </Modal>
  );
}
Enter fullscreen mode Exit fullscreen mode

Flatlist with predictions

Uni Tests

It's test time! 😍

We need to add a new tests file for BookingInformation component. On the first test, we will test that the component renders correctly.

Create a new test file src/components/__tests__/BookingInformation.test.js:

import React from 'react';
import {render} from '@testing-library/react-native';
import BookingInformation from '../BookingInformation';
import {PlaceContext} from '../../context/PlacesManager';

describe('<BookingInformation />', () => {
  test('should render correctly when not selected destination', () => {
    const place = {
      currentPlace: {
        description: 'Keillers Park',
        placeId: 'abc',
      },
      destinationPlace: {description: '', placeId: ''},
    };
    const dispatchPlace = jest.fn();
    const {getByTestId, getByText} = render(
      <PlaceContext.Provider value={{place, dispatchPlace}}>
        <BookingInformation />
      </PlaceContext.Provider>,
    );

    expect(getByText('Keillers Park')).toBeDefined();
    expect(getByText('Add destination')).toBeDefined();
    expect(getByTestId('destination-label')).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode

Also let's add another test file for the SearchAddressModal component. Create a new file src/components/__tests__/SearchAddressModal.test.js

import React from 'react';
import {render} from '@testing-library/react-native';
import SearchAddressModal from '../SearchAddressModal';

describe('<SearchAddressModal />', () => {
  test('should render correctly', () => {
    const {getByPlaceholderText, getByTestId, queryByText} = render(
      <SearchAddressModal isModalVisible={true} />,
    );

    expect(getByTestId('back-button')).toBeDefined();
    expect(getByPlaceholderText('Add destination')).toBeDefined();
    expect(getByTestId('clear-button')).toBeDefined();
    expect(queryByText(/Recent/i)).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode

For now, we only test if the component renders correctly. We check for the back button, Add destination placeholder, clear-button, and the Recent places heading.

Now, let's add another test case about 'should render a list of predictions'.

There're a couple of things that we need to test like lodash.debounce code and the fetching for APIPlaceAutocomplete function.

Open src/components/__tests__/SearchAddressModal.test.js:

test('should render a list of predictions', async () => {
    const lodash = require('lodash');
    lodash.debounce = jest.fn(fn => fn);
    jest.useFakeTimers();
    const promise = Promise.resolve();
    const mockSetNewAddress = jest.fn(() => promise);
    const newAddress = 'Domkyrkan';
    const mockCurrentPlace = {
      description: 'Keillers Park',
      placeId: 'abc',
      latitude: 57.7,
      longitude: 11.93,
    };

    render(
      <SearchAddressModal
        newAddress={newAddress}
        setNewAddress={mockSetNewAddress}
        currentPlace={mockCurrentPlace}
      />,
    );
  });
Enter fullscreen mode Exit fullscreen mode

For now, we require lodash and mock lodash.debounce function with jest.fn. We need to use jest fake timers to simulate the waiting time.

When we run the test it will fail because we need to mock fetch function we use inside APIPlaceAutocomplete.

For mocking Fetch we use a new library jest-fetch-mock. Open your terminal and install jest-fetch-mock.

npm install --save-dev jest-fetch-mock
Enter fullscreen mode Exit fullscreen mode

Finish the setup by opening our jest-setup.js configuration file and paste the next line

require('jest-fetch-mock').enableMocks();
Enter fullscreen mode Exit fullscreen mode

Also update the test adding a mock response for fetch.

test('should render a list of predictions', () => {
    const lodash = require('lodash');
    lodash.debounce = jest.fn(fn => fn);
    jest.useFakeTimers();

    const results = {
      predictions: [
        {
          description: 'Domkyrkan',
          place_id: '123',
        },
      ],
    };
    fetch.mockResponseOnce(JSON.stringify(results));

    const promise = Promise.resolve();
    const mockSetNewAddress = jest.fn(() => promise);
    const newAddress = 'Domkyrkan';
    const mockCurrentPlace = {
      description: 'Keillers Park',
      placeId: 'abc',
      latitude: 57.7,
      longitude: 11.93,
    };

    render(
      <SearchAddressModal
        newAddress={newAddress}
        setNewAddress={mockSetNewAddress}
        currentPlace={mockCurrentPlace}
      />,
    );
  });
Enter fullscreen mode Exit fullscreen mode

We mock the fetch by passing an array of predictions. Now, let's fire and event simulating the user typing a new address:

test('should render a list of predictions', () => {
    const lodash = require('lodash');
    lodash.debounce = jest.fn(fn => fn);
    jest.useFakeTimers();
    const results = {
      predictions: [
        {
          description: 'Domkyrkan',
          place_id: '123',
        },
      ],
    };
    fetch.mockResponseOnce(JSON.stringify(results));
    const promise = Promise.resolve();
    const mockSetNewAddress = jest.fn(() => promise);
    const newAddress = 'Domkyrkan';
    const mockCurrentPlace = {
      description: 'Keillers Park',
      placeId: 'abc',
      latitude: 57.7,
      longitude: 11.93,
    };

    const {getByPlaceholderText} = render(
      <SearchAddressModal
        newAddress={newAddress}
        setNewAddress={mockSetNewAddress}
        currentPlace={mockCurrentPlace}
      />,
    );

    fireEvent.changeText(getByPlaceholderText('Add destination'), newAddress);
    expect(mockSetNewAddress).toHaveBeenCalledWith(newAddress);
  });
Enter fullscreen mode Exit fullscreen mode

We select getByPlaceholderText function from the render component and import fireEvent in order to add a new address. Then we assert that the local state, that update the InputText have been called.

Let's finish by adding the rest about loadash.bounce and rendering a list of predictions.

test('should render a list of predictions', async () => {
    const lodash = require('lodash');
    lodash.debounce = jest.fn((fn) => fn);
    const onResponse = jest.fn();
    const onError = jest.fn();
    jest.useFakeTimers();
    const results = {
      predictions: [
        {
          description: 'Domkyrkan',
          place_id: '123',
        },
      ],
    };
    fetch.mockResponseOnce(JSON.stringify(results));
    const promise = Promise.resolve();
    const mockSetNewAddress = jest.fn(() => promise);
    const newAddress = 'Domkyrkan';
    const mockCurrentPlace = {
      description: 'Keillers Park',
      placeId: 'abc',
      latitude: 57.7,
      longitude: 11.93,
    };

    const {getByPlaceholderText, queryByTestId} = render(
      <SearchAddressModal
        newAddress={newAddress}
        setNewAddress={mockSetNewAddress}
        currentPlace={mockCurrentPlace}
        currentSession={currentSession}
        dispatchAuth={dispatchAuth}
      />,
    );

    fireEvent.changeText(getByPlaceholderText('Add destination'), newAddress);
    expect(mockSetNewAddress).toHaveBeenCalledWith(newAddress);

    lodash.debounce(
      APIPlaceAutocomplete(newAddress, mockCurrentPlace)
        .then(onResponse)
        .catch(onError)
        .finally(() => {
          expect(onResponse).toHaveBeenCalled();
          expect(onError).not.toHaveBeenCalled();

          expect(onResponse.mock.calls[0][0][0]).toEqual(results);
        }),
      1000,
    );

    expect(queryByTestId('prediction-row-0')).toBeNull();

    await act(() => promise);
    queryByTestId('prediction-row-0');
  });
Enter fullscreen mode Exit fullscreen mode

Don't forget to import APIPlaceAutocomplete import {APIPlaceAutocomplete} from '../../utils'; and act function from testing-library.

See how we async/await for a promise to be resolved so we can see a list of predictions using await act(() => promise);

🛑 Stop!

That's it from me for now. I hope you're doing good by now and learning a thing or two. We'll continue working on the Booking Information component in the next post.

Stay tuned!

Discussion (0)