[testdriven.io] Developing a Real-Time Taxi App with Django Channels and React - pt 4

HTTP Requests

Part 2, Chapter 7

At this point, we have two views designed to gather information from users for authentication. Users register for a new account using the “Sign Up” view and they log into an existing account using the “Log In” view. Each of our views is like a cell phone without a carrier. You can type something on the screen, but nothing receives anything from it or sends anything to it. In this chapter, we’re going to connect our interface to the “world” by wiring it up to the server. Once connected, users will be able to manipulate data on the server, which other app users can see.

Log In

The first aspect of authentication we want to handle is logging in. Given that the user has a valid active account, she should be able to enter her credentials and gain access to it. What should happen to the user interface when the user logs in? For one, she should be able to read, create, update, and delete her personal data. But we’re not there yet. For now, she should be able to log out.

Let’s create a test to prove that a user is logged in by finding a “Log out” button. Open the authentication.spec.js Cypress test file and edit the login test to look for a “Log out” button. It’s presence will confirm that the user is authenticated.

// cypress/integration/authentication.spec.js

it('Can log in.', function () {
  cy.visit('/#/log-in');
  cy.get('input#username').type('gary.cole@example.com');
  cy.get('input#password').type('pAssw0rd', { log: false });
  cy.get('button').contains('Log in').click();
  cy.hash().should('eq', '#/');

  // new
  cy.get('button').contains('Log out');
});

If you run this test right now, it will fail since we haven’t set up our app to include a “Log out” button. Next, let’s add a couple elements to our main App.js file. We’ll add a hook to set a variable that indicates whether a user is logged into the app. We’ll also create the shell of a logIn() method. We’ll modify the navigation bar to conditionally include a “Log out” button when the user is authenticated. Lastly, we’ll pass our logIn() method to our LogIn component so it can use it to set the logged in state for the entire app.

// src/App.js

import React, { useState } from 'react'; // changed
import {
  Button, Container, Form, Navbar
} from 'react-bootstrap'; // changed
import { LinkContainer } from 'react-router-bootstrap';
import { Link, Route, Switch } from 'react-router-dom';

import SignUp from './components/SignUp';
import LogIn from './components/LogIn';

import './App.css';

function App () {
  // new
  const [isLoggedIn, setLoggedIn] = useState(false);

  // new
  const logIn = (username, password) => setLoggedIn(true);

  return (
    <div>
      <Navbar bg='light' expand='lg' variant='light'>
        <LinkContainer to='/'>
          <Navbar.Brand className='logo'>Taxi</Navbar.Brand>
        </LinkContainer>
        <Navbar.Toggle />
        <Navbar.Collapse>
          {/* new */}
          {
            isLoggedIn &&
            <Form inline className='ml-auto'>
              <Button type='button'>Log out</Button>
            </Form>
          }
        </Navbar.Collapse>
      </Navbar>
      <Container className='pt-3'>
        <Switch>
          <Route exact path='/' render={() => (
            <div className='middle-center'>
              <h1 className='landing logo'>Taxi</h1>
              <Link className='btn btn-primary' to='/sign-up'>Sign up</Link>
              <Link className='btn btn-primary' to='/log-in'>Log in</Link>
            </div>
          )} />
          <Route path='/sign-up' component={SignUp} />
          {/* changed */}
          <Route path='/log-in' render={() => (
            <LogIn logIn={logIn} />
          )} />
        </Switch>
      </Container>
    </div>
  );
}

export default App;

Initially, our logIn() method just sets the isLoggedIn variable to true . We’ll add the real functionality to this function later in the chapter, but for now let’s just confirm that the “Log out” button appears properly.

We need to hook up the logIn() method to the LogIn component form before our test will pass. Edit the onSubmit() method in the components/LogIn.js file to call logIn() when the user submits his credentials in the form.

// src/components/LogIn.js

const onSubmit = (values, actions) => {
  props.logIn(values.username, values.password);
  setSubmitted(true);
};

Run the Cypress tests. The login test should be working now.

Happy Path

