Lockdown diaries — React + Symfony + Api Platform: the perfect combination
The days are long during the lockdown, so I decided to learn ReactJs from scratch or almost.
“Almost” because Javascript, even if it’s not the language which I best know, I still often used it. I’ve also got some knowledge about VueJs.
After 15 days of quarantine, an application came out. You can view and clone it here.
I would like to share with you the various steps that led me to have a functional application written in ReactJs, with an API base on Symfony and Api Platform. I would also like to show how the new Api Platform features such as Vulcain and Mercure can be integrated naturally with ReactJs.
I tried to be constant about the time spent on learning and practicing : 3 hours per day for 14 days.
Day 1 — Reading documentation and practice
The first day I’ve read the very accurate documentation of ReactJs from the official website.
In particular, I’ve focused on the main concepts : Jsx, Components, Props, State, Conditions, Lists and Forms . I’ve played with CodePen, trying to redo the various examples of the documentation.
Day 2 — Reading documentation and practice
On the second day I’ve continued reading the documentation. I switched to advanced guides and I learned what Fragments are. I worked on understanding the Context, static validation, PropTypes and Hooks. Still practicing with CodePen during all the process.
Day 3 — Installing and configuring environnement (Symfony, Api Platform, Yarn, React)
On the third day I was impatient to start something more than CodePen examples. So I started setting up an environment to develop my first ReactJs application.
I’ve downloaded the latest Api Platform distribution. You must have Docker, if you do not already have it on your computer, you can install it here.
Built on top of Symfony, API Platform enables you to build a rich, JSON-LD-powered, hypermedia API.
We’re going to build a books store. The application allows us to consult a books list. You can filter on title or author and you can add a book into shopping cart. It is roughly the same type of application made in my first post of these series.
But let’s go step by step.
I start Docker containers with:
$ docker-compose up -d
Then I created the entities.
/**
* @ApiResource()
* ...
*/
class Book
{
...
...
}/**
* @ApiResource(
* ...
* )
* ...
*/
class MediaObject()
{
...
...
}
Note that for image management, I followed the Api Platform documentation. In this way you can easy linking an image to a resource.
I install fixtures with hautelook alice:
$ composer require — dev hautelook/alice-bundle
#src/fixtures/book.yamlApp\Entity\MediaObject:
madia_object_1:
filePath: 'cover1.jpg'
madia_object_2:
filePath: 'cover2.png'
madia_object_3:
filePath: 'cover3.png'
madia_object_4:
filePath: 'cover4.png'
madia_object_5:
filePath: 'cover5.png'
madia_object_6:
filePath: 'cover6.png'
madia_object_7:
filePath: 'cover7.png'
madia_object_8:
filePath: 'cover8.png'
madia_object_9:
filePath: 'cover9.png'
madia_object_10:
filePath: 'cover10.png'
App\Entity\Book:
book_{1..10}:
title: <sentence(4, true)>
description: <text()>
author: <name()>
isbn: <isbn13()>
stock: <numberBetween(1, 100)>
price: <randomFloat(2, 2, 20)>
image: '@madia_object_*'
I installed Webpack Encore :
$ composer require symfony/webpack-encore-bundle
I’ve installed the necessary node modules. Note that you should already have npm installed on your computer.
$ npm install yarn
$ yarn init
Webpack:
$ yarn add @symfony/webpack-encore
$ yarn add webpack-notifier
React:
$ yarn add react react-dom
$ yarn add add core-js@3 @babel/runtime-corejs3
SASS:
yarn add sass-loader@7.0.1 node-sass
Then I configured my webpack.config.js to enable react.
Encore
.setOutputPath('public/build/')
.setPublicPath('/build')
.addEntry('app', './assets/js/app.js')
.splitEntryChunks()
.enableSingleRuntimeChunk() .cleanupOutputBeforeBuild()
.enableBuildNotifications()
.enableSourceMaps(!Encore.isProduction())
.enableVersioning(Encore.isProduction()) // enables @babel/preset-env polyfills
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = 3;
})
// enables Sass/SCSS support .enableSassLoader()
// enables React
.enableReactPreset()
;
module.exports = Encore.getWebpackConfig();
Don’t forgot to add entry in your base.html.twig file:
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% endblock %}
Now everything is in order to start writing my first application in React. The entry point (defined in webpack.config.js) is assets/js/app.js.
But first I need to create the Controller and the View capable to load the React app.
//src/Controller/HomeController.php<?php declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
final class HomeController extends AbstractController
{
/**
*
* @Route("/bookstore", name="bookstore_home")
* @return Response
*/
public function __invoke(): Response
{
return $this->render('index.html.twig');
}
}//templates/index.html.twig{% extends 'base.html.twig' %}
{% block body %}
<div id="root"></div>
{% endblock %}
And here’s the « Hello world » app.
//assets/js/app.js
import React from 'react';
import ReactDOM from 'react-dom';
require('../css/app.scss');
class App extends React.Component {
render() {
return (
<div>Hello world</div>
);
}
}
ReactDOM.render(
<App text='' />,
document.getElementById('root')
);
To build assets I run the watcher with:
$ yarn encore dev — watch
Now when i go to http://localhost:8443/bookstore i can see my Hello World !
Day 4 — Main concepts
On the fourth day I was very motivated to apply the concepts I had seen two days earlier.
Goal of the day: a search field where you can search on the title or author and get the resulting list of books.
Note that I’ve use bootstrap for this demo, to install it with yarn you can use the following command :
$ yarn add bootstrap –dev
And import the bootstap scss :
//assets/css/app.scss
@import "~bootstrap/scss/bootstrap";
I will have four components : SearchInput the text input where i can perform search, BookList the list of books composed of a series of BookListItem which represents each element of the list. And finally a HomePage that reassembles the components.
If the user write more than 3 characters into the text field I fetch the books data. If the input field is empty I reset to initial state.
//assets/js/components/Book/SearchInput.js;import React from "react";export default class SearchInput extends React.Component {
constructor (props) {
super(props); this.state = {
text: '',
}; this.handleChange = this.handleChange.bind(this);
} handleChange (event) {
if (event.target.value.length === 0) {
this.props.onEmptyInput(1);
} if (event.target.value.length < 3) {
return;
}
this.setState({text: event.target.value}); fetch('/books/search/' + event.target.value)
.then(response => response.json())
.then(data => {
this.props.onTextChange(data['searchResult'], this.state.text);
});
} render () {
const text = this.props.text;
return (
<div className="input-group input-group-sm mb-3">
<div className="input-group-prepend">
<span className="input-group-text" id="inputGroup-sizing-sm">
<i className="fas fa-search" />
</span>
</div>
<input type="text"
value={text}
onChange={this.handleChange}
className="form-control"
aria-label="Small"
aria-describedby="inputGroup-sizing-sm"
/>
</div>
);
}
}
And the list is updated as soon as we receive the data
//assets/js/components/Book/Booklist.js;import React from "react";
import BookListItem from "./BookListItem";
export default class BookList extends React.Component {
constructor (props) {
super(props);
}
render () {
const bookItems = this.props.books.map((book) =>
<BookListItem
key={book.id}
id={book.id}
title={book.title}
author={book.author}
commentsNumber={book.comments.length}
image={book.image}
/>
);
return (
<div className="col-sm-8 mx-auto">
<ul className="list-group">
{bookItems}
</ul>
</div>
);
}
}
The BookList iterates over the result and displays items. BookListItem fetch image and display book data received by the Props.
//assets/js/components/Book/BookListItem.js;import React from "react";
export default class BookListItem extends React.Component {
constructor (props) {
super(props);
this.state = {
isHovered: false,
imagePath: ''
};
this.handleHover = this.handleHover.bind(this);
}
handleHover () {
this.setState(prevState => ({
isHovered: !prevState.isHovered
}));
}
componentDidMount () {
fetch(this.props.image).then(response => response.json())
.then(data => {
this.setState({imagePath: data.contentUrl});
}
});
}
render () {
const isActive = this.state.isHovered ? "active" : "";
return (
<li className={`d-flex justify-content-between align-items-center list-group-item
${isActive}`} onMouseEnter={this.handleHover}
onMouseLeave={this.handleHover}>
{this.props.title} <br/>
<small>by {this.props.author}</small> <br/>
<small>{this.props.commentsNumber} avis</small>
<div>
<img className="image-item" src={this.state.imagePath} alt={this.state.imagePath}/>
</div>
</li>
);
}
}
The HomePage components contains the components above and makes them work together. But first, the HomePage component makes a first request to retrieve the list of books.
//assets/js/components/Page/HomePage.jsimport React from "react";import BookList from "../Book/BookList";
import SearchInput from "../Search/SearchInput";
export default class HomePage extends React.Component {
constructor (props) {
super(props);
this.state = {
isLoading: true,
books: [],
totalBooks: 0
};
this.updateList = this.updateList.bind(this);
this.searching = this.searching.bind(this);
this.fetchBooks = this.fetchBooks.bind(this);
}
async fetchBooks (page) {
this.setState({isLoading: true});
await fetch('/books?page=' + page)
.then(response => response.json())
.then(data => {
this.setState({
isLoading: false,
books: data['hydra:member'],
totalBooks: data['hydra:totalItems']
});
});
}
async componentDidMount () {
this.fetchBooks(1);
}
updateList (books, text) {
this.setState(
{
isLoading: false,
text: text,
books: books,
}
);
}
searching () {
this.setState({isLoading: true, books: [], totalBooks: 0});
}
render () {
return (
<main role="main">
<div className="jumbotron">
<div className="col-sm-8 mx-auto">
<SearchInput onTextChange={this.updateList} onEmptyInput={this.fetchBooks} onSearching={this.searching}/>
</div>
<BookList books={this.state.books}/>
</div>
</main>
);
}
}
On the backend side I have to create a DTO and a DataProvider to retrieve data exposed by the API:
//src/DataProvider/SearchDataProvider<?php
declare(strict_types=1);
namespace App\DataProvider;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
use App\Dto\Search;
use App\Repository\BookRepository;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;final class SearchDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
{
/** @var BookRepository */
private $bookRepository;
public function __construct(BookRepository $bookRepository)
{
$this->bookRepository = $bookRepository;
} public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return Search::class === $resourceClass;
}
public function getItem(string $resourceClass, $searchText, string $operationName = null, array $context = [])
{
$books = $this->bookRepository->searchBookByTitleOrAuthorName($searchText);
return new Search($searchText, $books);
}
}
And the DTO associated:
<?php
declare(strict_types=1);
namespace App\Dto;
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ApiResource(
* itemOperations={
* "get"={
* "method"="GET",
* "path"="/books/search/{id}",
* "swagger_context"={
* "tags"={"Book"}
* }
* }
* },
* collectionOperations={}
* )
*/
final class Search
{
/**
* @var string
*
* @ApiProperty(identifier=true)
*
* @Assert\NotBlank()
*
* @Groups({"read"})
*/
private $text;
/**
* @var array|null
*
* @Groups({"read"})
*/
private $searchResult = [];
public function __construct(string $text, ?array $searchResult)
{
$this->text = $text;
$this->searchResult = $searchResult;
}
public function getText(): string
{
return $this->text;
}
public function getSearchResult(): ?array
{
return $this->searchResult;
}
}
We can now get the books list when reloading the page:
And we can search a books by author or title:
I’ve practiced on the React’s props today, with the lists, and the conditional instructions, but also with the management of the internal state of the component. I also applied the concept of “lifting state up” and I’ve used forms.
Day 5 — Main concepts
On the fifth day I wanted to review the concepts seen the day before, to better fix them in my head. So I worked on two components. BookPagination, which allows the user to browse the pages of the book list. And the Loader showing a spinner while fetching data.
//assets/js/components/BookPagination.js
import React from 'react';
export default class BookPagination extends React.Component {
constructor (props) {
super(props);
this.perPage = 10;
this.state = {
currentPage: 1,
totalPages: 0
};
this.changePage = this.changePage.bind(this);
this.previousPage = this.previousPage.bind(this);
this.nextPage = this.nextPage.bind(this);
}
previousPage () {
this.changePage(this.state.currentPage - 1);
}
nextPage () {
this.changePage(this.state.currentPage + 1);
}
changePage (page) {
this.props.onChangePage(page);
this.setState({currentPage: page});
}
render () {
//previous button
let previousButton = '';
if (this.state.currentPage > 1 && this.props.totalBooks > 0) {
previousButton = <li className="page-item">
<a className="page-link" href="#" onClick={() => this.changePage(this.state.currentPage - 1)}>Previous</a>
</li>
;
}
let totalPages = Math.ceil(this.props.totalBooks / this.perPage);
//pages
let pages = [];
if (this.props.totalBooks > 0) {
for (let i = 1; i < totalPages; i++) {
let classActive ='';
if (i === this.state.currentPage) {
classActive = 'active';
}
pages.push(
<li className={`page-item ${classActive}`} key={i}>
<a className="page-link" href="#" onClick={() => this.changePage(i)}>{i}</a>
</li>
);
}
}
//next button
let nextButton = '';
if (this.state.currentPage < totalPages && this.state.currentPage > 0) {
nextButton = <li className="page-item">
<a className="page-link" href="#" onClick={() => this.changePage(this.state.currentPage + 1)}>Next</a>
</li>
;
}
return (
<ul className="pagination justify-content-center">
{previousButton}
{pages}
{nextButton}
</ul>
);
}
}
Each time that the user clicks on a page the callback of the props is called.
this.props.onChangePage(page);
I want to display a loader during data fetching with a simple Spinner to inform the user that something is going on. To do this I change the content when a fetching is in progress.
The HomePage containing BookPagination and Loader is then modified as follows:
//assets/js/components/HomePage.js...
...render () {
let content = <Loader />;
if (this.state.isLoading !== true) {
content = <BookList books={this.state.books}/>;
}
return (
<main role="main">
<div className="jumbotron">
<div className="col-sm-8 mx-auto">
<SearchInput onTextChange={this.updateList} onEmptyInput={this.fetchBooks} onSearching={this.searching}/>
</div>
{content}
<div className="col-sm-8 mx-auto">
<BookPagination totalBooks={this.state.totalBooks} onChangePage={this.fetchBooks}/>
</div>
</div>
</main>
);
}...
...
And the Loader:
//assets/js/components/Loader.jsimport React from 'react';
export default class Loader extends React.Component {
constructor (props) {
super(props);
}
render () {
return (
<div className="col-sm-8 mx-auto">
<div className="d-flex align-items-center">
<strong>Loading...</strong>
<div className="spinner-border ml-auto" role="status" aria-hidden="true"/>
</div>
</div>
);
}
}
Day 6— Router
The goal of the day is to view the book’s details on a dedicated page.
To do this I had to create a component that represents the book page. The BookPage component. To display this page we need the router, because the user must be able to click on one of the items in the list of books (BookListItem) and to be redirected to the book page.
I install react router library following the official documentation:
$ yarn add react-router react-router-dom
The component BookPage looks like this
//assets/js/components/BookPage.jsimport React from "react";
import Book from "../Book/Book";
import Loader from "../Common/Loader";
export default class BookPage extends React.Component {
constructor (props) {
super(props);
this.state = {
book: '',
isLoaded: false,
imagePath: '',
comments: []
};
}
componentDidMount () {
//fetch book
const bookJSON = fetch('/books/' + this.props.match.params.id);
this.setState({book: bookJSON, isLoaded: true});
//fetch image
fetch(bookJSON.image)
.then(response => response.json())
.then(data => {
this.setState({imagePath: data.contentUrl});
});
}
render () {
const isLoaded = this.state.isLoaded;
let imagePath = '';
if (this.state.imagePath !== '') {
imagePath = this.state.imagePath;
}
if (isLoaded) {
return (
<main role="main">
<Book book={this.state.book} imagePath={imagePath} />
</main>
);
}
return (
<main role="main">
<div className="jumbotron">
<Loader/>
</div>
</main>);
}
}
Note that the props this.props.match.parms.id
is a props provided to the component by the router and corresponding to the value of route parameter.
/bookstore/book/1
I’ve defined the router at the top of the application, this means into app.js.
//assets/js/app.jsimport React from 'react';
import ReactDOM from 'react-dom';
import {
BrowserRouter as Router,
Switch,
Route,
} from "react-router-dom";
import BookPage from "./components/Page/BookPage";
import HomePage from "./components/Page/HomePage";
......
class App extends React.Component {
render () {
return (
<Router>
<NavBar />
<Switch>
<Route exact path="/bookstore">
<HomePage />
</Route>
<Route
path="/book/:id"
render={(props) => <BookPage {...props} />}
/>
</Switch>
</Router>
);
}
}
ReactDOM.render(
<App text='' />,
document.getElementById('root')
);
As we said above, the Router takes care of passing the request parameters to the BookPage component:
<Route
path=”/book/:id”
render={(props) => <BookPage {…props} />}
/>
And I also add a NavBar to be able to easily return to the Home.
import React from 'react';
import {Link} from "react-router-dom";
export default class NavBar extends React.Component {
constructor (props) {
super(props);
}
render () {
return(
<nav className="navbar navbar-expand-lg navbar-light bg-light rounded">
<div className="collapse navbar-collapse">
<ul className="navbar-nav text-right">
<li className="nav-item">
<Link to="/bookstore">
<i className="fas fa-home"/>Home
</Link>
</li>
</ul>
</div>
</nav>
);
}
}
Day 7 and Day 8— State management with Redux
Now that I can display a book page, I wish I could add the book to the shopping cart.
The status of the cart must be persistent in order to be able to be use anywhere in the application. For that I need a state manager and logically I’ve chosen Redux.
I know that ideally we need to persistently save data, in local storage for example. But for the purpose of this demo the redux store is quite enough.
After a quick read of the documentation I installed it:
$ yarn add redux react-redux — save
On a practical level I need a cart action and a cart reducer, the first indicates the action to be performed and the second actually performs it:
I focus initially on one action: Add a book to the shopping cart.
//assets/js/actions/cart.jsexport const addToCart = book => ({
type: 'ADD_TO_CART',
book: book,
});
If a book is already present in the shopping cart when an user add it, I only change the quantity without physically adding it again.
I also provide a “total” property that indicates the number of items placed in the cart. Every time the user inserts a new book the property is increased. Here’s what my reducer looks like:
//assets/js/reducers/cart.jsconst initialState = {
books: [],
total: 0,
};const cartItems = (state = initialState, action) => {
switch (action.type) {
case 'ADD_TO_CART':
if (typeof action.book.quantity === 'undefined') {
action.book.quantity = 1;
}
state.books.forEach(function (book, index, books) {
if (book.id === action.book.id) {
action.book.quantity = book.quantity + 1;
books.splice(index, 1);
}
});
return {
...state,
books: [...state.books, action.book],
total: state.total + 1,
};
default:
return state;
}
};
export default cartItems;
Now we have to connect the component to the store.
To do this you have to do two things, the first one is to pass the store to all the components of the application. For that i use a Provider in my app.js:
//assets/js/app.jsimport React from 'react';
...
...import { Provider } from 'react-redux';
import { createStore } from 'redux';...import cartItems from './reducers/cart';
...class App extends React.Component {
render () {
const store = createStore(cartItems);
return (
<Provider store={store}>
<Router>
<NavBar />
<Switch>
...
...
</Switch>
</Router>
</Provider>
);
}
}
ReactDOM.render(
<App text='' />,
document.getElementById('root')
);
The second thing is to really connect the component to the store using the connect() function of Redux Api.
Let’s imagine we have a button that allows the user to add the current book to the cart. The AddToCartButton component:
//assets/js/components/AddToCartButton.jsimport React from 'react';
import { connect } from 'react-redux';
import { addToCart } from '../../actions/cart';
class AddToCartButton extends React.Component {
constructor (props) {
super(props);
this.addBookToCart = this.addBookToCart.bind(this);
this.state = {
confirmation: false
};
}
addBookToCart () {
//mapDispatchToProps
this.props.addItemToCart(this.props.book);
this.setState({ confirmation: true });
setTimeout(() => {
this.setState({ confirmation: false });
}, 3000);
}
render () {
return (
<div>
<button type="button" className="btn btn-primary btn-lg btn-block add-cart-btn" onClick={this.addBookToCart}>
<i className="fas fa-shopping-cart"/> Add to cart
</button>
<Confirmation confirmation={this.state.confirmation}/>;
</div>
);
}
}
function Confirmation (props) {
if (props.confirmation === true) {
return (
<div className="alert alert-success add-cart-button-confirmation" role="alert">
Item added to cart
</div>
);
}
return (<div/>);
}
const mapDispatchToProps = {
addItemToCart: addToCart
};
export default connect({}, mapDispatchToProps)(AddToCartButton);
The argumentmapDispatchToProp
deals with dispatch function of Store. This means that when in my component i call addItemToCart then the action AddToCart is dispatched.
Now every time the user clicks on the button the state changes.
We just have to add AddToCartButton to Book component:
//assets/js/components/Book.jsimport React from "react";
import BookImage from "./BookImage";
import AddToCartButton from "../Cart/AddToCartButton";
export default class Book extends React.Component {
...
...
render () {
let book = this.props.book;
return (
...
...
<div className="row">
<div className="col-sm-2">
<div className="row add-cart-button">
<AddToCartButton book={book} />
</div>
</div>
</div>
...
... );
}
}
Now it’s easy to use the state elsewhere. For example if in the NavBar we want to show the number of items in the shopping cart:
//assets/js/components/Navbar.jsimport React from 'react';
import {Link} from "react-router-dom";
import { connect } from 'react-redux';
class NavBar extends React.Component {
...
...
render () {
return(
<nav className="navbar navbar-expand-lg navbar-light bg-light rounded">
<div className="collapse navbar-collapse">
<ul className="navbar-nav text-right">
<li className="nav-item">
<Link to="/bookstore"><i className="fas fa-home"/> Home</Link>
</li>
<li className="nav-item">
<i className="fas fa-shopping-cart"/> {this.props.cart.total}
</li>
</ul>
</div>
</nav>
);
}
}
function mapStateToProps (state) {
return { cart: state };
}
export default connect(mapStateToProps, {})(NavBar);
mapStateToProps
allow to subscribe to Redux store updates. This means that any time the store is updated, cart
property will be updated too. In this way the cart object is part of the component’s internal state.
Day 9 — Boost your app with HTTP/2 using Vulcain
As described in the official documentation
Vulcain is a brand new protocol using HTTP/2 Server Push to create fast and idiomatic client-driven REST APIs.
Created to fix performance bottlenecks impacting web APIs: over fetching, under fetching, the n+1 problem…
The Preload
HTTP header introduced by Vulcain can be used to ask the server to immediately push resources related to the requested one using HTTP/2 Server Push.
Let’s see how to implement it in our application.
Imagine that we want to load the comments that users have left on a book.
On the backend side we only have to create a one-to-many relationship between entity Book and entity Comment.
//src/Entity/Book.php<?php declare(strict_types=1);
namespace App\Entity;
...
.../**
* @ApiResource(attributes={"pagination_items_per_page"=10}, mercure=true)
*
* @ORM\Entity(repositoryClass="App\Repository\BookRepository")
*/
final class Book
{
...
... /**
* @ApiSubresource()
*
* @ORM\OneToMany(targetEntity="App\Entity\Comment", mappedBy="book")
*/
private $comments;
...
...}
Note that the $comments
property is a ApiSubresource
.
//src/Entity/Comment.php<?phpdeclare(strict_types=1);namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
/**
* @ApiResource(attributes={"order"={"createdAt": "DESC"}})
* @ORM\Entity(repositoryClass="App\Repository\CommentRepository")
*/
final class Comment
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $name;
/**
* @ORM\Column(type="string", length=255)
*/
private $title;
/**
* @ORM\Column(type="string", length=255)
*/
private $text;
/**
* @ORM\Column(type="datetime")
*/
private $createdAt;
/**
* @ORM\ManyToOne(targetEntity="App\Entity\Book", inversedBy="comments")
* @ORM\JoinColumn(nullable=false)
*/
private $book;
...
...
}
After adding some fixtures
#fixtures/book.yaml...
...App\Entity\Comment:
comment_{1..200}:
name: <name()>
title: <sentence(4, true)>
text: <text()>
createdAt: <DateTime('now')>
book: '@book*'
if we request the resource:
https://localhost:8443/books/1
The response body is:
{
"@context": "/contexts/Book",
"@id": "/books/1",
"@type": "Book",
"image": "/media_objects/1",
"id": 1,
"title": "Recusandae asperiores accusamus nihil repellat.",
"description": "Doloribus nisi placeat cumque est ducimus temporibus. Saepe architecto unde non dicta. Exercitationem aut porro sed magni cupiditate sit vitae.",
"author": "Kaylee Will",
"isbn": "9798643071181",
"stock": 1,
"price": 16.26,
"comments": [
"/comments/31",
"/comments/49",
"/comments/68",
"/comments/138",
"/comments/160"
]
}
The Api Platform distribution I’m using for this post is already configured to use Vulcain.
On the React side, we can use the Preload HTTP header introduced by Vulcain to ask the server to immediately push resources related to the requested one using HTTP/2 Server Push.
//assets/js/components/BookPage.jsimport React from "react";import Book from "../Book/Book";
import Loader from "../Common/Loader";
import CommentList from "../Comment/CommentList";
export default class BookPage extends React.Component {
constructor (props) {
super(props);
this.state = {
book: '',
isLoaded: false,
imagePath: '',
comments: []
};
this.fetchJson = this.fetchJson.bind(this);
this.updateComments = this.updateComments.bind(this);
}
async fetchJson (url, opts = {}) {
const resp = await fetch(url, opts);
return resp.json();
}
async componentDidMount () {
//fetch book
const bookJSON = await this.fetchJson('/books/' + this.props.match.params.id, {headers: {Preload: '/comments/*'}});
this.setState({book: bookJSON, isLoaded: true});
//fetch comments
bookJSON.comments.forEach(async commentURL => {
const comment = await this.fetchJson(commentURL);
this.updateComments(comment);
});
...
...
}
updateComments (comment) {
let comments = this.state.comments;
comments.push(comment);
this.setState({comments: comments});
}
render () {
const isLoaded = this.state.isLoaded;
let imagePath = '';
if (this.state.imagePath !== '') {
imagePath = this.state.imagePath;
}
let comments = '';
if (this.state.comments.length > 0) {
comments = <CommentList comments={this.state.comments}/>;
}
if (isLoaded) {
return (
<main role="main">
<Book book={this.state.book} imagePath={imagePath} />
{comments}
</main>
);
}
return (
<main role="main">
<div className="jumbotron">
<Loader/>
</div>
</main>);
}
}
Thanks to HTTP/2 multiplexing, pushed responses will be sent in parallel.
In addition to /book/1
, a Vulcain server will use HTTP/2 Server Push to send the related comments resources.
The CommentList component will be updated when receiving the receipt of the list of comments.
//assets/js/components/CommentList.jsimport React from "react";
import CommentItem from "./CommentItem";
export default class CommentList extends React.Component {
constructor (props) {
super(props);
this.state = {
comments: []
};
}
render () {
return (
<div className="jumbotron">
<h2>Comments</h2>
{
this.props.comments.map((comment) =>
<CommentItem
key={comment.id}
comment={comment}
/>
)
}
</div>
);
}
}
Day 11 — Boost your app with HTTP/2 using Mercure
The distribution of Api Platform is already configured, even for Mercure. In case you are not using it, you can follow the official Symfony documentation.
Mercure is an open protocol designed from the ground to publish updates from server to clients. The Mercure hub dispatches the updates to all connected clients using Server-sent Events (SSE).
For this demo I just add the mercure
option to the Book entity.
//src/Entity/Book.php/**
* @ApiResource(mercure=true)
*
* @ORM\Entity(repositoryClass="App\Repository\BookRepository")
*/
class Book
{
/**
* @var int
*
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;...
...
By doing so, API Platform will dispatch the updates to the Mercure hub every time a Book is created, updated or deleted. Then the hub informs all subscribed clients.
And subscribing to the updates in Javascript is straightforward:
const u = new URL('https://localhost:1337/.well-known/mercure');
u.searchParams.append('topic', 'https://localhost:8443/books/' + this.props.book.id);const es = new EventSource(u);console.log(es);es.onmessage = e => {
const book = JSON.parse(e.data);
this.setState({stock: book.stock});
};
In the above code I subscribed to the Mercure hub for the updates of a single book.
Let’s imagine that we want to inform in real time the user when a book is no longer available.
I can modify the Book component in this way.
import React from "react";...
...export default class Book extends React.Component {
constructor (props) {
super(props);
this.state = {
stock: 0
};
}
componentDidMount () {
this.setState({stock: this.props.book.stock});
const u = new URL('https://localhost:1337/.well-known/mercure');
u.searchParams.append('topic', 'https://localhost:8443/books/' + this.props.book.id);
const es = new EventSource(u);
es.onmessage = e => {
const book = JSON.parse(e.data); this.setState({stock: book.stock});
};
}
render () {
...
... let addToCartButton = (this.state.stock > 0) ? <AddToCartButton book={book} /> : '';
return (
<div className="jumbotron">
...
... <div className="row book-detail">
<StockInfo stock={this.state.stock}/>
</div>
<div className="row">
<div className="col-sm-2">
<div className="row add-cart-button">
{addToCartButton}
</div>
</div>
</div> ...
...
);
}
}
function StockInfo (props)
{
if (props.stock > 0) {
return <button type="button" className="btn btn-success">
<i className="fas fa-check"/>
In stock <span className="badge badge-light">{props.stock}</span>
<span className="sr-only">stock</span>
</button>;
}
return <button type="button" className="btn btn-danger">
<i className="fas fa-check"/>
Out of stock
</button>;
}
Day 12— Unit tests with Jest
To test the javascript side I chose Jest. It’s an open source project maintained by Facebook, and it’s especially well suited for React code testing.
The React and Jest documentations are comprehensive, easy-to-read, full of tips and practical examples. I recommend reading them.
First, we need to install some dependencies via yarn:
$ yarn add — dev jest
$ yarn add — dev jest babel-jest @babel/preset-env @babel/preset-react react-test-renderer
$ yarn add — dev @babel/plugin-transform-runtime
I must also add one line to webpack.config.js to activate babel preset react for compiling JSX and other stuff down to Javascript.
.enableReactPreset()
To test my application I wanted to start testing a simple component, so I started with BookList.
As said previously this component takes care of displaying the received dataset as a list. The idea, therefore, is to verify that the list is displayed correctly.
//assets/js/tests/components/book/book-list.test.jsimport React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import renderer from "react-test-renderer";
import { act } from "react-dom/test-utils";
import BookList from "../../../components/Book/BookList";
//mocking component used from BookList
jest.mock("../../../components/Book/BookListItem", () => {
return function FakeItem (props) {
return (
<li id={props.id} key={props.id}>
{props.title}
</li>
);
};
});
let container = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
const listBook = [
{
id: 1,
title: "title 1",
author: "author 1",
comments: ['comment', 'comment'],
image: '/media_objects/1',
},
{
id: 2,
title: "title 2",
author: "author 2",
comments: ['comment', 'comment'],
image: '/media_objects/1',
}
];
it("should show 2 item", () => {
act(() => {
render(
<BookList books={listBook} />,
container
);
});
expect(container.querySelector("li").textContent).toBe('title 1');
expect(container.querySelector("li:nth-child(2").textContent).toBe('title 2');
});
it('item render correctly', () => {
const tree = renderer
.create(<BookList books={listBook} />)
.toJSON();
expect(tree).toMatchSnapshot();
});
I created a mock of BookListItem. This allows us to exclude the real BookListItem component from the test and focus on the real component to be tested, i.e. BookList.
I also created the stub bookList
, representing the data, and pass it to the component, wrapped into act() method.
And then I checked that the fake data is really present in the list.
expect(container.querySelector("li").textContent).toBe('title 1');
expect(container.querySelector("li:nth-child(2").textContent).toBe('title 2');
});
When i run:
$ yarn jest assets/js/tests/components/book/book-list.test.js
Note that I have also created a second test, which I use to verify, through a snapshot, that the component contains the expected elements.
it('item render correctly', () => {
const tree = renderer
.create(<BookList books={listBook} />)
.toJSON();
expect(tree).toMatchSnapshot();
})
In the project repository you will find some other tests.
Day 13— Functional tests with Panther
Today, I want to write an end-to-end test for my application.
Panther is a convenient standalone library to scrape websites and to run end-to-end tests using real browsers.
First I install some package:
$ composer req --dev tests
$ composer req --dev symfony/panther
In the following test I simulated navigation from the home to the shopping cart via the book page.
<?php
namespace App\Tests\Functional\EndToEnd;
use Symfony\Component\Panther\PantherTestCase;
final class FromHomeToShoppingCartTest extends PantherTestCase
{
public function testNavigateFromHomeToBookPage(): void
{
$client = static::createPantherClient();
//go to homepage
$crawler = $client->request('GET', '/bookstore');
//find books list
$container = $crawler->filter('.jumbotron');
$ul = $container->filter('div')->eq(3)->filter('ul'); //select first book
$li = $ul->filter('li')->eq(0);
//select link
$a = $li->filter('a');
$link = $a->link();
//click on the book item and go to the book page
$crawler = $client->click($link);
//waiting for loading data
$client->waitFor('.book-detail');
//find "add to cart" button
$container = $crawler->filter('.jumbotron');
$div = $container->filter('div')->eq(0);
$addToCartButton = $div->filter('.add-cart-button');
self::assertStringContainsString('Add to cart', $addToCartButton->getText());
//click on the button
$client->executeScript('document.querySelector(".add-cart-btn").click();');
//find and click the shopping cart icon of navabar
$navbar = $crawler->filter('.navbar');
$items = $navbar->filter('div')->eq(0)->filter('ul');
$cartItem = $items->filter('li')->eq(1)->filter('a');
$cartItemLink = $cartItem->link();
$client->click($cartItemLink);
//asserts that we are on shopping cart page
self::assertPageTitleContains('Welcome to the bookstore');
self::assertStringContainsString('CHECKOUT', $crawler->filter('.jumbotron'));
}
}
I run my test:
$ bin/phpunit -c . tests/Functional/EndToEnd/FromHomeToShoppingCartTest.php
Day 14— Some improvement
On the last day I wanted to make some small improvements using React Context.
In the Book component, where the Mercure pushing process is implemented, the URL to subscribe to the hub is a static code.
const u = new URL('https://localhost:1337/.well-known/mercure');
u.searchParams.append('topic', 'https://localhost:8443/books/' + this.props.book.id);
const es = new EventSource(u);
es.onmessage = e => {
const book = JSON.parse(e.data); this.setState({stock: book.stock});
};
Of course it is not a good practice. For example, you can use an env file variable to define the URL and then pass it to your javascript code.
#.envMERCURE_SUBSCRIBE_URL=https://localhost:1337/.well-known/mercure
BOOK_API_URL=https://localhost:8443/
$ yarn add dotenv — save
//webpack.config.js...Encore ...
... .configureDefinePlugin(options => {
var dotenv = require('dotenv');
const env = dotenv.config();
if (env.error) {
throw env.error;
}
...
...module.exports = Encore.getWebpackConfig();
Now I can modify the component Book like this:
const u = new URL(process.env.MERCURE_SUBSCRIBE_URL);
u.searchParams.append('topic', process.env.BOOK_API_URL + 'books/' + this.props.book.id);
const es = new EventSource(u);
es.onmessage = e => {
const book = JSON.parse(e.data);
this.setState({stock: book.stock});
};
And it works, but today I want to try to use the React Context, so I can try passing this value to the component through the Context. Starting from app.js, I will pass this value to the component Book.
The thing is even more interesting because we have to make the Store, the Router and the Context coexist.
So I’m going to edit app.js like this:
import React from 'react';
import ReactDOM from 'react-dom';
import {
BrowserRouter as Router,
Switch,
Route,
} from "react-router-dom";
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import NavBar from './components/Common/NavBar';
import BookPage from "./components/Page/BookPage";
import HomePage from "./components/Page/HomePage";
import CartPage from "./components/Page/CartPage";
import cartItems from './reducers/cart';
require('../css/app.scss');
console.log('app.js');
export const UrlContext = React.createContext();
class App extends React.Component {
render () {
const store = createStore(cartItems);
return (
<Provider store={store}>
<Router>
<NavBar />
<Switch>
<Route exact path="/bookstore">
<HomePage />
</Route>
<Route exact path="/bookstore/cart">
<CartPage />
</Route>
<Route path="/bookstore/book/:id"
render={
(props) =>
<UrlContext.Provider value={{mercure: process.env.MERCURE_SUBSCRIBE_URL, api: process.env.BOOK_API_URL}}>
<BookPage {...props} />
</UrlContext.Provider>
}
/>
</Switch>
</Router>
</Provider>
);
}
}
ReactDOM.render(
<App text='' />,
document.getElementById('root')
);
As you can see I use a provider to pass the value to the other components.
On the other side, I’ve used a Consumer to recover the Context data in the components who needs it.
......import {UrlContext} from "../../app.js";...<UrlContext.Consumer>
{value => <Book book={this.state.book} imagePath={imagePath} mercureUrl={value.mercure} apiUrl={value.api}/>}
</UrlContext.Consumer>
Conclusions
Learning React from scratch isn’t that difficult. Obviously, being a good ReactJs developer is another subject, because, as everything it takes effort and practice. But you do have to start somewhere.… And I hope this (long) post will help those who want to start to use it.
As you have seen, it is very easy to integrate it with Symfony. The most complete, solid and flexible framework PHP.
API Platform is the most obvious choice if you want to expose an API Rest with PHP. You can Leverage its awesome features to develop complex and high performance API-first projects.
Special thanks to my colleague from france.tv, Antoine Buzaud, for the review of this post.