Data tables with Symfony, Hateoas and AngularJS
Recently, I had to create some tables to present data from a Symfony2 REST API, so I decided to write this article to detail the process I used.
I am going to create an API endpoint to retrieve a list of products and a simple table with sorting, and pagination to present the data using AngularJS.
Backend
I created a new Symfony project and installed FOSRestBundle
, then enabled the view listener:
fos_rest:
view:
view_response_listener: 'force'
I installed BazingaHateoasBundle
with the default configuration and created a simple Product
entity:
<?php
namespace Acme\DemoBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
class Product
{
/**
* @ORM\Id()
* @ORM\Column(type="integer")
* @ORM\GeneratedValue()
*/
private $id;
/**
* @ORM\Column(type="string")
*/
private $title;
/**
* @ORM\Column(type="decimal")
*/
private $price;
/**
* @ORM\Column(type="datetime")
*/
private $createdDate;
// Setters and getters go here
}
Here is the endpoint to retrieve a list of products:
<?php
namespace Acme\DemoBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use FOS\RestBundle\Controller\Annotations as Rest;
class ProductController extends Controller
{
/**
* @Rest\Get(path="/api/products")
* @Rest\View()
*/
public function getAllAction()
{
$repository = $this->getDoctrine()->getManager()
->getRepository('AcmeDemoBundle:Product');
return $repository->findAll();
}
}
If I send a JSON GET request to /api/products
I will get something like:
[
{
"id":1,
"title":"Laptop",
"price":500,
"createdDate":"2014-11-05T08:17:15+0100"
}
]
I use the
IdenticalPropertyNamingStrategy
forJMSSerializer
to simplify things
Adding Pagination
First, I need to install the Pagerfanta
library:
$ composer require pagerfanta/pagerfanta
I am leveraging the Hateoas library to add the pagination informations to the resource (that will be taken directly from the pager instance).
The controller action has to be modified to accept two query parameters: page and limit.
<?php
namespace Acme\DemoBundle\Controller;
use Hateoas\Configuration\Route;
use Hateoas\Representation\Factory\PagerfantaFactory;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Component\HttpFoundation\Request;
class ProductController extends Controller
{
/**
* @Rest\Get(name="product_list", path="/api/products", defaults={"_format" = "json"})
* @Rest\View()
*/
public function getAllAction(Request $request)
{
$limit = $request->query->getInt('limit', 10);
$page = $request->query->getInt('page', 1);
$queryBuilder = $this->getDoctrine()->getManager()->createQueryBuilder()
->select('p')
->from('AcmeDemoBundle:Product', 'p');
$pagerAdapter = new DoctrineORMAdapter($queryBuilder);
$pager = new Pagerfanta($pagerAdapter);
$pager->setCurrentPage($page);
$pager->setMaxPerPage($limit);
$pagerFactory = new PagerfantaFactory();
return $pagerFactory->createRepresentation(
$pager,
new Route('product_list', array('limit' => $limit, 'page' => $page))
);
}
}
Adding sorting
To handle sorting, a new query parameter in the form sorting[column]=direction
must be accepted, allowing to support sorting by multiple columns.
I also refactored the pager creation logic and put it into the ProductRepository
.
<?php
namespace Acme\DemoBundle\Controller;
use Hateoas\Configuration\Route;
use Hateoas\Representation\Factory\PagerfantaFactory;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Component\HttpFoundation\Request;
class ProductController extends Controller
{
/**
* @Rest\Get(name="product_list", path="/api/products")
* @Rest\View()
*/
public function getAllAction(Request $request)
{
$limit = $request->query->getInt('limit', 10);
$page = $request->query->getInt('page', 1);
$sorting = $request->query->get('sorting', array());
$productsPager = $this->getDoctrine()->getManager()
->getRepository('AcmeDemoBundle:Product')
->findAllPaginated($limit, $page, $sorting);
$pagerFactory = new PagerfantaFactory();
return $pagerFactory->createRepresentation(
$productsPager,
new Route('product_list', array(
'limit' => $limit,
'page' => $page,
'sorting' => $sorting
))
);
}
}
The product repository:
<?php
namespace Acme\DemoBundle\Repository;
use Doctrine\ORM\EntityRepository;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
class ProductRepository extends EntityRepository
{
public function findAllPaginated($limit, $page, array $sorting = array())
{
$fields = array_keys($this->getClassMetadata()->fieldMappings);
$queryBuilder = $this->createQueryBuilder('p');
foreach ($fields as $field) {
if (isset($sorting[$field])) {
$direction = ($sorting[$field] === 'asc') ? 'asc' : 'desc';
$queryBuilder->addOrderBy('p.'.$field, $direction);
}
}
$pagerAdapter = new DoctrineORMAdapter($queryBuilder);
$pager = new Pagerfanta($pagerAdapter);
$pager->setCurrentPage($page);
$pager->setMaxPerPage($limit);
return $pager;
}
}
If I send a JSON GET request to /api/products?sorting[price]=asc&sorting[name]=asc
, I get the products sorted by price and title in ascending order.
{
"page":1,
"limit":10,
"pages":3,
"total":23,
"_links":{
"self":{
"href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=1&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
},
"first":{
"href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=1&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
},
"last":{
"href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=3&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
},
"next":{
"href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=2&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
}
},
"_embedded":{
"items":[
{
"id":31,
"title":"Phone",
"price":200,
"createdDate":"2012-11-05T08:17:15+0100"
},
// more items here
]
}
}
I didn't add a self link to the Product as it's not needed because we just display a product list in this example.
The _links
of the representation itself won't be used by the AngularJS application but will be useful for other http clients using the API.
Frontend
For the frontend entry point, I created a new controller and a template containing the bootstrapping logic for the javascript application:
<?php
namespace Acme\DemoBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class AppController extends Controller
{
/**
* @Route(name="index", path="/")
* @Template()
*/
public function indexAction()
{
return array();
}
}
For simplicity, the template is just an HTML5 boilerplate with the dependencies directly fetched from the CDNs. Because the application has only one view, I display the table directly there.
For the table management, I use ngTable
which is the one I prefer because it's easy to customize. I also included Underscore.js
which has some useful utility functions.
<!doctype html>
<html class="no-js" lang="" ng-app="app">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Products</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css">
</head>
<body>
<h1 class="text-center">Products</h1>
<div class="col-lg-8 col-lg-offset-2">
{% verbatim %}
<table ng-controller="ProductController" ng-table="tableParams" class="table">
<tr ng-repeat="product in $data">
<td data-title="'Title'" sortable="'title'">
{{product.title}}
</td>
<td data-title="'Price'" sortable="'price'">
{{product.price | currency}}
</td>
<td data-title="'Created Date'" sortable="'createdDate'">
{{product.createdDate | date}}
</td>
</tr>
</table>
{% endverbatim %}
</div>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.0/angular.min.js"></script>
<script src="//cdn.rawgit.com/esvit/ng-table/master/ng-table.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js"></script>
<script>
// Set the base url of the API as a property of the 'config'
// service to access it in the app
angular.module('app', ['ngTable'])
.constant('config', {
baseUrl: '{{ app.request.getBaseURL() }}/api'
});
</script>
<script src="{{ asset('bundles/acmedemo/js/product.js') }}"></script>
</body>
</html>
The
sortable
attribute on thetd
elements corresponds to the column's name in theorderBy
table params
I can now create the ProductController
to set up the table which retrieves data from the backend API:
(function () {
angular
.module('app')
.controller('ProductController', ProductController);
function ProductController($scope, $location, $http, config, ngTableParams) {
this.$http = $http;
this.config = config;
// Default values, usually fetched from the url
// to allow direct access to the filtered table
var sorting = {title: 'asc'};
var page = 1;
var count = 10;
// Setup and publish the table on the scope
$scope.tableParams = new ngTableParams({page: page, count: count, sorting: sorting},
{
total: 0,
getData: function ($defer, tableParams) {
this.fetchProducts(this.createQuery(tableParams), tableParams, $defer);
}.bind(this)
}
);
}
/**
* Create the query object we need to send to our API endpoint
* from the table params.
*/
ProductController.prototype.createQuery = function (tableParams) {
var query = {
page: tableParams.page(),
limit: tableParams.count()
};
// The orderBy is in the form ["+state", "-title"]
// where '+' represents ascending and '-' descending
// We need to convert it to the format accepted by our API
_.each(tableParams.orderBy(), function (dirColumn) {
var key = 'sorting[' + dirColumn.slice(1) + ']';
query[key] = (dirColumn[0] === '+') ? 'asc' : 'desc';
});
return query;
};
/**
* Fetch the product list by sending the HTTP request to the products endpoint.
*/
ProductController.prototype.fetchProducts = function (query, tableParams, $defer) {
this.$http({
url: this.config.baseUrl + '/products',
method: 'GET',
params: query
}).then(
// Success callback
function (response) {
var data = response.data;
var products = data._embedded.items;
// Set the total number of products
tableParams.total(data.total);
// Resolve the defer with the products array
$defer.resolve(products);
}
);
}
})();
To improve the code I could move the HTTP interaction into a separate service, for example ProductApi
and inject it in the ProductController
.
Then, the ProductController
can be easily abstracted into a TableController
used as base for all table controllers.