NgAdminGeneratorBundle: Create a JavaScript Admin Panel and a REST API for Symfony2 Apps in Minutes
We are all, at marmelab, keen on rapid prototyping and iterative enhancement. Thus, we have been working on many developers tools like ng-admin, an Angular.js-powered admin GUI for RESTful APIs, to help us deliver value to our customers faster. Many of our projects are Symfony2 based, with an model layer powered by the Doctrine ORM. We missed a tool to generate ng-admin configuration based on these entities and a few conventions. So we built it: it's called NgAdminGeneratorBundle.
Creating a REST API with Stan Lemon's RestBundle
Let's imagine we want to track our progress at foosball ("baby-foot" in French) through a mobile application. This way, we would be able to scientifically reward the best marmelab player. Our model would be pretty simple:
- Locations: we got two offices, in Nancy and Paris,
- Players: with name and location,
- Games: with date, players and result.
Bootstrapping a Symfony2 application
We decided to use Symfony2 for this API. So, let's create the application:
composer create-project symfony/framework-standard-edition Foosball-tracker
No need to install Acme bundle: we are going to create a bundle from scratch. Give composer all required parameters, and create our brand new bundle via generate:bundle
command:
./app/console generate:bundle \
--namespace=marmelab/FoosballTrackerBundle \
--dir=src \
--no-interaction
Of course, you can execute the command without any options to enter into the interactive process.
Generating our domain classes
Let's create our three entities for our domain with the doctrine:generate:entity
command. For instance, for the Player
entity (output has been partially truncated for readability):
The Entity shortcut name: marmelabFoosballTrackerBundle:Player
Determine the format to use for the mapping information.
Configuration format (yml, xml, php, or annotation) [annotation]:
Instead of starting with a blank entity, you can add some fields now.
Note that the primary key will be added automatically (named id).
New field name (press <return> to stop adding fields): firstName
Field type [string]:
Field length [255]:
New field name (press <return> to stop adding fields): lastName
Field type [string]:
Field length [255]:
New field name (press <return> to stop adding fields): location_id
Field type [integer]:
Do you want to generate an empty repository class [no]?
Generating the entity code: OK
You can now start using the generated code!
We now have our three entities. Let's add relationships between them. First, link player with its location:
// Entity/Player.php
/**
* @ORM\Column(name="location_id", type="integer", nullable=false)
*/
protected $location_id;
/**
* @ORM\ManyToOne(targetEntity="marmelab\FoosballTrackerBundle\Entity\Location")
* @ORM\JoinColumn(name="location_id", referencedColumnName="id")
**/
private $location;
Note: we excluded the serialized object and exposed only the location_id
field. This is indeed a (temporary) limitation of our configuration generator bundle. Our API should only return ids for references, and not entire object.
In the next days, we are going to fix it using Restangular entity transformers.
/**
* @ORM\ManyToMany(targetEntity="marmelab\FoosballTrackerBundle\Entity\Player", mappedBy="games")
**/
private $players;
Setup the model in the database:
./app/console doctrine:schema:update --force
the domain is set, we can now set our API.
Generating a REST API
Let's use Stan Lemon's REST Bundle to quickly generate our API based on the Doctrine entities.
composer require stanlemon/rest-bundle
We register it into the app/AppKernel.php
file (don't forget JMSSerializer dependency):
public function registerBundles()
{
$bundles = array(
// ...
new marmelab\FoosballTrackerBundle\marmelabFoosballTrackerBundle(),
new JMS\SerializerBundle\JMSSerializerBundle(),
new \Lemon\RestBundle\LemonRestBundle(),
);
}
To unleash the power of this bundle, we simply add the entities to the lemon_rest
section in config.yml
:
lemon_rest:
envelope: Lemon\RestBundle\Object\Envelope\FlattenedEnvelope
mappings:
- { name: location, class: marmelab\FoosballTrackerBundle\Entity\Location }
- { name: player, class: marmelab\FoosballTrackerBundle\Entity\Player }
- { name: game, class: marmelab\FoosballTrackerBundle\Entity\Game }
We set a mapping to link all API routes to our entities. We also specified the envelope
to use. Envelope is simply a structure to return the final payload to the serializer. As we are going to use some AngularJS based administration, we have to use the FlattenedEnvelope
.
Finally, last step, we add the lemon_rest
API routes to routing.yml
:
lemon_rest:
resource: "@LemonRestBundle/Resources/config/routing.yml"
prefix: /api
That's all! The API is now fully configured! We can test it using curl
, or better, httpie, a more human-friendly CLI tool that we love:
$ http --body --json GET localhost/api/location
[]
$ http --body --json POST localhost/api/location name="Nancy"
{
"id": 1,
"name": "Nancy"
}
$ http --body --json GET localhost/api/location
[
{
"id": 1,
"name": "Nancy"
}
]
Hurray! In only 10 minutes, we already set a fully functional API. Let's build our administration panel now!
Install admin panel without efforts
Several administration panel bundles exist. You can for instance use the famous SonataAdminBundle. Yet, this bundle, even if powerful, forces you to define a web GUI with Symfony2 objects ; we feel it's more natural to use JavaScript for that. That's why marmelab initiated the ng-admin project, an opiniated and convention oriented administration panel for REST APIs, written in Angular.js. So, let's use it on our brand new API!
Installing ng-admin
First, we need to install it, using bower. But let's configure Bower first by creating a .bowerrc
file at the project root:
{
"directory": "web/components"
}
This way, Bower will place all retrieved dependencies in the document root, allowing us to access it directly from the templates.
bower init
bower install ng-admin --save
If you take a look at the ng-admin documentation, you will quickly see that we should write a JS configuration to map the the REST API resources. But we've just done that already!
Generating ng-admin configuration automatically
Fortunately, we worked the entire last week on a new bundle called NgAdminGeneratorBundle. This tool allows to bootstrap the ng-admin
configuration file using a single command. We simply base this generation on all conventions used by Stan's REST bundle. Of course, the generated configuration will need further tweaking after the initial set up, but it will be much easier to start with.
First, install the bundle the usual way:
composer require marmelab/ng-admin-generator-bundle
public function registerBundles()
{
// ...
if (in_array($this->getEnvironment(), array('dev', 'test'))) {
// ...
$bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();
$bundles[] = new marmelab\NgAdminGeneratorBundle\marmelabNgAdminGeneratorBundle(),
}
);
}
No need to add it in all environments: you would generate ng-admin configuration only on your development computer. This bundle enables the following command:
Before generating our configuration, we first have to exclude our location
object from serializing, as explained above:
/**
* @ORM\ManyToOne(targetEntity="marmelab\FoosballTrackerBundle\Entity\Location")
* @ORM\JoinColumn(name="location_id", referencedColumnName="id")
* @Serializer\Exclude()
**/
private $location;
Another limitation is the support of ManyToMany
relationships. It will be improved over time, but for now, it is likely
that you have to configure them manually.
./app/console ng-admin:configuration:generate > web/js/ng-admin.conf.js
The JavaScript configuration for ng-admin is now bootstrapped, but we still need to create a template to load it:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Angular admin</title>
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="components/ng-admin/build/ng-admin.min.css" />
<script src="components/angular/angular.js"></script>
<script
src="components/ng-admin/build/ng-admin.min.js"
type="text/javascript"
></script>
<script src="js/ng-admin.config.js" type="text/javascript"></script>
</head>
<body ng-app="myApp">
<div ui-view></div>
</body>
</html>
That's enough to get the entire administration up and running:
Yes, without any extra configuration, we already have a nice looking UI binded to our model.
Under the hood of NgAdminGeneratorBundle
If you want to dig further down into the ng-admin configuration, let's focus on some parts of the generated configuration.
The following code will be called for every request you do, simply converting URL parameters into the expected REST bundle format. For instance, pagination is done with _page
and _perPage
parameters in ng-admin
, but should be _start
and _end
in Stan's bundle.
// use custom query parameters function to format the API request correctly
app.config(function(RestangularProvider) {
RestangularProvider.addFullRequestInterceptor(function(
element,
operation,
what,
url,
headers,
params
) {
if (operation == "getList") {
// custom pagination params
params._start = (params._page - 1) * params._perPage;
params._end = params._page * params._perPage;
delete params._page;
delete params._perPage;
// custom sort params
if (params._sortField) {
params._orderBy = params._sortField;
params._orderDir = params._sortDir;
delete params._sortField;
delete params._sortDir;
}
// custom filters
if (params._filters) {
for (var filter in params._filters) {
params[filter] = params._filters[filter];
}
delete params._filters;
}
}
return { params: params };
});
});
Then we get several app.config
call, one per entity. As the logic is the same, we are just going to look the PlayerAdmin
:
player.menuView().icon('<span class="glyphicon glyphicon-user"></span>');
For each entity, the app fetches a particular icon. Here, it displays a user glyphicon for the player
entity. NgAdminGeneratorBundle automatically maps many entity names to Bootstrap icons (see the full overview of icons mapping).
Next comes the definition for the dashboard view. NgAdminGeneratorBundle limits the number of displayed fields to three for readability reasons. Indeed, getting too much data here would make the look and feel quite dirty.
player
.dashboardView()
.fields([
nga.field("id", "number"),
nga.field("first_name"),
nga.field("last_name"),
]);
Then comes the listView
. Note the three default actions we display (show, edit, and delete).
player
.listView()
.fields([
nga.field("id", "number"),
nga.field("first_name"),
nga.field("last_name"),
nga
.field("location_id", "reference")
.targetEntity(nga.entity("location"))
.targetField(nga.field("name")),
])
.listActions(["show", "edit", "delete"]);
We also mapped the location
field to match its name
field. We defined an heuristic to match the best field based on
field names. Full list
is available in repository.
The creation view doesn't display id
:
player.creationView().fields([
nga.field("first_name"),
nga.field("last_name"),
nga
.field("location_id", "reference")
.targetEntity(nga.entity("location"))
.targetField(nga.field("name")),
]);
The edition view is a little bit heavier. We just display all entity fields. All of them are editable, except of course
for the id
.
player.editionView().fields([
nga
.field("id", "number")
.editable(false)
.isDetailLink(false),
nga.field("first_name"),
nga.field("last_name"),
nga
.field("location_id", "reference")
.targetEntity(nga.entity("location"))
.targetField(nga.field("name")),
]);
The show view is quite the same as the edition view. We may have used player.editionView().fields()
to retrieve the
already defined fields, but repeating the code gives more flexibility to tweak it further.
player.showView().fields([
nga.field("id", "number").isDetailLink(false),
nga.field("first_name"),
nga.field("last_name"),
nga
.field("location_id", "reference")
.targetEntity(nga.entity("location"))
.targetField(nga.field("name")),
]);
We did it! In less than 30 minutes, we've successfully configured an API and an administration panel for recording our Foosball progress!
Don't hesitate to open an issue for every question or issue you may encounter.Your feedback is indeed the most valuable way to orientate the future of this project.
PS: All of this code is under MIT licence. Do whatever you want with it! ;)