Handling User Inputs and Forms page

Use forms and inputs to collect information.

Overview

In this section, we will:

  • Learn about controlled vs. uncontrolled inputs
  • Work with change events
  • Use TypeScript’s Record interface
  • Set state using an updater function
  • Updating reference types and rendering
  • Learn about the useId() Hook

Objective 1: Add checkboxes to order menu items

Now that we have our RestaurantOrder page, let’s start building the form for placing an order!

We’ll start with checkboxes to select menu items, with a message that warns users when no items are selected and a total that shows the sum of the selected items.

Controlled vs. uncontrolled inputs

React has special handling for <input> components that allow developers to create “controlled” or “uncontrolled” inputs. An input is uncontrolled when its value (or checked) prop is not set, and it does not have a handler set for the onChange prop; an initial value may be set using the defaultValue prop.

An input is controlled when both its value (or checked) and onChange props have values set. The term “controlled” refers to the fact that the value of the input is controlled by React. Most of the time, we want our <input> components to be controlled with their value stored in a state variable.

Note: If an <input> only has the value or onChange prop set, React will log a warning to the console in development mode.

Let’s take a look at an example of a controlled input:

const ControlledInput: React.FC = () => {
  const [name, setName] = useState("");
  return (
    <label>
      Name:
      <input
        onChange={(event) => setName(event.target.value)}
        type="text"
        value={name}
      />
    </label>
  )
}

Controlled components aren’t allowed to have a value of null or undefined. To set an input with “no value,” use an empty string: "".

Working with change events

In the previous example, the <input> prop onChange had its value set to a function known as an “event handler.”

const ControlledInput: React.FC = () => {
  const [name, setName] = useState("");
  return (
    <label>
      Name:
      <input
        onChange={(event) => setName(event.target.value)}
        type="text"
        value={name}
      />
    </label>
  )
}

When an event occurs, such as a user typing in an input field, an event object is passed to the event handler function. This event object contains various properties and methods that provide information about the event.

One such property is target, which is a reference to the DOM element that triggered the event. In the case of an input field, target would refer to the specific <input> element where the user is typing.

The property event.target.value is particularly important when dealing with input fields. The value property here holds the current content of the input field. It represents what the user has entered or selected. When you access event.target.value in your event handler function, you’re essentially retrieving the latest input provided by the user. This is commonly used to update the state of the component with the new input value, ensuring that the component’s state is in sync with what the user is entering.

For most input types, you’ll want to use event.target.value to get the value entered. But there are exceptions! For <input type="checkbox">, you’ll want to use event.target.checked instead:

const TodoItem: React.FC = () => {
  const [isCompleted, setIsCompleted] = useState(false)
  return (
    <label>
      <input
        checked={isCompleted}
        onChange={(event) => setIsCompleted(event.target.checked)}
        type="checkbox"
      />
      Completed
    </label>
  )
}

Using TypeScript’s Record interface

In our upcoming exercise, we want to store information in a JavaScript object. We also want to use TypeScript so we can constrain the types used as keys and values.

TypeScript provides a handy interface named Record that we can use. Record is a generic interface that requires two types: the first is the type of the keys, and the second is the type of the values.

For example, if we’re recording the items in a list that are selected, we might capture the item’s name and whether or not it’s selected like this:

import React, { useState } from 'react';

const landmarks = [
    { id: '0b90c705', name: 'Eiffel Tower' },
    { id: '5be758c1', name: 'Machu Picchu' },
    { id: '206025c3', name: 'Taj Mahal' },
]

type SelectedItems = Record<string, number>

const Selected: React.FC = () => {
    const [selected, setSelected] = useState<SelectedItems>({});

    function handleChange(name: string, isSelected: boolean) {
        setSelected((current) => ({ ...current, [name]: isSelected }))
    }

    return (
        <form>{
            landmarks.map((landmark) => {
                return (
                    <label key={landmark.id}>{landmark.name}:
                        <input
                            checked={selected[landmark.name]}
                            onChange={(event) => handleChange(landmark.name, event.target.checked)}
                            type="checkbox"
                        />
                    </label>
                )
            })
        }</form>
    )
}

