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 thevalue
oronChange
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 thecalculateTotal
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
, andvalue
. - returns a
<div>
with the class name "form-group". <div>
contains a<label>
and<input>
that are paired by a unique id
- has the following props:
- 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"
- have its props set by
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