Remember, all we did here was stub the logIn() function to return the result we wanted. Let’s take it one step further. Let’s make an API call to the server.

React doesn’t come with a native HTTP service, so we have to install a third-party dependency. Axios is a popular choice in the React community and it’s the one we’ll be using.

Install Axios.

$ npm install axios --save

Before we continue, we need to add protection against Cross-Site Request Forgeries. Let’s add the following code to the src/index.js file to ensure that every HTTP request we make includes the proper headers. The required header values are defined in the Django documentation.

// src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import 'bootswatch/dist/lumen/bootstrap.css';
import axios from 'axios'; // new
import { HashRouter } from 'react-router-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

axios.defaults.xsrfCookieName = 'csrftoken'; // new
axios.defaults.xsrfHeaderName = 'X-CSRFToken'; // new

ReactDOM.render(
  <HashRouter>
    <App />
  </HashRouter>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Next, let’s change our login test to actually make an HTTP request to the server. Since we’re doing this in baby steps, let’s mock the server API to return the data we expect the real server to return.

Make the following changes to the login test.

// cypress/integration/authentication.spec.js

it('Can log in.', function () {
  // new
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/log_in/**',
    status: 200,
    response: {
      'access': 'ACCESS_TOKEN',
      'refresh': 'REFRESH_TOKEN'
    }
  }).as('logIn');

  cy.visit('/#/log-in');
  cy.get('input#username').type('gary.cole@example.com');
  cy.get('input#password').type('pAssw0rd', { log: false });
  cy.get('button').contains('Log in').click();

  // new
  cy.wait('@logIn');

  cy.hash().should('eq', '#/');
  cy.get('button').contains('Log out');
});

Cypress’s server() method captures requests to the server, while the route() method can used to either stub server calls or actually pass them to the server. In this case, we’re capturing any POST requests to the /api/log_in/ endpoint and returning a fake user’s data to the client. After we click the “Log in” button, we wait for our HTTP call to finish before checking our assertions. Nothing else in our test needs to change. We still expect to see the “Log out” button appear when the user logs into the application successfully.

We need to make a couple of changes to the App.js code we added in the previous step. First, how can the client tell whether the user is already authenticated? Remember in Part 1 we elected to use JSON Web Token authentication, which works by issuing an access token in exchange for a user’s credentials. That access token must be sent back to the server in all subsequent requests, and it will verify the user’s identity until the token expires.

The access token includes a Base 64 encoded data payload, which in our case contains the JSON-serialized user data. Since we need to use both the token and the user’s information frequently during the user’s session, we need to store the data in the browser. When the server returns the access token on login, we can store that data locally using the browser’s localStorage API.

Edit the App.js file to change the hook and the logIn() function.

// src/App.js

import axios from 'axios';

const [isLoggedIn, setLoggedIn] = useState(() => { // changed
  return window.localStorage.getItem('taxi.auth') !== null;
});

const logIn = async (username, password) => { // changed
  const url = '/api/log_in/';
  try {
    const response = await axios.post(url, { username, password });
    window.localStorage.setItem(
      'taxi.auth', JSON.stringify(response.data)
    );
    setLoggedIn(true);
  }
  catch (error) {
    console.error(error);
  }
};

We modified our hook to determine the value of isLoggedIn based on whether the window.localStorage object contained an authenticated data resource. If the data is present, we know the user is logged in. If it is missing, then the user is not authenticated.

We also changed the logIn() function to make an HTTP request. Our edits made logIn() asynchronous – it runs asynchronously on the event loop and uses an implicit Promise to return its result. The await keyword pauses the function until the Promise is resolved or rejected. In our case, the logIn() function pauses until the the Axios HTTP client receives a response from the server. If the call succeeds, then we store the response data in window.localStorage .

Since logIn() is asynchronous, the LogIn component’s onSubmit() function must wait for it to resolve before it can resolve itself, so we make it asynchronous as well.

// src/components/LogIn.js

// changed
const onSubmit = async (values, actions) => {
  try {
    await props.logIn(values.username, values.password);
    setSubmitted(true);
  }
  catch (error) {
    console.error(error);
  }
};

Here’s something you might have guessed we needed to handle. A logged-in user shouldn’t be able to visit the “Log In” page while he is authenticated. He needs to log out first. Let’s write a test to prove this.

// cypress/integration/authentication.js

it('Cannot visit the login page when logged in.', function () {
  const { username, password } = Cypress.env('credentials');

  // Log in.
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/log_in/**',
    status: 200,
    response: {
      'access': 'ACCESS_TOKEN',
      'refresh': 'REFRESH_TOKEN'
    }
  }).as('logIn');
  cy.visit('/#/log-in')
  cy.get('input#username').type(username)
  cy.get('input#password').type(password, { log: false })
  cy.get('button').contains('Log in').click()
  cy.hash().should('eq', '#/')
  cy.get('button').contains('Log out')
  cy.wait('@logIn')

  cy.visit('/#/log-in');
  cy.hash().should('eq', '#/');
});

This test stubs the log_in API to return an OK response with some fake data to simulate a successful login event. Then, the test client attempts to visit the “Log In” page, but it should be redirected back to the home page.

We’re using the Cypress.env() API to access environment variables that are scoped to the current test spec file. We need to define these values in the cypress.json file.

// cypress.json

{
  "baseUrl": "http://localhost:3000",
  "env": {
    "credentials": {
      "username": "gary.cole@example.com",
      "password": "pAssw0rd"
    }
  }
}

Visiting the “Log In” page should redirect the user home. We can achieve this behavior with a simple adjustment. Let’s check whether the user is logged in and then either redirect him home if he is or render the LogIn component if he is not. Change the /log-in route to the following.

// src/App.js

<Route path='/log-in' render={() => (
  isLoggedIn ? (
    <Redirect to='/' />
  ) : (
    <LogIn logIn={logIn} />
  )
)} />

Now that we’ve made this change, we need to refactor the LogIn component too.

// src/components/LogIn.js

import React from 'react'; // changed
import { Formik } from 'formik';
import {
  Breadcrumb, Button, Card, Col, Form, Row
} from 'react-bootstrap';
import { Link } from 'react-router-dom'; // changed

function LogIn (props) {
  // changed
  const onSubmit = async (values, actions) => {
    try {
      await props.logIn(values.username, values.password);
    }
    catch (error) {
      console.error(error);
    }
  };

  return (
    <Row>
      <Col lg={12}>
        <Breadcrumb>
          <Breadcrumb.Item href='/'>Home</Breadcrumb.Item>
          <Breadcrumb.Item active>Log in</Breadcrumb.Item>
        </Breadcrumb>
        <Card>
          <Card.Header>Log in</Card.Header>
          <Card.Body>
            <Formik
              initialValues={{
                username: '',
                password: ''
              }}
              onSubmit={onSubmit}
              render={({
                handleChange,
                handleSubmit,
                values
              }) => (
                {/* hidden for clarity */}
              )}
            />
          </Card.Body>
          <p className='mt-3 text-center'>
            Don't have an account? <Link to='/sign-up'>Sign up!</Link>
          </p>
        </Card>
      </Col>
    </Row>
  );
}