We’ve explicitly defined the type of useState as a Record<string, boolean>; all the keys must be strings, and all the values must be booleans. Fortunately, JavaScript’s object implements the Record interface, so we can set the default value to an empty object instance. Now let’s see how we can use a Record to store state data.

Setting state using an updater function

One challenge we face when using an object for state is that we probably need to merge the current state value with the new state value. Why? Imagine we have a state object that already has multiple keys and values, and we need to add a new key and value.

Well, we’re in luck! React already has a solution for this: the setter function returned by useState will accept an “updater function” that’s passed the “current” state value and should return the “next” state value.

import React, { useState } from 'react';

const landmarks = [
  { id: '0b90c705', name: 'Eiffel Tower' },
  { id: '5be758c1', name: 'Machu Picchu' },
  { id: '206025c3', name: 'Taj Mahal' },
]

type SelectedItems = Record<string, number>

const Selected: React.FC = () => {
  const [selected, setSelected] = useState<SelectedItems>({});

  function handleSelectedChange(name: string, isSelected: boolean) {
    setSelected((currentSelectedItems) => {
      const updatedSelectedItems = {
        ...currentSelectedItems,
      }

      if (isSelected) {
        updatedSelectedItems[name] = true
      } else {
        delete updatedSelectedItems[name]
      }

      return updatedSelectedItems
    })
  }

  return (
    <form>{
      landmarks.map((landmark) => {
        return (
          <label key={landmark.id}>
            {landmark.name}:
            <input
              checked={selected[landmark.name]}
              onChange={(event) => handleSelectedChange(landmark.name, event.target.checked)}
              type="checkbox"
            />
          </label>
        )
      })
    }</form>
  )
}

In the example above, the onChange event handler calls handleSelectedChange, which accepts a name string and a boolean.

In turn, handleSelectedChange calls setSelected with an updater function as the argument. The updater function accepts the currentSelectedItems argument, which is the object with the currently-selected items before our checkbox was checked.

We will dig into how we create the updatedSelectedItems object in just a bit, but for now let’s take note that we create a new updatedSelectedItems object and return it from our updater function. This gives React the updated selected state and allows React to re-render the component.

Updating reference types and rendering

Now let’s explain how the updater function works in the example above. The updater function does not mutate the current object, then return it; instead, it makes a new object and populates it with the contents of the current object.

This is an important detail because, after the updater function runs, React will compare the values of the current and next objects to determine if they are different. If they are different, React will re-render the Selected component; if they are the same, then React will do nothing.

The same rules apply when state is an array: create a new array, then update the contents of the new array.

// Adding an item when state (`current`) is an array.
setSelectedOrders(current => {
  const next = [...current, newOrder];
  return next;
});

// Replacing an item when state (`current`) is an array.
setUpdatedRestaurant(current => {
  const next = [
    ...current.filter(item => item.id !== updatedRestaurant.id),
    updatedRestaurant
  ];
  return next;
});

OK, that was a lot. Let’s start making some code changes so we can select menu items for an order.

Setup 1

✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:

import { useState } from "react"
import { useParams } from "react-router-dom"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"

type OrderItems = Record<string, number>

const RestaurantOrder: React.FC = () => {
    const params = useParams() as { slug: string }

    const restaurant = useRestaurant(params.slug)

    if (restaurant.isPending) {
        return (
            <p aria-live="polite" className="loading">
                Loading restaurant…
            </p>
        )
    }

    if (restaurant.error) {
        return (
            <p aria-live="polite" className="error">
                Error loading restaurant: {restaurant.error.message}
            </p>
        )
    }

    if (!restaurant.data) {
        return <p aria-live="polite">No restaurant found.</p>;
    }

    const subtotal = 0 // Use calculateTotal here.

    return (
        <>
            <RestaurantHeader restaurant={restaurant.data} />

            <div className="order-form">
                <h3>Order from {restaurant.data.name}!</h3>

                <form>
                    {subtotal === 0 && (
                        <p className="info text-error">Please choose an item.</p>
                    )}

                    <h4>Lunch Menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.lunch.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <h4>Dinner menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.dinner.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <div className="submit">
                        <h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
                    </div>
                </form>
            </div>
        </>
    )
}

