Simple RESTful pagination with Symfony and Angularjs
Suppose we are going to display a list of tasks
What we currently have at our stack is
- Symfony 3.4
- FOSRestBundle 2.3
- JMSSerializerBundle 2.3
- Angular 1.6.8
- Angular UI Router 1.0.15
- Twitter Bootstrap
There is a state task-list
which is pointed to TaskController
(function () {
'use strict';
var app = angular.module('taskApp');
app.config(function($stateProvider) {
$stateProvider.state('task-list', {
url: '/tasks',
controller: 'TaskController',
templateUrl: '/src/task/task-list.html',
});
});
app.controller('TaskController', function($scope, TaskResource) {
$scope.tasks = [];
TaskResource.list().$promise.then(function(response) {
$scope.tasks = response.tasks;
});
});
})();
task-list.html
template is simple as this
<div class="task-list">
<div class="task" ng-repeat="task in tasks">
<h3>{{ task.title }}</h3>
</div>
</div>
and TaskResource
is an angular resource with the only method list
which retrieves the list of Tasks
using RESTful API
(function () {
'use strict';
angular.module('taskApp').factory('TaskResource', function($resource, CONFIG) {
return $resource(null, {}, {
list: {
method: 'GET',
url: CONFIG.API_URL + '/tasks',
},
});
});
})();
On a backend we have a Symfony controller with the only method taskListAction
which is responsible for handling GET
request to /tasks
URI.
<?php
namespace AppBundle\Controller;
use FOS\RestBundle\Controller\Annotations\Get;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\View\View;
/**
* Class TaskController
* @package AppBundle\Controller
*/
class TaskController extends FOSRestController
{
/**
* @Get("tasks")
*
* @return View
*/
public function taskListAction()
{
$repo = $this->getDoctrine()->getRepository('AppBundle:Task');
$tasks = $repo->findAll();
$view = $this->view(['tasks' => $tasks]);
$view->getContext()->setGroups(['task_list']);
return $view;
}
}
This method returns an instance of FOS\RestBundle\View\View
which contains the list of Tasks
. Afterwards it is automatically handled by FOSRestBundle
and converted into json
format according to following configuration
AppBundle/Resources/config/serializer/Entity.Task.yml
AppBundle\Entity\Task:
exclusion_policy: ALL
properties:
id:
expose: true
groups:
- task_list
title:
expose: true
groups:
- task_list
And Task
entity itself looks like this
<?phpnamespace AppBundle\Entity;use Doctrine\ORM\Mapping as ORM;/**
* Class Thought
* @package AppBundle\Entity
*
* @ORM\Entity
*/
class Task
{
/**
* @var int
*
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*/
private $id; /**
* @var string
*
* @ORM\Column(type="string", length=128)
*/
private $title; /**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
} /**
* Set title.
*
* @param string $title
*
* @return Task
*/
public function setTitle($title)
{
$this->title = $title; return $this;
} /**
* Get title.
*
* @return string
*/
public function getTitle()
{
return $this->title;
}
}
So /tasks
endpoint returns json
response in following format
{
"tasks":[
{
"id":1,
"title":"Task 1"
},
{
"id":2,
"title":"Task 2"
},
{
"id":3,
"title":"Task 3"
}
]
}
Now the problem is obvious: if task list becomes huge we would like to split it into several pages and display only one page to user along with a pager bar. Fortunately major part of what we are going to do is already implemented by others, we just need to do a few things to make it work. First of all, there is a well-known KnpPaginatorBundle, which can be used for handling pagination on a backend. Install and enable the bundle and then modify TaskController::taskListAction
/**
* @Get("tasks")
*
* @param Request $request
* @return View
*/
public function taskListAction(Request $request)
{
/** @var EntityManager $em */
$em = $this->getDoctrine()->getManager();
$qb = $em->createQueryBuilder()
->select('t')
->from('AppBundle:Task', 't');
$paginator = $this->get('knp_paginator');
$pagination = $paginator->paginate($qb, $request->get('page', 1), 5);
$view = $this->view($pagination);
$view->getContext()->setGroups(['task_list', 'pagination']);
return $view;
}
What has changed here? Instead of querying the whole list of tasks we create an instance of QueryBuilder
and pass it to Knp\Component\Pager\Paginator::paginate
method as first argument. Second argument is page
passed as optionalGET
parameter and representing current page number (first by default). Third one is page size which is hardcoded in this case but can be retrieved from request/configuration/elsewhere. What this method does is it modifies the QueryBuilder
by appending limit
and offset
instructions (particularly calling it's setMaxResults
and setFirstResult
methods), executes the query and returns SlidingPagination
object containing all necessary information about pages and items. Then it is passed to a view layer together with the context data — pay attention to $view->getContext()->setGroups([‘task_list’, ‘pagination’]);
line.
Now to properly handle serialization of a new data we need to create configuration for SlidingPagination
class. First we configure JMSSerializerBundle
to be able to provide serialization configuration for classes in third-party bundles
jms_serializer:
metadata:
directories:
KnpPaginatorBundle:
namespace_prefix: "Knp\\Bundle\\PaginatorBundle"
path: "%kernel.root_dir%/KnpPaginatorBundle/Resources/config/serializer"
And the app/KnpPaginatorBundle/Resources/config/serializer/Pagination.SlidingPagination.yml
file
Knp\Bundle\PaginatorBundle\Pagination\SlidingPagination:
exclusion_policy: ALL
virtual_properties:
getItems:
expose: true
serialized_name: items
type: array
groups:
- pagination
getPageCount:
expose: true
serialized_name: page_count
type: integer
groups:
- pagination
getItemNumberPerPage:
expose: true
serialized_name: items_per_page
groups:
- pagination
getTotalItemCount:
expose: true
serialized_name: total_item_count
groups:
- pagination
getCurrentPageNumber:
expose: true
serialized_name: current_page
groups:
- pagination
Actually we are not going to use all these properties in this example. But all of them contain reasonable information and might be useful for any additional needs. Notice that we specify pagination
as a group for each property — it is necessary if we are going to apply pagination functionality to other resources and don’t want to modify this configuration each time we add a new serialization groups (user_list
, tag_list
etc).
Now both /tasks
and /tasks?page=1
endpoint return data regarding the first page of tasks
{
"current_page":1,
"items":[
{
"id":1,
"title":"Task 1"
},
{
"id":2,
"title":"Task 2"
},
{
"id":3,
"title":"Task 3"
},
{
"id":4,
"title":"Task 4"
},
{
"id":5,
"title":"Task 5"
}
],
"page_count":2,
"items_per_page":5,
"total_item_count":8
}
and /tasks?page=2
about the second etc.
This is mainly it regarding server-side. Now lets modify our frontend application to be able to work with a new backend.
First lets create a pagination
component
// src/component/pagination/pagination.js
(function () {
'use strict';
angular.module('taskApp').component('pagination', {
templateUrl: 'src/component/pagination/pagination.html',
bindings: {
data: '<'
},
controller: function($location) {
var $ctrl = this;
$ctrl.$onChanges = function(changes) {
if (changes.data) {
$ctrl.pageCount = parseInt(changes.data.currentValue.page_count);
$ctrl.currentPage = parseInt(changes.data.currentValue.current_page);
$ctrl.pagesRange = getPagesRange($ctrl.pageCount, $ctrl.currentPage);
}
};
$ctrl.pageClick = function(page) {
$location.search({page: page}).replace();
};
var getPagesRange = function(pageCount, currentPage) {
function createList(min, max) {
var list = [];
for (var i = min; i <= max; i++) {
list.push(i);
}
return list;
}
if (pageCount <= 5) {
return createList(1, pageCount);
}
var left = currentPage - 2;
var right = currentPage + 2;
if (left < 1) {
right += Math.abs(left) + 1;
left = 1;
} else if (right > pageCount) {
var diff = Math.abs(right - pageCount);
right = pageCount;
left -= diff;
}
return createList(left, right);
};
},
});
})();
and the teamplate
<ul class="pagination" ng-if="$ctrl.pageCount > 1">
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === 1 }">
<a class="page-link" ng-click="$ctrl.pageClick(1)">««</a>
</li>
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === 1 }">
<a class="page-link" ng-click="$ctrl.pageClick($ctrl.currentPage - 1)">«</a>
</li>
<li class="page-item" ng-repeat="page in $ctrl.pagesRange" ng-class="{ 'active': $ctrl.currentPage === page }">
<a class="page-link" ng-click="$ctrl.pageClick(page)">{{ page }}</a>
</li>
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === $ctrl.pageCount }">
<a class="page-link" ng-click="$ctrl.pageClick($ctrl.currentPage + 1)">»</a>
</li>
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === $ctrl.pageCount }">
<a class="page-link" ng-click="$ctrl.pageClick($ctrl.pageCount)">»»</a>
</li>
</ul>
What is going on here? In a few words this component accepts pagination object which we receive from server as an argument, calculates number or pages (max five) and displays it. Roughly speaking this component is javascript implementation of PaginationHelper::render
method.
Bootstrap pagination component is used here. When any page button is clicked, pageClick
method is called which updates current location with a new page
parameter.
Now modify controller
(function () {
'use strict';
var app = angular.module('taskApp');
app.config(function($stateProvider) {
$stateProvider.state('task-list', {
url: '/tasks?page',
controller: 'TaskController',
templateUrl: '/src/task/task-list.html',
});
});
app.controller('TaskController', function($scope, TaskResource, $stateParams) {
$scope.pagination = {
current_page: $stateParams.page || 1,
};
TaskResource.list({page: $scope.pagination.current_page}).$promise.then(function(response) {
$scope.pagination = response;
});
});
})();
and a template
<div class="task-list">
<div class="task" ng-repeat="task in pagination.items">
<h3>{{ task.title }}</h3>
</div>
<pagination data="pagination"></pagination>
</div>
Nothing really to add here, just pay attention that page
parameter is added to state definition and we use $scope.pagination
object instead of $scope.tasks
. Also $scope.pagination.current_page
is passed to TaskResource.list
method to add page
information to a call of RESTful endpoint.
At this moment we have very simple but extensible implementation of RESTful resources pagination with Symfony and Angularjs.