export default LogIn;

You should notice a couple things here. First, we removed the redirect. That’s handled by the App component now, so we don’t need it anymore. With the redirect gone, we don’t need the isSubmitted state anymore either. In fact, the presence of this state was causing an issue before. Because we were redirecting from within LogIn , the LogIn component was being unmounted before the asynchronous function had a chance to update App 's state. We were getting an error message about a memory leak in the console. Removing the redirect and companion code fixes that issue.

An authenticated user has no business signing up for a new account either.

// cypress/integration/authentication.js

it('Cannot visit the sign up page when logged in.', function () {
  const { username, password } = Cypress.env('credentials');
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/log_in/**',
    status: 200,
    response: {
      'access': 'ACCESS_TOKEN',
      'refresh': 'REFRESH_TOKEN'
    }
  }).as('logIn');
  cy.visit('/#/log-in')
  cy.get('input#username').type(username)
  cy.get('input#password').type(password, { log: false })
  cy.get('button').contains('Log in').click()
  cy.hash().should('eq', '#/')
  cy.get('button').contains('Log out')
  cy.wait('@logIn')

  cy.visit('/#/sign-up');
  cy.hash().should('eq', '#/');
});

Make a similar change to the /sign-up route in the same file.

// src/App.js

<Route path='/sign-up' render={() => (
  isLoggedIn ? (
    <Redirect to='/' />
  ) : (
    <SignUp />
  )
)} />

Don’t forget to add the Redirect import at the top of the page.

Users who are logged in also shouldn’t be given links to the Log In and Sign Up pages.

// cypress/integration/authentication.js

it('Cannot see links when logged in.', function () {
  const { username, password } = Cypress.env('credentials');
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/log_in/**',
    status: 200,
    response: {
      'access': 'ACCESS_TOKEN',
      'refresh': 'REFRESH_TOKEN'
    }
  }).as('logIn');
  cy.visit('/#/log-in')
  cy.get('input#username').type(username)
  cy.get('input#password').type(password, { log: false })
  cy.get('button').contains('Log in').click()
  cy.hash().should('eq', '#/')
  cy.get('button').contains('Log out')
  cy.wait('@logIn')

  cy.get('button#signUp').should('not.exist');
  cy.get('button#logIn').should('not.exist');
});
// src/App.js

<Route exact path='/' render={() => (
  <div className='middle-center'>
    <h1 className='landing logo'>Taxi</h1>
    {
      !isLoggedIn &&
      <Link
        id='signUp'
        className='btn btn-primary'
        to='/sign-up'
      >Sign up</Link>
    }
    {
      !isLoggedIn &&
      <Link
        id='logIn'
        className='btn btn-primary'
        to='/log-in'
      >Log in</Link>
    }
  </div>
)} />

A Quick Refactoring

You’ve probably noticed that we’ve reused the same login code over and again throughout our tests. Let’s refactor that login code into a reusable function.

Add the following function to the top of the authentication.spec.js file:

// integration/authentication.spec.js

const logIn = () => {
  const { username, password } = Cypress.env('credentials');

  // Capture HTTP requests.
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/log_in/**',
    status: 200,
    response: {
      'access': 'ACCESS_TOKEN',
      'refresh': 'REFRESH_TOKEN'
    }
  }).as('logIn');

  // Log into the app.
  cy.visit('/#/log-in');
  cy.get('input#username').type(username);
  cy.get('input#password').type(password, { log: false });
  cy.get('button').contains('Log in').click();
  cy.wait('@logIn');
};

Next, refactor the tests to use the new logIn() function.

// integration/authentication.spec.js

const logIn = () => {
  // Collapsed for clarity.
};

describe('Authentication', function () {
  it('Can log in.', function () {
    logIn();
    cy.hash().should('eq', '#/');
  });

  // not changed.
  it('Can sign up.', function () {
    // Hidden for clarity.
  });

  it('Cannot visit the login page when logged in.', function () {
    logIn();
    cy.visit('/#/log-in');
    cy.hash().should('eq', '#/');
  });

  it('Cannot visit the sign up page when logged in.', function () {
    logIn();
    cy.visit('/#/sign-up');
    cy.hash().should('eq', '#/');
  });

  it('Cannot see links when logged in.', function () {
    logIn();
    cy.get('button#signUp').should('not.exist');
    cy.get('button#logIn').should('not.exist');
  });
});

Run all of the tests to confirm that they are passing.

Server Errors

We programmed how to handle the happy path in our LogIn component. But what happens if the server returns an error? In most cases, the error will be caused by the user. For example, he may enter incorrect credentials. We want to alert the user in this case and ask him to try again.

Let’s set up a scenario to test a client error. Add the following test to the cypress/integration/authentication.spec.js file.

// cypress/integration/authentication.spec.js

it('Shows an alert on login error.', function () {
  const { username, password } = Cypress.env('credentials');
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/log_in/**',
    status: 400,
    response: {
      '__all__': [
        'Please enter a correct username and password. ' +
        'Note that both fields may be case-sensitive.'
      ]
    }
  }).as('logIn');
  cy.visit('/#/log-in');
  cy.get('input#username').type(username);
  cy.get('input#password').type(password, { log: false });
  cy.get('button').contains('Log in').click();
  cy.wait('@logIn');
  cy.get('div.alert').contains(
    'Please enter a correct username and password. ' +
    'Note that both fields may be case-sensitive.'
  );
  cy.hash().should('eq', '#/log-in');
});

Run the test and watch it fail.

Let’s write the code to get the test to pass.

// src/components/LogIn.js

// changed
const onSubmit = async (values, actions) => {
  try {
    const { response, isError } = await props.logIn(
      values.username,
      values.password
    );
    if (isError) {
      const data = response.response.data;
      for (const value in data) {
        actions.setFieldError(value, data[value].join(' '));
      }
    }
  }
  catch (error) {
    console.error(error);
  }
  finally {
    actions.setSubmitting(false);
  }
}

Change the logIn() function in App.js too.

// src/App.js

const logIn = async (username, password) => {
  const url = '/api/log_in/';
  try {
    const response = await axios.post(url, { username, password });
    window.localStorage.setItem(
      'taxi.auth', JSON.stringify(response.data)
    );
    setLoggedIn(true);
    // new
    return { response, isError: false };
  }
  catch (error) {
    console.error(error);
    // new
    return { response: error, isError: true };
  }
};

When the user submits the form, the submit() function will invoke the logIn() function to send the credentials to the server. If the call fails, then the form will display the errors.

Note

Instead of using props.logIn() , we can reference logIn() directly by destructuring the props object into the keys we expect to receive.

// old
function LogIn(props) {...}

// new
function LogIn({ logIn }) {...}

Lastly, we need to update our Formik component to display the errors correctly. Any errors that the server returns to us with the __all__ key are not related to a particular field but are related to the data as a whole. Given that information, we’re going to display general errors at the top of the page using a Bootstrap Alert component.

// src/components/LogIn.js

<Formik
  initialValues={{
    username: '',
    password: ''
  }}
  onSubmit={onSubmit}
>
  {({
    errors, // new
    handleChange,
    handleSubmit,
    isSubmitting, // new
    values
  }) => (
    {/* new */}
    <>
      {
        '__all__' in errors &&
        <Alert variant='danger'>
          { errors['__all__'] }
        </Alert>
      }
      <Form noValidate onSubmit={handleSubmit}>
        {/* contents hidden for clarity */}
        <Button
          block
          disabled={isSubmitting}
          type='submit'
          variant='primary'
        >Log in</Button>
      </Form>
    </>
  )}