function calculateTotal(items: OrderItems) {
    return Object.values(items).reduce((total, itemPrice) => {
        return total + itemPrice
    }, 0)
}

export default RestaurantOrder

Verify 1

These tests will pass when the solution has been implemented properly.

✏️ Update src/pages/RestaurantOrder/RestaurantOrder.test.tsx to be:

import type { ReactNode } from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import RestaurantOrder from './RestaurantOrder';

// Mock the hooks and components used in RestaurantOrder
vi.mock('../../services/restaurant/hooks', () => ({
  useRestaurant: vi.fn()
}));

vi.mock('../../components/RestaurantHeader', () => ({
  default: vi.fn(() => (
    <div data-testid="mock-restaurant-header">
      Mock RestaurantHeader
    </div>
  ))
}));

import { useRestaurant } from '../../services/restaurant/hooks';

const mockRestaurantData = {
  data: {
    _id: '1',
    name: 'Test Restaurant',
    slug: 'test-restaurant',
    images: { owner: 'owner.jpg' },
    menu: {
      lunch: [
        { name: 'Lunch Item 1', price: 10 },
        { name: 'Lunch Item 2', price: 15 }
      ],
      dinner: [
        { name: 'Dinner Item 1', price: 20 },
        { name: 'Dinner Item 2', price: 25 }
      ]
    },
  },
  isPending: false,
  error: null
};

const renderWithRouter = (ui: ReactNode, { route = '/restaurants/test-restaurant' } = {}) => {
  window.history.pushState({}, 'Test page', route)
  return render(
    ui,
    { wrapper: ({ children }) => <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter> }
  );
};

describe('RestaurantOrder component', () => {
  it('renders loading state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: true, error: null });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/Loading restaurant…/i)).toBeInTheDocument();
  });

  it('renders error state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading' } });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/Error loading restaurant/i)).toBeInTheDocument();
  });

  it('renders no restaurant found state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: null });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/No restaurant found/i)).toBeInTheDocument();
  });

  it('renders the RestaurantHeader when data is available', () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    renderWithRouter(<RestaurantOrder />);

    expect(screen.getByTestId('mock-restaurant-header')).toBeInTheDocument();
  });

  it('renders the order form when restaurant data is available', () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    render(<RestaurantOrder />);

    expect(screen.getByTestId('mock-restaurant-header')).toBeInTheDocument();
    expect(screen.getByText('Order from Test Restaurant!')).toBeInTheDocument();
    expect(screen.getAllByRole('checkbox').length).toBe(4); // 2 lunch + 2 dinner items
  });

  it('updates subtotal when menu items are selected', async () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    render(<RestaurantOrder />);

    const checkboxes = screen.getAllByRole('checkbox');
    await userEvent.click(checkboxes[0]); // Select 'Lunch Item 1' (price: 10)

    expect(screen.getByText('Total: $10.00')).toBeInTheDocument();

    await userEvent.click(checkboxes[2]); // Select 'Dinner Item 1' (price: 20)

    expect(screen.getByText('Total: $30.00')).toBeInTheDocument();
  });
});

Exercise 1

  • Call useState() and use the OrderItems interface to create an items state.
  • Create a function for calling setItems() with the updated items state.
  • Add the checked and onChange props to all the checkboxes.
  • Update subtotal to use the calculateTotal() helper function.

Hint: The items state will look like this when populated:

{
  "Menu item 1 name": 1.23,// Menu item 1 price
  "Menu item 2 name": 4.56,// Menu item 2 price
}

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 1

Click to see the solution

✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:

import { useState } from "react"
import { useParams } from "react-router-dom"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"

type OrderItems = Record<string, number>

