Step 8 - Working with Forms & Advanced Routing
Although we’ve mostly covered everything in the last steps, this step provides you with a few ideas on working with forms and advanced routing using react-router
. For this we’ll create a Checkout feature & view for the store.
Let’s start by downloading the Country Names JSON here and saving it as src/client/app/Countries.json
.
Now in Cart.jsx
, let’s add a Checkout button at the bottom of the Cart list:
import { IndexLinkContainer } from 'react-router-bootstrap';
// existing code
<MenuItem divider />
<IndexLinkContainer to={'/checkout'}>
<MenuItem
className="text-center h4 cart-checkout-button"
disabled={this.props.cart.size < 1}>
Go To Checkout
</MenuItem>
</IndexLinkContainer>
...
This also needs a bit of styling:
main.less
.cart {
min-width: 300px;
max-width: 90vw;
max-height: 450px;
overflow-y: auto;
&-checkout-button {
padding: 0 @padding-base-horizontal 4px;
margin: 0;
a {
.btn;
.btn-lg;
.btn-default;
.btn-block;
}
}
}
Now let’s create a Checkout route, where buyers can review their order and enter their address:
Checkout.jsx
:
import React from 'react';
import { ListGroup, ListGroupItem, Form, FormControl, FormGroup, ControlLabel, Row, Col, Button } from 'react-bootstrap';
import CheckoutItem from './CheckoutItem.jsx';
import IPropTypes from 'immutable-props';
import { Map } from 'immutable';
import CountriesJSON from '../Countries.json';
const COUNTRIES = Map(CountriesJSON);
class Checkout extends React.Component {
constructor(props) {
super(props);
this.state = {
firstName: {
value: '',
valid: false
},
lastName: {
value: '',
valid: false
},
email: {
value: '',
valid: false
},
phone: {
value: '',
valid: false
},
address: {
value: '',
valid: false
},
country: {
value: '',
valid: false
},
city: {
value: '',
valid: false
},
comments: {
value: '',
valid: true
}
};
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSubmitOrder = this.handleSubmitOrder.bind(this);
}
componentDidMount() {
if (this.props.cart.size < 1) {
this.context.router.push('/shop');
}
}
handleInputChange(e) {
let valid = this.state[e.target.id].valid,
value = e.target.value;
switch (e.target.id) {
case 'firstName':
case 'lastName':
case 'city':
if (/^[^±!@£$%^&*_+§¡€#¢§¶•ªº«\\/<>?:;|=.,0-10]{1,20}$/.test(value)) {
valid = true;
} else {
valid = false;
}
break;
case 'email':
if (/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/.test(value)) {
valid = true;
} else {
valid = false;
}
break;
case 'phone':
if (/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/im.test(value)) {
valid = true;
} else {
valid = false;
}
break;
case 'address':
case 'country':
if (value.length > 0) {
valid = true;
} else {
valid = false;
}
break;
}
this.setState({
[e.target.id]: {
value,
valid
}
});
}
handleSubmitOrder() {
this.context.router.push({
pathname: 'order_sent',
state: {
fromCheckout: true
}
});
}
render() {
let formIsValid = true;
for (const field in this.state) {
if (this.state.hasOwnProperty(field)) {
formIsValid = formIsValid && this.state[field].valid;
}
}
const total = this.props.cart.toArray().reduce(
(acc, cartItem) => (Number(acc) + Number(cartItem.product.price) * Number(cartItem.quantity)),
0
).toFixed(2);
return (
<Col sm={12}>
<h1>Checkout</h1>
<h2>Products</h2>
<ListGroup>
{
this.props.cart.toArray().map(cartItem => (
<CheckoutItem
key={cartItem.product.id}
cartItem={cartItem} />
))
}
<ListGroupItem>
<h4>
<span className="pull-right">
${total}
</span>
Total
</h4>
</ListGroupItem>
</ListGroup>
<h2>Shipping</h2>
<Form>
<Row>
<Col sm={6}>
<FormGroup validationState={this.state.firstName.valid ? 'success': 'error'}>
<ControlLabel>
First name <span className="text-danger">*</span>
</ControlLabel>
<FormControl
type="text"
id="firstName"
required
value={this.state.firstName.value}
onChange={this.handleInputChange} />
</FormGroup>
<FormGroup validationState={this.state.lastName.valid ? 'success': 'error'}>
<ControlLabel>
Last name <span className="text-danger">*</span>
</ControlLabel>
<FormControl
type="text"
id="lastName"
required
value={this.state.lastName.value}
onChange={this.handleInputChange} />
</FormGroup>
<FormGroup validationState={this.state.email.valid ? 'success': 'error'}>
<ControlLabel>
E-mail <span className="text-danger">*</span>
</ControlLabel>
<FormControl
type="email"
id="email"
required
value={this.state.email.value}
onChange={this.handleInputChange} />
</FormGroup>
<FormGroup validationState={this.state.phone.valid ? 'success': 'error'}>
<ControlLabel>
Phone <span className="text-danger">*</span>
</ControlLabel>
<FormControl
type="phone"
id="phone"
required
value={this.state.phone.value}
onChange={this.handleInputChange} />
</FormGroup>
</Col>
<Col sm={6}>
<FormGroup validationState={this.state.address.valid ? 'success': 'error'}>
<ControlLabel>
Address <span className="text-danger">*</span>
</ControlLabel>
<FormControl
componentClass="textarea"
style=
id="address"
required
value={this.state.address.value}
onChange={this.handleInputChange} />
</FormGroup>
<FormGroup validationState={this.state.country.valid ? 'success': 'error'}>
<ControlLabel>
Country <span className="text-danger">*</span>
</ControlLabel>
<FormControl
componentClass="select"
id="country"
required
value={this.state.country.value}
onChange={this.handleInputChange}>
<option
value=""
hidden>
Select a country
</option>
{
COUNTRIES.map((name, label) => (
<option
key={label}
value={label}>
{name}
</option>
)).toArray()
}
</FormControl>
</FormGroup>
<FormGroup validationState={this.state.city.valid ? 'success': 'error'}>
<ControlLabel>
City <span className="text-danger">*</span>
</ControlLabel>
<FormControl
type="text"
id="city"
required
value={this.state.city.value}
onChange={this.handleInputChange} />
</FormGroup>
</Col>
</Row>
<FormGroup>
<ControlLabel>
Comments
</ControlLabel>
<FormControl
componentClass="textarea"
type="text"
id="comments"
rows={4}
value={this.state.comments.value}
onChange={this.handleInputChange} />
</FormGroup>
<Row>
<Col
sm={6}
smOffset={3}>
<Button
block
bsSize="large"
bsStyle="primary"
disabled={!formIsValid}
onClick={this.handleSubmitOrder}>
Submit order
</Button>
</Col>
</Row>
</Form>
</Col>
);
}
}
Checkout.propTypes = {
cart: IPropTypes.Map
};
Checkout.contextTypes = {
router: React.PropTypes.object.isRequired
};
export default Checkout;
And the CheckoutItem.jsx
:
import React from 'react';
import { ListGroupItem, Media, Image } from 'react-bootstrap';
function CheckoutItem(props) {
const {
product,
quantity
} = props.cartItem;
return (
<ListGroupItem
className="cart-item">
<Media>
<Media.Left>
<Image
src={product.picture}
width="64" />
</Media.Left>
<Media.Body>
<Media.Heading>{product.name}</Media.Heading>
<h5 className="pull-right">{`$${Number(quantity * product.price).toFixed(2)}`}</h5>
<h5 className="pull-left">{`${quantity} Units`}</h5>
</Media.Body>
</Media>
</ListGroupItem>
);
}
CheckoutItem.propTypes = {
cartItem: React.PropTypes.object.isRequired
};
export default CheckoutItem;
Now let’s analyze it:
- we create an
Immutable.Map
from the Countries JSON (which we use in the country select) - we store each field’s value in the state as it’s mutable data, and their validation state
- in
componentDidMount
we check if the cart is empty, if it is, the user is redirected to the shop - using the
handleInputChange
method, we handle change events on all the fields and we treat each one differently according to its id; we validate the value also and set the field’s valid value accordingly. It’s generally better to use a dedicated validation library, like Parsley, but for the sake of simplicity here we used some regexes - When the form is valid, the submit button is enabled; clicking it redirects us to the
order_sent
route, which is undefined yet, so let’s also define it
OrderSent.jsx
:
import React from 'react';
class OrderSent extends React.Component {
componentDidMount() {
if (!this.props.location.state || !this.props.location.state.fromCheckout) {
this.context.router.push({
pathname: '',
state: {
fromCheckout: false
}
});
}
}
componentWillUnmount() {
this.context.router.push({
state: {
fromCheckout: false
}
});
}
render() {
return (
<div className="text-center">
<h1>The order was successfully placed!</h1>
<p className="text-muted">Please wait forever for your order to arrive.</p>
</div>
);
}
}
OrderSent.propTypes = {
location: React.PropTypes.object.isRequired
};
OrderSent.contextTypes = {
router: React.PropTypes.object.isRequired
};
export default OrderSent;
This screen informs the user that the order was sent. But what if we visit it directly entering the URL?
Here comes the router state to save the day: we check if fromCheckout
is defined and true in the state, if it’s not, we redirect the user to home.
Now we just have to add these routes to index.js
:
import React from 'react';
import { render } from 'react-dom';
import { Router, Route, IndexRoute, browserHistory } from 'react-router';
import App from './components/App.jsx';
import Home from './components/Home.jsx';
import ProductList from './components/ProductList.jsx';
import ProductPageWrapper from './components/ProductPageWrapper.jsx';
import NotFound from './components/NotFound.jsx';
import Checkout from './components/Checkout.jsx';
import OrderSent from './components/OrderSent.jsx';
render(
(
<Router
history={browserHistory}>
<Route
path="/"
component={App}>
<IndexRoute
component={Home} />
<Route
path="shop">
<IndexRoute
component={ProductList} />
<Route
path=":id"
component={ProductPageWrapper} />
</Route>
<Route
path="checkout"
component={Checkout} />
<Route
path="order_sent"
component={OrderSent} />
<Route
path="*"
component={NotFound} />
</Route>
</Router>
), document.getElementById('app'));
And done, we have a working checkout!