</Formik>

In addition to the Alert , we’re also updating the submit button to disable itself until the form submission Ajax request completes.

Don’t forget to add the Alert import to the top of the page.

// src/components/LogIn.js

import {
  Alert, Breadcrumb, Button, Card, Col, Form, Row // changed
} from '

With these changes in place, our tests should pass.

Log Out

Here’s what should happen in our client when an authenticated user logs out:

  • The “Log out” button should disappear.
  • The user’s data should be removed from LocalStorage .
  • The user should regain access to the Login Page .

Create a new failing test.

// cypress/integration/authentication.spec.js

it('Can log out.', function () {
  logIn();
  cy.get('button').contains('Log out').click().should(() => {
    expect(window.localStorage.getItem('taxi.auth')).to.be.null;
  });
  cy.get('button').contains('Log out').should('not.exist');
});

In this test, we add mock data to LocalStorage to make the “Log out” button appear on the screen. After confirming that the “Log out” button is on the page, we click it, and then we check whether the authentication data has been removed from LocalStorage . Lastly, we confirm that the “Log out” button has been removed from the page.

Let’s add a logOut() function and a “Log Out” button to our App component.

// src/App.js

const logOut = () => {
  window.localStorage.removeItem('taxi.auth');
  setLoggedIn(false);
};