const RestaurantOrder: React.FC = () => {
    const params = useParams() as { slug: string }

    const restaurant = useRestaurant(params.slug)

    const [items, setItems] = useState<OrderItems>({})

    if (restaurant.isPending) {
        return (
            <p aria-live="polite" className="loading">
                Loading restaurant…
            </p>
        )
    }

    if (restaurant.error) {
        return (
            <p aria-live="polite" className="error">
                Error loading restaurant: {restaurant.error.message}
            </p>
        )
    }

    if (!restaurant.data) {
        return <p aria-live="polite">No restaurant found.</p>;
    }

    const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
        return setItems((currentItems) => {
            const updatedItems = {
                ...currentItems,
            }
            if (isChecked) {
                updatedItems[itemId] = itemPrice;
            } else {
                delete updatedItems[itemId]
            }
            return updatedItems
        })
    }

    const subtotal = calculateTotal(items)

    return (
        <>
            <RestaurantHeader restaurant={restaurant.data} />

            <div className="order-form">
                <h3>Order from {restaurant.data.name}!</h3>

                <form>
                    {subtotal === 0 && (
                        <p className="info text-error">Please choose an item.</p>
                    )}

                    <h4>Lunch Menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.lunch.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        checked={name in items}
                                        onChange={(event) => setItem(name, event.target.checked, price)}
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <h4>Dinner menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.dinner.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        checked={name in items}
                                        onChange={(event) => setItem(name, event.target.checked, price)}
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <div className="submit">
                        <h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
                    </div>
                </form>
            </div>
        </>
    )
}

function calculateTotal(items: OrderItems) {
    return Object.values(items).reduce((total, itemPrice) => {
        return total + itemPrice
    }, 0)
}

export default RestaurantOrder

Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Objective 2: Create a reusable text field component

The order form is going to be made up of many input fields with labels. Rather than repeat multiple input components over and over, let’s compose that structure in a single component named FormTextField. Creating this component will involve using some of what we’ve learned from prior lessons.

The useId() Hook

Since the value of every id attribute in an HTML document must be unique, this Hook is useful in creating a unique identifier string that can be used as the value for an id prop.

Let’s say you’re rendering a component that has a label that needs to be associated with an input:

<label for="name">
  Name
</label>
<input id="name" type="text">

Every ID has to be unique in an HTML page, but name might clash with another element in a page. To avoid this issue in React, we can get a unique ID with useId():

import { useId } from 'react'

const Form: React.FC = () => {
  const id = useId();

  return (
    <label htmlFor={id}>
      Name
    </label>
    <input id={id} type="text" />
  )
}

The value of useId is guaranteed to be unique within the component where it is used; this ideal for linking related components together.

Setup 2

✏️ Create src/components/FormTextField/FormTextField.tsx and update it to be:

import { useId } from "react"

const FormTextField: React.FC<{
}> = ({  }) => {
    return (
        <div className="form-group">
            <label className="control-label">
                Label:
            </label>

            <input
                className="form-control"
            />
        </div>
    )
}

export default FormTextField

✏️ Create src/components/FormTextField/index.ts and update it to be:

export { default } from "./FormTextField"

Verify 2

These tests will pass when the solution has been implemented properly.

✏️ Create src/components/FormTextField/FormTextField.test.tsx and update it to be:

import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import FormTextField from './FormTextField';

describe('FormTextField component', () => {
  const mockOnChange = vi.fn();
  
  it('renders with correct label and type', () => {
    render(<FormTextField label="Test Label" type="text" value="" onChange={mockOnChange} />);
    
    expect(screen.getByLabelText(/Test Label:/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/Test Label:/i)).toHaveAttribute('type', 'text');
  });

  it('renders with the correct value', () => {
    render(<FormTextField label="Test Label" type="text" value="Test Value" onChange={mockOnChange} />);
    
    expect(screen.getByLabelText(/Test Label:/i)).toHaveValue('Test Value');
  });

  it('calls onChange prop when input changes', async () => {
    render(<FormTextField label="Test Label" type="text" value="" onChange={mockOnChange} />);
    
    await userEvent.type(screen.getByLabelText(/Test Label:/i), 'New');
    
    expect(mockOnChange).toHaveBeenCalledTimes(3);
    expect(mockOnChange).toHaveBeenCalledWith('N');
    expect(mockOnChange).toHaveBeenCalledWith('e');
    expect(mockOnChange).toHaveBeenCalledWith('w');
  });

  it('respects different input types', () => {
    render(<FormTextField label="Email" type="email" value="" onChange={mockOnChange} />);
    
    expect(screen.getByLabelText(/Email:/i)).toHaveAttribute('type', 'email');
  });
});

