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"
   }
]

Note: I use the IdenticalPropertyNamingStrategy for JMSSerializer 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 bootstraping 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 the td elements correponds to the column's name in the orderBy 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.

demo_symfony_table.gif

comments powered by Disqus