{/* changed */}
<Navbar.Collapse>
  {
    isLoggedIn && (
      <Form inline className='ml-auto'>
        <Button type='button' onClick={() => logOut()}>Log out</Button>
      </Form>
    )
  }
</Navbar.Collapse>

With the code in place, clicking the “Log out” button will trigger to call to the server.

Our logout test should be passing.

Sign Up

Our last step is to implement the HTTP client in our sign up workflow. Modify the sign up test in the authentication test file like the following.

// cypress/integration/authentication.js

it('Can sign up.', function () {
  // new
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/sign_up/**',
    status: 201,
    response: {
      'id': 1,
      'username': 'gary.cole@example.com',
      'first_name': 'Gary',
      'last_name': 'Cole',
      'group': 'driver',
      'photo': '/media/images/photo.jpg'
    }
  }).as('signUp');

  cy.visit('/#/sign-up');
  cy.get('input#username').type('gary.cole@example.com');
  cy.get('input#firstName').type('Gary');
  cy.get('input#lastName').type('Cole');
  cy.get('input#password').type('pAssw0rd', { log: false });
  cy.get('select#group').select('driver');

  // Handle file upload
  cy.fixture('images/photo.jpg').then(photo => {
    cy.get('input#photo').upload({
      fileContent: photo,
      fileName: 'photo.jpg',
      mimeType: 'application/json'
    });
  });

  cy.get('button').contains('Sign up').click();
  cy.wait('@signUp'); // new
  cy.hash().should('eq', '#/log-in');
});

Our code changes stub the server to return mock data. After we click the “Sign up” button, we wait for the HTTP call to resolve. And we confirm that the user is redirected to the Login Page .

Let’s alter the onSubmit() function in our SignUp component to call the server.

// src/components/SignUp.js

const onSubmit = async (values, actions) => {
  const url = '/api/sign_up/';
  const formData = new FormData();
  formData.append('username', values.username);
  formData.append('first_name', values.firstName);
  formData.append('last_name', values.lastName);
  formData.append('password1', values.password);
  formData.append('password2', values.password);
  formData.append('group', values.group);
  formData.append('photo', values.photo);
  try {
    await axios.post(url, formData);
    setSubmitted(true);
  }
  catch (response) {
    const data = response.response.data;
    for (const value in data) {
      actions.setFieldError(value, data[value].join(' '));
    }
  }
  finally {
    actions.setSubmitting(false);
  }
};

Add the axios import at the top of the file too:

import axios from 'axios';

Unlike with our logIn() function, the App component’s state does not rely on a user signing up, so we can handle signing up directly in an onSubmit() function on our SignUp component.

This HTTP call looks a bit different than the login and logout calls. Since we’re sending a photo in our request, we need to use the FormData JavaScript interface, which uses the same format a form would use if the encoding type were set to multipart/form-data .

Check your tests and confirm that they’re all passing.

Server Errors

We’re going to follow a similar list of steps to handle sign up errors as we did for our login.

Let’s begin by writing a test for handling sign up errors.

// cypress/integration/authentication.spec.js

it('Show invalid fields on sign up error.', function () {
  cy.server();
  cy.route({
    method: 'POST',
    url: '**/api/sign_up/**',
    status: 400,
    response: {
      'username': [
        'A user with that username already exists.'
      ]
    }
  }).as('signUp');
  cy.visit('/#/sign-up');
  cy.get('input#username').type('gary.cole@example.com');
  cy.get('input#firstName').type('Gary');
  cy.get('input#lastName').type('Cole');
  cy.get('input#password').type('pAssw0rd', { log: false });
  cy.get('select#group').select('driver');

  // Handle file upload
  cy.fixture('images/photo.jpg').then(photo => {
    cy.get('input#photo').upload({
      fileContent: photo,
      fileName: 'photo.jpg',
      mimeType: 'application/json'
    });
  });
  cy.get('button').contains('Sign up').click();
  cy.wait('@signUp');
  cy.get('div.invalid-feedback').contains(
    'A user with that username already exists'
  );
  cy.hash().should('eq', '#/sign-up');
});

We capture outgoing client requests to the /api/sign_up/ API endpoint and return a mock client error.

Unlike our login form, our sign up form will have errors relating to specific fields. Let’s prepare our form to display those errors by first updating our Formik component.

// src/components/SignUp.js

<Formik
  initialValues={{
    username: '',
    firstName: '',
    lastName: '',
    password: '',
    group: 'rider',
    photo: []
  }}
  onSubmit={onSubmit}
>
  {({
    errors, // new
    handleChange,
    handleSubmit,
    isSubmitting, // new
    setFieldValue, // new
    values
  }) => (
    {/* contents hidden for clarity */}
  )}