Exercise 2

Let’s implement our FormTextField component and have it:

  • Accept label, onChange, type, and value props
  • Create a unique ID with useId()
  • Associate the <label> and <input> elements with htmlFor and id props
  • Add the onChange, type, and value props to the <input> element

Hint: The onChange prop type can be defined as (data: string) => void

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 2

Click to see the solution

✏️ Update src/components/FormTextField/FormTextField.tsx to be:

import { useId } from "react"

const FormTextField: React.FC<{
    label: string
    onChange: (data: string) => void
    type: string
    value: string
}> = ({ label, type, value, onChange }) => {
    const id = useId()

    return (
        <div className="form-group">
            <label className="control-label" htmlFor={id}>
                {label}:
            </label>

            <input
                className="form-control"
                id={id}
                onChange={(event) => onChange(event.target.value)}
                type={type}
                value={value}
            />
        </div>
    )
}

export default FormTextField

Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Objective 3: Integrate FormTextField into RestaurantOrder

Finally we’ll update the form to incorporate the FormTextField component so users can create and submit an order to the restaurant. We need to fill the form with input fields and handle the submit button.

Setup 3

✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:

import { FormEvent, useState } from "react"
import { useParams } from "react-router-dom"
import FormTextField from "../../components/FormTextField"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"

type OrderItems = Record<string, number>

const RestaurantOrder: React.FC = () => {
    const params = useParams() as { slug: string }

    const restaurant = useRestaurant(params.slug)

    const [items, setItems] = useState<OrderItems>({})

    if (restaurant.isPending) {
        return (
            <p aria-live="polite" className="loading">
                Loading restaurant…
            </p>
        )
    }

    if (restaurant.error) {
        return (
            <p aria-live="polite" className="error">
                Error loading restaurant: {restaurant.error.message}
            </p>
        )
    }

    if (!restaurant.data) {
        return <p aria-live="polite">No restaurant found.</p>;
    }

    const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
        return setItems((currentItems) => {
            const updatedItems = {
                ...currentItems,
            }
            if (isChecked) {
                updatedItems[itemId] = itemPrice;
            } else {
                delete updatedItems[itemId]
            }
            return updatedItems
        })
    }

    const handleSubmit = (event: FormEvent) => {
        event.preventDefault()
        alert('Order submitted!')
    }

    const selectedCount = Object.values(items).length
    const subtotal = calculateTotal(items)

    return (
        <>
            <RestaurantHeader restaurant={restaurant.data} />

            <div className="order-form">
                <h3>Order from {restaurant.data.name}!</h3>

                <form onSubmit={(event) => handleSubmit(event)}>
                    {
                        subtotal === 0
                            ? (
                                <p className="info text-error">Please choose an item.</p>
                            )
                            : (
                                <p className="info text-success">{selectedCount} selected.</p>
                            )
                    }

                    <h4>Lunch Menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.lunch.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        checked={name in items}
                                        onChange={(event) => setItem(name, event.target.checked, price)}
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <h4>Dinner menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.dinner.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        checked={name in items}
                                        onChange={(event) => setItem(name, event.target.checked, price)}
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <div className="submit">
                        <h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
                        <button className="btn" type="submit">
                            Place My Order!
                        </button>
                    </div>
                </form>
            </div>
        </>
    )
}

function calculateTotal(items: OrderItems) {
    return Object.values(items).reduce((total, itemPrice) => {
        return total + itemPrice
    }, 0)
}

export default RestaurantOrder

Verify 3

✏️ Update src/pages/RestaurantOrder/RestaurantOrder.test.tsx.tsx to be:

import type { ReactNode } from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import RestaurantOrder from './RestaurantOrder';

// Mock the hooks and components used in RestaurantOrder
vi.mock('../../services/restaurant/hooks', () => ({
  useRestaurant: vi.fn()
}));

