Handling User Inputs and Forms page

Use forms and inputs to collect information.

Overview

Objective 1: Add a form with controlled checkboxes

TODO

Key concepts

  • A controlled input requires value (checked) and onChange props.
  • Form input events have a target property with current form values.
  • Record helper in TypeScript.
  • Store form data as a Record in state.
  • Update form data in state using an updater function.
  • Always create a new value for arrays and objects in state (don’t update the arrays and values).

Controlled and 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, <input> components are controlled, and their value is stored in a state variable.

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

const ControlledInput: React.FC = () => {
  const [name, setName] = useState("");
  return (
    <label>Name: 
      <input onChange={(event) => setName(event.target.value)} 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 events

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

onChange={(event) => setName(event.target.value)}

The onChange prop requires a function that implements the ChangeEventHandler interface. When an event handler is called, it receives an argument named "event", which is a SyntheticEvent defined by React. While a SyntheticEvent is similar to a native DOM Event and has many of the same properties, they are not identical.

A ChangeEvent — derived from SyntheticEvent — is the event argument provided to a ChangeEventHandler. A ChangeEvent always has a property named target that references the component that emitted the event. As you can see above, it's possible to get the target's new value using its value property.

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:

const [selected, setSelected] = useState<Record<string, boolean>>({});

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.

Set state using a 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 set function returned by useState will accept a function called an "updater function" that's passed the "pending" state value and returns a "next" state value.

const [selected, setSelected] = useState<Record<string, boolean>>({});
setSelected((pending) => { /* Do something with the pending state value and return a next state value. */ });

In the example below, 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. In the updater function, the contents of the next state object are initially set by spreading the contents of the pending state object. Then the value of checked provided by the input is set on the next state value object.

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

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

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

Updating reference types and rendering

We'd like to call your attention again to how the updater function works in the example above. The updater function does not mutate the pending object, then return it; instead, it makes a new object and populates it with the contents of the pending object. This is an important detail because, after the updater function runs React will compare the values of the pending and next objects to determine if they are different. If they are different, React will render the Selected component; if they are the same React will do nothing.

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

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

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

Now may be a good time to brush up on how different JavaScript types are compared for equality.

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

Setup

✏️ 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>

interface NewOrderState {
    items: OrderItems;
}

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

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

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

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, { 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

  • Add newOrder state so that when menu items are selected, the state will look like:
{
  items: {
    "Menu item 1 name": 1.23,// Menu item 1 price
    "Menu item 2 name": 4.56,// Menu item 2 price
  }
}
  • Add the onChange listener to all the checkboxes.
  • Add the checked prop to all the checkboxes.
  • Update subtotal to use the calculateTotal helper function.

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

Solution

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>

interface NewOrderState {
    items: OrderItems;
}

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

    const restaurant = useRestaurant(params.slug)

    const [newOrder, setNewOrder] = useState<NewOrderState>({
        items: {},
    })

    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 setNewOrder((newOrder) => {
            const updatedItems = {
                ...newOrder.items,
            }
            if (isChecked) {
                updatedItems[itemId] = itemPrice;
            } else {
                delete updatedItems[itemId]
            }
            return {
                ...newOrder,
                items: updatedItems,
            }
        })
    }

    const subtotal = calculateTotal(newOrder.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 newOrder.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 newOrder.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

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 components 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.

In this section, we will:

  • Learn how to use the useId() hook.

useId

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

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

import { useId } from "react"

const FormTextField: React.FC<{
}> = ({ }) => {
    return (
    )
}

export default FormTextField

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

export { default } from "./FormTextField"

Verify

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

  • FormTextField:
    • has the following props: label, onChange, type, and value.
    • returns a <div> with the class name "form-group".
    • <div> contains a <label> and <input> that are paired by a unique id
  • The <label> will:
    • have its text set by a prop
    • have its text positioned to the left of the input and will have a colon (:) appended
    • include the class name "control-label"
  • The <input> will:
    • have its props set by FormTextField props
    • include the class name "form-control"

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

Solution

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

Objective 3: Integrate FormTextField into RestaurantOrder and submit the form

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.

Key concepts

  • Form submission: submit button, onSubmit handler, and managing the submit event.
  • [key]: value syntax to make reusable setter functions.

TODO: I’m not sure whether the submit button should be a part of the exercise. Maybe? For now I’d like to have the content to cover it, then figure out if we actually want it in the exercise after we see how it looks.

Concept 1

TODO

Concept 2

TODO

Setup

TODO

✏️ 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>

interface NewOrderState {
    address: string;
    items: OrderItems;
    name: string;
    phone: string;
}

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

    const restaurant = useRestaurant(params.slug)

    const [newOrder, setNewOrder] = useState<NewOrderState>({
        items: {},
    })

    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 setNewOrder((newOrder) => {
            const updatedItems = {
                ...newOrder.items,
            }
            if (isChecked) {
                updatedItems[itemId] = itemPrice;
            } else {
                delete updatedItems[itemId]
            }
            return {
                ...newOrder,
                items: updatedItems,
            }
        })
    }

    const handleSubmit = (event: FormEvent) => {
        event.preventDefault()
    }

    const selectedCount = Object.values(newOrder.items).length
    const subtotal = calculateTotal(newOrder.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 newOrder.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 newOrder.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

TODO

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

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/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>
  ))
}));

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, { 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

TODO

Solution

Click to see the solution

TODO

✏️ 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