</Formik>

We only want to show an error if we receive an error from the server. Let’s start by editing the code for the username field to get a hang of the changes we need to make. Then we can apply the same change to the rest of the fields.

// src/components/SignUp.js

<Form.Group controlId='username'>
  <Form.Label>Username:</Form.Label>
  {/* changed */}
  <Form.Control
    className={ 'username' in errors ? 'is-invalid' : '' }
    name='username'
    onChange={handleChange}
    values={values.username}
    required
  />
  {/* new */}
  {
    'username' in errors &&
    <Form.Control.Feedback type='invalid'>{ errors.username }</Form.Control.Feedback>
  }
</Form.Group>

Note that we need to update the Bootstrap form control class to display a red border for an error. Also, we need to display feedback text under the form control with the error message.

Let’s apply the same change to the rest of the form controls in the sign up form.

// src/components/SignUp.js

<Form.Group controlId='firstName'>
  <Form.Label>First name:</Form.Label>
  <Form.Control
    className={ 'firstName' in errors ? 'is-invalid' : '' }
    name='firstName'
    onChange={handleChange}
    values={values.firstName}
  />
  {
    'firstName' in errors &&
    <Form.Control.Feedback type='invalid'>{ errors.firstName }</Form.Control.Feedback>
  }
</Form.Group>
<Form.Group controlId='lastName'>
  <Form.Label>Last name:</Form.Label>
  <Form.Control
    className={ 'lastName' in errors ? 'is-invalid' : '' }
    name='lastName'
    onChange={handleChange}
    values={values.lastName}
  />
  {
    'lastName' in errors &&
    <Form.Control.Feedback type='invalid'>{ errors.lastName }</Form.Control.Feedback>
  }