vi.mock('../../components/FormTextField', () => ({
  default: vi.fn(({ label, onChange, type, value }) => (
    <div className="form-group">
      <label htmlFor={`form-field-${label.toLowerCase()}`}>{label}</label>
      <input
        id={`form-field-${label.toLowerCase()}`}
        data-testid={`form-field-${label.toLowerCase()}`}
        className="form-control"
        onChange={(event) => onChange(event.target.value)}
        type={type}
        value={value}
      />
    </div>
  ))
}));

vi.mock('../../components/RestaurantHeader', () => ({
  default: vi.fn(() => (
    <div data-testid="mock-restaurant-header">
      Mock RestaurantHeader
    </div>
  ))
}));

// Mocking the global fetch function
const mockAlert = vi.fn();

global.alert = mockAlert;

beforeEach(() => {
  mockAlert.mockClear();
});

afterEach(() => {
  mockAlert.mockClear();
});

import { useRestaurant } from '../../services/restaurant/hooks';

const mockRestaurantData = {
  data: {
    _id: '1',
    name: 'Test Restaurant',
    slug: 'test-restaurant',
    images: { owner: 'owner.jpg' },
    menu: {
      lunch: [
        { name: 'Lunch Item 1', price: 10 },
        { name: 'Lunch Item 2', price: 15 }
      ],
      dinner: [
        { name: 'Dinner Item 1', price: 20 },
        { name: 'Dinner Item 2', price: 25 }
      ]
    },
  },
  isPending: false,
  error: null
};

const renderWithRouter = (ui: ReactNode, { route = '/restaurants/test-restaurant' } = {}) => {
  window.history.pushState({}, 'Test page', route)
  return render(
    ui,
    { wrapper: ({ children }) => <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter> }
  );
};

describe('RestaurantOrder component', () => {
  it('renders loading state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: true, error: null });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/Loading restaurant…/i)).toBeInTheDocument();
  });

  it('renders error state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: { message: 'Error loading' } });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/Error loading restaurant/i)).toBeInTheDocument();
  });

  it('renders no restaurant found state', () => {
    useRestaurant.mockReturnValue({ data: null, isPending: false, error: null });
    renderWithRouter(<RestaurantOrder />);
    expect(screen.getByText(/No restaurant found/i)).toBeInTheDocument();
  });

  it('renders the RestaurantHeader when data is available', () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    renderWithRouter(<RestaurantOrder />);

    expect(screen.getByTestId('mock-restaurant-header')).toBeInTheDocument();
  });

  it('renders the order form when restaurant data is available', () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    render(<RestaurantOrder />);

    expect(screen.getByTestId('mock-restaurant-header')).toBeInTheDocument();
    expect(screen.getByText('Order from Test Restaurant!')).toBeInTheDocument();
    expect(screen.getAllByRole('checkbox').length).toBe(4); // 2 lunch + 2 dinner items
  });

  it('updates subtotal when menu items are selected', async () => {
    useRestaurant.mockReturnValue(mockRestaurantData);
    render(<RestaurantOrder />);

    const checkboxes = screen.getAllByRole('checkbox');
    await userEvent.click(checkboxes[0]); // Select 'Lunch Item 1' (price: 10)

    expect(screen.getByText('Total: $10.00')).toBeInTheDocument();

    await userEvent.click(checkboxes[2]); // Select 'Dinner Item 1' (price: 20)

    expect(screen.getByText('Total: $30.00')).toBeInTheDocument();
  });

  it('updates form fields', async () => {
    renderWithRouter(<RestaurantOrder />);

    await userEvent.type(screen.getByTestId('form-field-name'), 'John Doe');
    expect(screen.getByTestId('form-field-name')).toHaveValue('John Doe');

    await userEvent.type(screen.getByTestId('form-field-address'), '123 Main St');
    expect(screen.getByTestId('form-field-address')).toHaveValue('123 Main St');

    await userEvent.type(screen.getByTestId('form-field-phone'), '555-1234');
    expect(screen.getByTestId('form-field-phone')).toHaveValue('555-1234');
  });

  it('handles form submission', async () => {
    const submitSpy = vi.fn();
    renderWithRouter(<RestaurantOrder />);

    const submitButton = screen.getByRole('button', { name: /Place My Order!/i });
    const form = submitButton.closest('form');
    expect(form).toBeInTheDocument(); // Ensure the form is found

    if (form) {
      form.onsubmit = submitSpy;
    }

    await userEvent.click(submitButton);
    expect(submitSpy).toHaveBeenCalledTimes(1);
  });
});

Exercise 3

  • Create state variables and setters for address, name, and phone.
  • Use <FormTextField> to create input fields for these three state variables.

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 3

Click to see the solution

✏️ Update src/pages/RestaurantOrder/RestaurantOrder.tsx to be:

import { FormEvent, useState } from "react"
import { useParams } from "react-router-dom"
import FormTextField from "../../components/FormTextField"
import RestaurantHeader from "../../components/RestaurantHeader"
import { useRestaurant } from "../../services/restaurant/hooks"

type OrderItems = Record<string, number>

const RestaurantOrder: React.FC = () => {
    const params = useParams() as { slug: string }

    const restaurant = useRestaurant(params.slug)

    const [address, setAddress] = useState<string>("")
    const [items, setItems] = useState<OrderItems>({})
    const [name, setName] = useState<string>("")
    const [phone, setPhone] = useState<string>("")

    if (restaurant.isPending) {
        return (
            <p aria-live="polite" className="loading">
                Loading restaurant…
            </p>
        )
    }

    if (restaurant.error) {
        return (
            <p aria-live="polite" className="error">
                Error loading restaurant: {restaurant.error.message}
            </p>
        )
    }

    if (!restaurant.data) {
        return <p aria-live="polite">No restaurant found.</p>;
    }

    const setItem = (itemId: string, isChecked: boolean, itemPrice: number) => {
        return setItems((currentItems) => {
            const updatedItems = {
                ...currentItems,
            }
            if (isChecked) {
                updatedItems[itemId] = itemPrice;
            } else {
                delete updatedItems[itemId]
            }
            return updatedItems
        })
    }

    const handleSubmit = (event: FormEvent) => {
        event.preventDefault()
        alert('Order submitted!')
    }

    const selectedCount = Object.values(items).length
    const subtotal = calculateTotal(items)

    return (
        <>
            <RestaurantHeader restaurant={restaurant.data} />

            <div className="order-form">
                <h3>Order from {restaurant.data.name}!</h3>

                <form onSubmit={(event) => handleSubmit(event)}>
                    {
                        subtotal === 0
                            ? (
                                <p className="info text-error">Please choose an item.</p>
                            )
                            : (
                                <p className="info text-success">{selectedCount} selected.</p>
                            )
                    }

                    <h4>Lunch Menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.lunch.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        checked={name in items}
                                        onChange={(event) => setItem(name, event.target.checked, price)}
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <h4>Dinner menu</h4>
                    <ul className="list-group">
                        {restaurant.data.menu.dinner.map(({ name, price }) => (
                            <li key={name} className="list-group-item">
                                <label>
                                    <input
                                        checked={name in items}
                                        onChange={(event) => setItem(name, event.target.checked, price)}
                                        type="checkbox"
                                    />
                                    {name}
                                    <span className="badge">{price}</span>
                                </label>
                            </li>
                        ))}
                    </ul>

                    <FormTextField
                        label="Name"
                        onChange={setName}
                        type="text"
                        value={name}
                    />
                    <FormTextField
                        label="Address"
                        onChange={setAddress}
                        type="text"
                        value={address}
                    />
                    <FormTextField
                        label="Phone"
                        onChange={setPhone}
                        type="tel"
                        value={phone}
                    />

                    <div className="submit">
                        <h4>Total: ${subtotal ? subtotal.toFixed(2) : "0.00"}</h4>
                        <button className="btn" type="submit">
                            Place My Order!
                        </button>
                    </div>
                </form>
            </div>
        </>
    )
}

function calculateTotal(items: OrderItems) {
    return Object.values(items).reduce((total, itemPrice) => {
        return total + itemPrice
    }, 0)
}

export default RestaurantOrder

Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Next steps

Next, let’s learn how to write tests with React Testing Library.