</Form.Group>
<Form.Group controlId='password'>
  <Form.Label>Password:</Form.Label>
  <Form.Control
    className={ 'password' in errors ? 'is-invalid' : '' }
    name='password'
    onChange={handleChange}
    type='password'
    value={values.password}
  />
  {
    'password' in errors &&
    <Form.Control.Feedback type='invalid'>{ errors.password }</Form.Control.Feedback>
  }
</Form.Group>
<Form.Group controlId='group'>
  <Form.Label>Group:</Form.Label>
  <Form.Control
    as='select'
    className={ 'group' in errors ? 'is-invalid' : '' }
    name='group'
    onChange={handleChange}
    value={values.group}
  >
    <option value='rider'>Rider</option>
    <option value='driver'>Driver</option>
  </Form.Control>
  {
    'group' in errors &&
    <Form.Control.Feedback type='invalid'>{ errors.group }</Form.Control.Feedback>
  }
</Form.Group>
<Form.Group controlId='photo'>
  <Form.Label>Photo:</Form.Label>
  <Form.Control
    className={ 'photo' in errors ? 'is-invalid' : '' }
    name='photo'
    onChange={event => {
      setFieldValue('photo', event.currentTarget.files[0]);
    }}
    type='file'
  />
  {
    'photo' in errors &&
    <Form.Control.Feedback type='invalid'>{ errors.photo }</Form.Control.Feedback>
  }
</Form.Group>

Run the Cypress tests one more time to see them working.